4. Servicios web

Los servicios web proporcionan una forma estándar de interoperabilidad entre diferentes aplicaciones, posiblemente heterogéneas. En este tema, veremos cómo crear y cómo acceder a estos servicios, así como su relación con el estándar HTTP. Además, abordaremos el estudio de la arquitectura de servicios conocida como REST (por el inglés, representation state transfer), en la que los agentes proporcionan una interfaz con una semántica uniforme (que, básicamente, se corresponde con las operaciones para crear, recuperar, actualizar y borrar recursos), en lugar de interfaces arbitrarias o específicas de una aplicación concreta. En un primer momento, nos centraremos en el acceso a servicios web ofrecidos por terceros; posteriormente, veremos cómo definir nuestros propios servicios web.

Importante

Las habilidades que deberías adquirir con este tema incluyen las siguientes:

  • Saber usar Ajax, especialmente a través de la API Fetch, como una tecnología para crear aplicaciones de una única página.

  • Saber crear y encadenar promesas en un programa de JavaScript.

  • Entender la política del mismo origen y cómo superarla con la técnica CORS.

  • Entender los fundamentos de la arquitectura REST.

  • Comprender los fundamentos del protocolo HTTP y la configuración cliente-servidor.

  • Diferenciar los distintos componentes de un navegador.

  • Saber implementar aplicaciones mashups que accedan e integren servicios web de terceros.

4.1. El protocolo HTTP

HTTP (por hypertext transfer protocol) es el protocolo de comunicación (a nivel de capa de aplicación) diseñado para permitir el envío de información en la world wide web entre distintos ordenadores. Sus primeras versiones fueron desarrolladas en la década de los noventa a partir de los trabajos de Tim Berners Lee y su equipo (los creadores de la web). Las diferentes versiones del estándar son coordinadas por el HTTP Working Group de la Internet Engineering Task Force. HTTP/1.1 se publicó en 1997, HTTP/2 en 2015 y HTTP/3 en 2018, aunque tanto navegadores como servidores siguen soportando las versiones antiguas y son capaces de adaptarse para usar el mismo protocolo: por ejemplo, un navegador reciente que soporte HTTP/3 usará HTTP/2 o incluso HTTP/1.1 al comunicarse con un servidor que no haya sido actualizado en mucho tiempo. HTTP/3, a diferencia de anteriores versiones, ya no se apoya en el protocolo de transporte TCP (transmission control protocol), sino que usa QUIC (que no son siglas, sino un término que pretende evocar a la palabra inglesa quick). QUIC mejora la capacidad de multiplexación de TCP y suele mejorar la latencia y velocidad de las conexiones.

Los conceptos que tienes que comprender del protocolo HTTP se encuentran recogidos en estas diapositivas.

4.2. Herramientas para desarrolladores

Las herramientas para desarrolladores que incorporan los navegadores permiten estudiar los detalles de todas las conexiones establecidas por el navegador al ejecutar una aplicación web.

Las peticiones lanzadas desde la barra de direcciones del navegador son siempre de tipo GET. Para estudiar los otros tipos de peticiones de HTTP, necesitamos crear un formulario para peticiones de tipo POST, o, si queremos poder usar cualquier verbo, escribir código en JavaScript (usando la API Fetch que estudiáremos después) u otro lenguaje de programación. También hay herramientas como Postman, una aplicación que permite crear cómodamente colecciones de peticiones a APIs (application programming interfaces). La herramienta curl es similar pero funciona desde la línea de órdenes.

Hazlo tú ahora

Familiarízate con las opciones de inspección de la actividad en red de la pestaña Network del entorno de las Chrome DevTools siguiendo la página de su documentación Inspect Network Activity . Practica las distintas posibilidades en DevTools con peticiones GET por ahora, pero recuerda volver a esta actividad cuando estudies cómo realizar desde JavaScript peticiones con otros verbos.

4.3. Promesas de JavaScript

Los objetos de tipo Promise (una clase definida en la API de JavaScript de los navegadores modernos) se usan para representar la finalización con éxito de una tarea (normalmente asíncrona, es decir, que no bloquea el bucle de eventos, sino que define una función de callback que será ejecutada más adelante) o su fracaso. En términos informales, diremos que una promesa se cumple (en caso de éxito), se incumple (en caso de fracaso) o que está pendiente (cuando aún no se ha resuelto); en inglés se usan los términos fullfilled, rejected o pending, respectivamente. Los objetos promesa nos serán muy útiles en tareas como la comunicación con un servidor, que veremos más adelante en este tema.

Veamos un primer ejemplo. El siguiente código crea un objeto de tipo promesa p invocando al constructor de Promise. Este constuctor recibe dos funciones de callback que son aportadas por el sistema (es decir, no son definidas por el programador); estas dos funciones (resolve y reject en el código de abajo) serán invocadas en los lugares de nuestro código en los que determinemos que la tarea se ha desarrollado con éxito (en el caso del primer parámetro) o ha fracasado (función pasada como segundo parámetro). En este caso, vamos a suponer que nuestra promesa ejecuta una tarea asíncrona muy sencilla: esperar un segundo y generar un número aleatorio entre 0 y 1; la tarea se considera un éxito si el número es menor de 0,5. En lenguaje de la calle, sería como decir «te prometo que voy a generar (asíncronamente) un número aleatorio y que será menor que 0,5»; como cualquier promesa, en cualquier caso, esta puede cumpirse o no.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
  let p= new Promise(function(resolve,reject) {
    console.log('Entrando en el constructor de la promesa');
    setTimeout( function() {
      const r= Math.random();
      if (r<0.5) {
        resolve('la cosa fue bien');
      }
      else {
        reject('algo salió mal');
      }
    }, 1000);
  });

  p.then(function(mensaje) {
      console.log(`Promesa cumplida: ${mensaje}`);
    }, function(mensaje) {
      console.log(`Promesa incumplida: ${mensaje}`);
    }
  );

Las funciones resolve y reject reciben un único argumento que usaremos para dar información adicional relacionada con el éxito o fracaso de la tarea asíncrona; en este caso, es una simple cadena con un mensaje, pero puede ser un objeto que incluya un conjunto de atributos. Nada más invocar al contructor de Promise se establece el estado de la promesa a pendiente y se ejecuta el código de la función pasada como parámetro al constructor. Este código normalmente definirá una operación asíncrona (como realizar una petición a un servidor) y terminará llamando a resolve o reject; estas funciones (recordemos que son creadas por el sistema y no pertenecen a nuestro código) cambian el estado de la promesa a cumplida o incumplida y llaman a las funciones que el programador haya definido para manejar ambas situaciones.

El vínculo entre las funciones del sistema resolve y reject y nuestro código se establece llamando al método then sobre el objeto de tipo promesa. Al método then se le pasan dos funciones: la primera se vincula a resolve y la segunda a reject. Estas funciones pueden tener un parámetro que será el mismo que el usado como argumento en las llamadas a resolve y reject del constructor de Promise.

Finalmente, observa en el código anterior cómo hemos usado cadenas con plantillas (template strings) para imprimir los mensajes por consola. Las cadenas con plantillas de JavaScript usan comillas invertidas en lugar de rectas, pueden contener variables embebidas como en el ejemplo, y pueden también ocupar más de una línea.

diagrama de los elementos implicados en una promesa

Diagrama de los elementos de una promesa por Sumant Mishra

El código anterior es equivalente al siguiente en el que en lugar de pasar dos funciones a then se define la función asociada al incumplimiento de la promesa en un método catch:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<div id="promesas830">
  let p= new Promise(function(resolve,reject) {
    setTimeout( function() {
      const r= Math.random();
      if (r<0.5) {
        resolve('la cosa fue bien');
      }
      else {
        reject('algo salió mal');
      }
    }, 1000);
  });

  p.then(function(mensaje) {
      console.log(`Promesa cumplida: ${mensaje}`);
    })
    .catch(function(mensaje) {
      console.log(`Promesa incumplida: ${mensaje}`);
    });

Además, si encapsulamos el código de creación de la promesa en una función, usamos funciones flecha y gestionamos el error mediante una excepción (clase Error), el código anterior se convierte en:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function aleatorio() {
  return new Promise( (resolve,reject) => {
    setTimeout( () => {
      const r= Math.random();
      if (r<0.5) {
        resolve('la cosa fue bien');
      }
      else {
        reject(Error('algo salió mal'));
      }
    }, 1000);
});

aleatorio()
  .then( (mensaje) => {console.log(`Promesa cumplida: ${mensaje}`);})
  .catch( (mensaje) => {console.log(`Promesa incumplida: ${error.message}`);});
</div>

Las promesas pueden concatenarse simplemente haciendo que la función asociada al cumplimiento de la promesa (la indicada en la llamada al método then) devuelva a su vez una promesa:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
  aleatorio().then( (mensaje) => {
    console.log(`Primera promesa cumplida: ${mensaje}`);
    return aleatorio2(0.8);
  })
  .then( (mensaje) => {
    console.log(`Segunda promesa cumplida: ${mensaje}`);
  })
  .catch( (error) => {
    console.log(`Una de las promesas fue incumplida: ${error.message}`);
  }
  );

  function aleatorio() {
    return new Promise( (resolve,reject) => {
      setTimeout( () => {
        const r= Math.random();
        if (r<0.5) {
          resolve('la cosa fue bien');
          }
        else {
          reject(Error('algo salió mal'));
        }
      }, 1000);
    });
  }

  function aleatorio2(delta) {
    return new Promise( (resolve,reject) => {
      setTimeout( () => {
        const r= Math.random();
        if (r<delta) {
          resolve('me encanta que los planes salgan bien');
        }
        else {
          reject(Error('peor imposible'));
        }
      }, 1000);
    });
  }

En este caso, la segunda promesa establece una clausura con el parámetro de la función que indica el umbral para que la promesa se cumpla. Si encapsulamos todos los bloques en funciones, el código principal queda muy compacto y legible:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
aleatorio()
  .then(primerMensaje)
  .then(segundoMensaje)
  .catch(mensajeError);

function primerMensaje (mensaje) {
  console.log(`Primera promesa cumplida: ${mensaje}`);
  return aleatorio2(0.8);
}

function segundoMensaje (mensaje) {
  console.log(`Segunda promesa cumplida: ${mensaje}`);
}

function mensajeError (error) {
  console.log(`Una de las promesas fue incumplida: ${error.message}`);
}

function aleatorio() {
  return new Promise( (resolve,reject) => {
    setTimeout( () => {
      const r= Math.random();
      if (r<0.5) {
        resolve('la cosa fue bien');
      }
      else {
        reject(Error('algo salió mal'));
      }
    }, 1000);
  });
}

function aleatorio2(delta) {
  return new Promise( (resolve,reject) => {
    setTimeout( () => {
      const r= Math.random();
      if (r<delta) {
        resolve('me encanta que los planes salgan bien');
      }
      else {
        reject(Error('peor imposible'));
      }
    }, 1000);
  });
}

Uno de los motivos de la introducción de las promesas en JavaScript fue precisamente el de simplificar la escritura de código en los frecuentes casos que los que un evento asíncrono dispara a su terminación otro evento asíncrono que dispara a su vez un nuevo evento asíncrono, etc. El código sin promesas termina teniendo una cantidad tal de ámbitos que su escritura y su lectura se hacen muy dificultosas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
  function aleatorio() {
      setTimeout( () => {
          const r= Math.random();
          if (r<0.5) {
            let mensaje= 'la cosa fue bien';
            alert(`Primera promesa cumplida: ${mensaje}`);
            let delta= 0.8;
            setTimeout( () => {
              const r= Math.random();
              if (r<delta) {
                let mensaje= 'me encanta que los planes salgan bien';
                alert(`Segunda promesa cumplida: ${mensaje}`);
              }
              else {
                let error= Error('peor imposible');
                alert(`Una de las promesas fue incumplida: ${error.message}`);
              }
            }, 1000);
          }
          else {
            let error= Error('algo salió mal');
            alert(`Una de las promesas fue incumplida: ${error.message}`);
          }
    }, 1000);
  }

  aleatorio();

Finalmente, JavaScript ha incluido más recientemente las funciones asíncronas que permiten simplificar aún más las cadenas de promesas al utilizar la misma notación secuencial empleada en segmentos síncronos de código con los bloques de código que contienen llamadas asíncronas. Lo veremos más adelante.

4.4. El objeto XMLHttpRequest

En los primeros años de la web, la mayoría de las aplicaciones web seguían el siguiente patrón de comportamiento al realizar, por ejemplo, una búsqueda de un determinado elemento en una base de datos: el usuario rellenaba los valores correspondientes para buscar el elemento en un formulario y pulsaba el botón de enviar; en ese momento, el navegador realizaba una petición al servidor y borraba la página web actual; el servidor realizaba la búsqueda en la base de datos y devolvía entonces una página web completa que el navegador mostraba en sustitución de la anterior. Además de generar una experiencia incómoda al usuario si se compara con una aplicación tradicional de escritorio (el usuario ha de esperar a que se cargue de nuevo toda la página para seguir interactuando con la aplicación), este procedimiento es muy ineficiente cuando la página web tiene mucho contenido que apenas cambia, y que, sin embargo, es enviado continuamente por el servidor.

Hazlo tú ahora

Prepárate para este tema, leyendo en primer lugar los capítulos sobre desarrollo en el lado del cliente y peticiones Ajax del libro «Client-Side Web Development».

A partir de finales de los noventa y especialmente en los primeros años del siglo XXI, los desarrolladores comienzan a explotar el uso de funcionalidades de los navegadores que permiten realizar peticiones a un servidor sin tener que recargar la página completa. A estas técnicas se les denomina Ajax por razones históricas: el término lo acuñó un desarrollador en 2005 como acrónimo de Asynchronous JavaScript and XML. Con Ajax, los datos devueltos por el servidor se usan para generar dinámicamente HTML que es insertado convenientemente en el árbol DOM, conformando lo que se conoce como aplicaciones de una solo página (single-page applications). La más usada de las técnicas Ajax se basaba en el objeto XMLHttpRequest (para abreviar se suele llamar XHR) que permite, como se ha comentado, solicitar (normalmente de forma asícrona) una serie de datos al servidor desde JavaScript en lugar de una página completa que reemplazará a la actual. Estos datos serán, entonces, procesados por una función definida por el programador.

El siguiente es un ejemplo típico de uso:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Los datos del servidor se insertarán en el elemento con id 'results':
var resultado= document.querySelector("#results");

// Borra el contenido previo del elemento:
resultado.textContent= "";

// Crea el objeto XHR:
var client = new XMLHttpRequest();

var url= "https://ghibliapi.herokuapp.com/films/";
// Identifica el verbo, la URL y que la petición será asíncrona:
client.open("GET", url, true);

// La función asignada a onreadystatechange es invocada varias veces por
// el navegador durante el transcurso de las diferentes fases de comunicación
// con el servidor (conexión establecida, petición recibida, petición en proceso,
// petición finalizada); la fase de la llamada actual se almacena en readyState;
// normalmente, nos interesa la llamada en la que readyState tiene el valor 4,
// que se hace en el momento en el que el servidor ha devuelto todos los
// datos:
client.onreadystatechange = function () {
  console.log(client.readyState);
  if (client.readyState == 4) {
    // Código de estado de HTTP:
    if (client.status != 200) {
      console.error("Hubo un error (" + client.status + ")!");
    }
    else {
      // JSON.parse analiza la cadena pasada como argumento y la convierte
      // a un objeto de JavaScript; la respuesta de esta petición es un array
      // que se almacena en r:
      var r= JSON.parse(client.responseText);

      // Los datos devueltos son de la forma [ {"id": "0440483e", "title": "Princess
      // Mononoke", "description": "Ashitaka, a prince of the disappearing Ainu tribe...",
      // "director": ..., "producer": ...},   {"id": dc2e6bd1", "title": "Spirited Away",
      // "description": "Spirited Away is an Oscar winning Japanese animated film about
      // a ten year old girl who wanders away from her parents...", "director": "Hayao
      // Miyazaki", ...},  {...},  {...} ]
      for (var i=0; i<r.length;i++) {
        resultado.textContent+= r[i].title+"; ";
      }
    }
  }
};
// Realiza la petición:
client.send(null);

Este código accede a modo de ejemplo a una API web sobre las películas del estudio Ghibli. Los datos devueltos por esta API web (y por muchas otras) están codificados en una notación independiente del lenguaje denominada JSON (por JavaScript Object Notation), que es muy parecida a la que se usa en JavaScript para definir literalmente un objeto, pero con algunas diferencias: principalmente, que los atributos van siempre entrecomillados, que no pueden usarse comillas simples y que no pueden incluirse valores de algunos tipos especiales de JavaScript como, por ejemplo, funciones. Como ves en el código anterior, la función JSON.parse permite convertir una cadena en formato a JSON a objeto de JavaScript; para lo opuesto, puede usarse la función JSON.stringify. En APIs web más antiguas se usaba el formato XML en lugar de JSON.

Hazlo tú ahora

Prueba el código del ejemplo anterior (por ejemplo, en una web como JSFiddle) para comprender su funcionamiento y estudia todo el tráfico de red mediante las Chrome DevTools. Asegúrate de crear un elemento con id results en el documento HTML. Escribe código para probar también otras APIs públicas, como la de información del juego Clash Royale.

4.5. La API Fetch

En los últimos años, sin embargo, los navegadores han comenzado a implementar la API Fetch (desarrollada por el WHATWG, Web Hypertext Application Technology Working Group, que también supervisa la evolución de otros estándares como HTML o la API DOM), que es una forma más potente, extensible y flexible de acceder a recursos externos. Usando esta API en lugar de XHR, el código mostrado anteriormente quedaría como sigue:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
var resultado= document.querySelector("#results");
resultado.textContent= "";
fetch('https://ghibliapi.herokuapp.com/films/')
.then(function(response) {
  if (!response.ok) {
    throw Error(response.statusText);
  }
  return response.json();
})
.then(function(responseAsObject) {
  for (var i=0; i<responseAsObject.length;i++) {
    resultado.textContent+= responseAsObject[i].title+"; ";
  }
})
.catch(function(error) {
  console.log('Ha habido un problema: ', error);
});

No te costará entender este código si repasas lo estudiado en una sección anterior sobre las promesas en JavaScript y te decimos que la función fetch devuelve una promesa que se cumple cuando el servidor devuelve un resultado (aunque la respuesta incluya un código de error de HTTP) y se incumple cuando por cualquier motivo no es posible establecer la comunicación con el servidor. También debería ser fácil deducir que la función json devuelve otra promesa que se cumple si el cuerpo de la respuesta del servidor (que se pasa por fetch a la función resolve y de ahí a la función del primer método then) es una cadena en formato JSON que se puede convertir sin errores (usando JSON.parse()) en un objeto de JavaScript.

También debería ser fácil de entender el siguiente código, que añade un paso intermedio al procesamiento de la respuesta del servidor que convierte los títulos de películas almacenados en el objeto de JavaScript a minúsculas. Para ello, define una función que devuelve una promesa que no se cumple únicamente en el caso de que la cadena del atributo title sea vacía.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function minusculas(r) {
  var promise= new Promise(function(resolve, reject) {
    if (r.length>0) {
      for (var i=0; i<r.length;i++) {
        r[i].title= r[i].title.toLowerCase();
      }
      resolve(r);
    }
    else {
      reject(Error("String cannot be empty!"));
    }
  });
  return promise;
}

var resultado= document.querySelector("#results");
resultado.textContent= "";
fetch('https://ghibliapi.herokuapp.com/films/')
.then(function(response) {
  if (!response.ok) {
    throw Error(response.statusText);
  }
  return response.json();  // llama a JSON.parse()
})
.then(minusculas)
.then(function(responseAsObject) {
  for (var i=0; i<responseAsObject.length;i++) {
        resultado.textContent+= responseAsObject[i].title+"; ";
}
})
.catch(function(error) {
  console.log('Ha habido un problema: \n', error);
});

Las peticiones realizadas por fetch son, por defecto, de tipo GET. Más adelante, veremos como realizar peticiones con otros verbos, añadir información en JSON o dar valor a ciertas cabeceras de HTTP.

4.6. La política del mismo origen

Todos los navegadores implementan una restricción conocida como política del mismo origen (en inglés, same-origin policy), un concepto de seguridad existente desde la época de Netscape 2.0 para peticiones basadas en XHR o en la API Fetch. Esta política impide por defecto que desde un script bajado de un determinado servidor se realicen peticiones a servicios web disponibles en un servidor con un dominio diferente. Permitir este tipo de accesos abre la puerta a toda una serie de potenciales problemas: por ejemplo, MaliciousSite.com usa los servicios web de la web de MyBank.com (en la que el usuario tiene sesión abierta en otra pestaña del navegador) para obtener información confidencial; los servicios web de MyBank.com devuelven la información solicitada porque el usuario está autenticado y la cookie de autenticación es enviada por el navegador junto con la petición; el script con origen MaliciousSite.com puede ahora compartir esta información con otros servidores.

Si la política del mismo origen no pudiera evitarse, muchas aplicaciones web que usan servicios web de terceros desde el cliente no podrían existir o deberían implementar un proxy a dichos servicios web en su propio servidor. Por ello, los navegadores permiten bajo determinadas condiciones que estos accesos puedan realizarse. En particular, la técnica estándar CORS (cross-origin resource sharing) utiliza la cabecera Origin (que es añadida por el navegador y no puede modificarse desde JavaScript) en la petición para informar al servidor del origen del código que está haciendo la solicitud. El servidor puede autorizar o denegar entonces el acceso añadiendo a la respuesta un valor adecuado en la cabecera Access-Control-Allow-Origin; si el valor de esta última cabecera en la respuesta coincide con el valor de Origin en la petición o si toma un valor como *, entonces el navegador autoriza el acceso. En peticiones que pueden modificar datos en el servidor (como las de tipo PUT o DELETE), el navegador realiza una comunicación previa con el servidor (usando el verbo OPTIONS) para realizar algunas comprobaciones pre-vuelo (pre-flight) usando las cabeceras ya mencionadas.

Aunque no es fácil engañar al servidor modificando el valor enviado por el navegador en la cabecera Origin, debe tenerse en cuenta que el propósito de CORS no es el de hacer un sitio web más seguro; si el servidor devuelve datos privados, es necesario usar cookies o tokens, por ejemplo.

Hazlo tú ahora

En una actividad anterior tienes un ejemplo de acceso a un servicio (el de información sobre el estudio Ghibli) que usa CORS. Estúdialo con ayuda de las herramientas para desarrolladores del navegador comprobando las cabeceras. Estudia también una petición vía Fetch a un servidor que no soporte CORS, como el de esta API de días festivos.

Finalmente, es importante resaltar que estas restricciones afectan a los servicios web a los que se intenta acceder desde un navegador. Una aplicación de escritorio o un programa ejecutándose en un servidor no tienen estas restricciones.

4.7. La arquitectura REST

REST es una arquitectura para implementar servicios web sobre el protocolo HTTP que es usada actualmente por la gran mayoría de APIs web. Bajo REST los recursos se representan mediante URLs y las acciones a realizar con ellos se indican mediante los correspondientes verbos de HTTP (principalmente, GET, POST, PUT y DELETE).

Hazlo tú ahora

En esta actividad vamos a explorar una API REST de jueguete para gestionar carritos de la compra. Para acceder a la API vamos a usar curl, un programa que permite realizar peticiones HTTP desde la línea de órdenes y observar la respuesta devuelta por el servidor. Ve probando en tu ordenador todos los pasos siguientes.

En primer lugar, vamos a asignar a una variable de entorno el URL base de la API:

endpoint=https://whispering-plains-89598.herokuapp.com/carrito/v1

Nota: la sintaxis que seguiremos aquí para manejar variables de entorno es la usada en sistemas basados en Unix. Para otros sistemas operativos, la sintaxis podría ser ligeramente diferente.

El primer paso con la API del carrito suele ser obtener un identificador de carrito válido, lo que haremos con el verbo POST:

curl --request POST --header 'content-type:application/json' -v $endpoint/creacarrito

La opción --request indica el verbo a usar y la opción --header sirve para identificar las cabeceras de la petición; en este caso, usamos la cabecera content-type que se usa para indicar al servidor en qué formato (JSON, en este caso) queremos recibir los datos de la respuesta; el servidor podría ignorar nuestra solicitud si no soportara dicho formato, lo que no es el caso. Finalmente, la opción --v hace que curl muestre información más detallada sobre la petición y la respuesta. La petición anterior nos devolverá en formato JSON el nombre del carrito recién creado en el atributo result.nombre. Asigna dicho valor (por ejemplo, fada6) a la variable de entorno carrito:

carrito=fada6

Ten en cuenta que si ningún cliente ha realizado una petición a la API en los últimos minutos, la primera respuesta puede tardar unos segundos en producirse. Vamos a añadir ahora un item al carrito. Para ello usamos el verbo POST sobre la ruta $endpoint/$carrito/productos; los datos del nuevo item los pasaremos en JSON dentro del cuerpo (payload) del mensaje, al que damos valor con la opción --data de curl:

curl --request POST --data '{"item":"queso","cantidad":1}' --header 'content-type:application/json' $endpoint/$carrito/productos

El servidor nos devuelve un resultado en JSON con dos atributos, result y error; el primero contiene información adicional si la petición pudo satisfacerse (el código de estado es 200 en ese caso); el atributo error contiene mas información sobre el error en caso de haberse producido (el código de estado es 404 en ese caso); si no procede dar valor a result o error, estos atributos tomarán el valor null. Vamos a añadir otro item al carrito:

curl --request POST --data '{"item":"leche","cantidad":4}' --header 'content-type:application/json' $endpoint/$carrito/productos

Para obtener la composición de un carrito, usaremos el verbo GET:

curl --request GET --header 'content-type:application/json' $endpoint/$carrito/productos

Obtendremos una respuesta como la siguiente:

{
  "result": {
    "nombre":"fada6",
    "productos":[{"item":"queso","cantidad":1},
                  {"item":"leche","cantidad":4}]
  },
  "error":null
}

Para modificar la cantidad de un item ya existente en el carrito, usaremos la acción PUT e indicaremos la nueva cantidad en JSON en el bloque de datos:

curl --request PUT --data '{"cantidad":2}' --header 'content-type:application/json' $endpoint/$carrito/productos/queso

Comprobamos que el carrito ha sido actualizado con la nueva cantidad:

curl --request GET --header 'content-type:application/json' $endpoint/$carrito/productos/queso

Finalmente, podemos borrar un producto con la acción DELETE:

curl --request DELETE --header 'content-type:application/json' $endpoint/$carrito/productos/queso
curl --request DELETE --header 'content-type:application/json' $endpoint/$carrito/productos/queso

Con la segunda petición, el servidor devolverá un error indicando que el producto no existe.

Si quisiéramos añadir un nuevo item cuyo nombre lleve algún carácter especial (por ejemplo, la vocal con tilde de jamón), lo podemos hacer como en los casos anteriores:

curl --request POST --data '{"item":"jamón","cantidad":2}' --header 'content-type:application/json' $endpoint/$carrito/productos

Pero a la hora de hacer una petición en la que el nombre del item forme parte del URL (y no del bloque de datos), es necesario convertir los caracteres especiales a aquellos que puedan formar parte de un URL a través de lo que se conoce como codificación por ciento (percent-encoding):

curl --request PUT --data '{"cantidad":5}' --header 'content-type:application/json' $endpoint/$carrito/productos/jam%C3%B3n

En JavaScript tenemos funciones como decodeURIComponent y encodeURIComponent que se encargan del trabajo de conversión. Para codificar un símbolo para el programa curl que se ejecuta en la línea de órdenes podemos usar herramientas en línea.

4.7.1. Envío de peticiones desde JavaScript

Ahora vamos a ver cómo interactuar con la API del carrito desde JavaScript (en concreto, usando la API Fetch que hemos estudiado antes) por medio de una aplicación web de gestión de carritos de la compra. Abre las DevTools de Google Chrome y estudia cada una de las peticiones Fetch realizadas por la aplicación. El código de este cliente de la API es el siguiente:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
<!doctype html>
<!-- Ejemplos de uso de la API web del carrito con la API Fetch del navegador -->
<html lang="es">
<head>
  <meta charset="utf-8">
  <link href="style.css" rel="stylesheet" type="text/css">
  <title>Carrito</title>
</head>
<body>

  <h1>Gestión de carritos de la compra</h1>
  <!-- Formularios: -->
  <form id="f0">
    <fieldset>
      <legend>Usar un carrito ya existente</legend>
      <label>Nombre del carrito:
        <input type="text" name="nombre" required>
      </label>
      <button>Usa</button>
    </fieldset>
  </form>
  <form id="f1">
    <fieldset>
      <legend>Crear un nuevo carrito</legend>
      <button>Crea</button>
    </fieldset>
  </form>
  <form id="f2">
    <fieldset>
      <legend>Añadir item al carrito</legend>
      <label>Nombre del item:
          <input type="text" name="item" required>
      </label> 
      <label>Cantidad:
          <input type="text" name="cantidad" required pattern="[0-9]+">
      </label> 
      <button>Añade</button>
    </fieldset>
  </form>
  <form id="f3">
    <fieldset>
      <legend>Actualizar cantidad de un item</legend>
      <label>Nombre del item:
          <input type="text" name="item" required>
      </label> 
      <label>Nueva cantidad:
          <input type="text" name="cantidad" required pattern="[0-9]+">
      </label> 
      <button>Actualiza</button>
    </fieldset>
  </form>
  <form id="f4">
    <fieldset>
      <legend>Borrar un item del carrito</legend>
      <label>Nombre del item:
          <input type="text" name="item" required>
      </label>
      <button>Borra</button> 
    </fieldset>
  </form>
  <form id="f5">
    <fieldset>
      <legend>Eliminar el carrito actual</legend>
      <button>Elimina</button>
    </fieldset>
  </form>
  
  <main>
    <div>Nombre del carrito actual: <span id="nombre"></span></div>
    <div>Última respuesta del servidor: <span id="mensaje"></span></div>
    <div>Contenido del carrito:</div>
    <table>
      <thead>
        <th>Item</th>
        <th>Cantidad</th>
      </thead>
      <tbody id="listado">
      </tbody>
    </table>
  </main>

<script>
  // Endpoint de la API del carrito:
  const base="/carrito/v1";
  
  var carrito= "";
  const cabeceras= {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  }

  function print(r) {
    const e= document.querySelector('#mensaje');  
    if(r.result) {
      e.textContent= JSON.stringify(r.result);
    }
    else {
      e.textContent= JSON.stringify(r.error);
    }
  }

  function printError(s) {
    const e= document.querySelector('#mensaje');  
    e.textContent= `Problema de conexión: ${s}`;
  }

  
  function usaCarrito (event) {
    event.preventDefault(); // evita la recarga de la página
    const e= document.querySelector("#f0 input[name='nombre']");
    document.querySelector('#nombre').textContent= carrito= e.value;
    e.value= "";
    muestraCarrito();
  }


  function creaCarrito(event) {
    event.preventDefault();
    const url= `${base}/creacarrito`;
    const payload= {};
    const request = {
      method: 'POST', 
      headers: cabeceras,
      body: JSON.stringify(payload),
    };
    fetch(url,request)
    .then( response => response.json() )
    .then( r => {
      carrito= r.result.nombre;
      document.querySelector("#nombre").textContent= carrito;
      muestraCarrito();
      print(r);
    })
    .catch( error => printError(error) );
  }


  function muestraCarrito() {
    const url= `${base}/${carrito}/productos`;
    const request = {
      method: 'GET', 
      headers: cabeceras,
    };
    fetch(url,request)
    .then( response => response.json() )
    .then( r => {
      const e= document.querySelector('#listado');
      e.innerHTML= '';
      if (r.result) {
        for(var i=0;i<r.result.length;i++) {
          e.innerHTML+= `<tr>
            <td>${r.result[i].item}</td>
            <td>${r.result[i].cantidad}</td>
            </tr>`;
        }
      }
    })
    .catch( error => printError(error) );
  }
   
  
  function nuevoItem (event) {
    event.preventDefault();
    const url= `${base}/${carrito}/productos`;
    const payload= {
      item:document.querySelector("#f2 input[name='item']").value,
      cantidad:document.querySelector("#f2 input[name='cantidad']").value,
    };
    const request = {
      method: 'POST', 
      headers: cabeceras,
      body: JSON.stringify(payload),
    };
    fetch(url,request)
    .then( response => response.json() )
    .then( r => {
      print(r);
      document.querySelector("#f2 input[name='item']").value= '';
      document.querySelector("#f2 input[name='cantidad']").value= '';
      muestraCarrito();
    })
    .catch( error => printError(error) );
  }
  
  
  function actualizaItem (event) {
    event.preventDefault();
    const item= document.querySelector("#f3 input[name='item']").value;
    const url= `${base}/${carrito}/productos/${item}`;
    const payload= {
      cantidad:document.querySelector("#f3 input[name='cantidad']").value,
    }; 
    const request = {
      method: 'PUT', 
      headers: cabeceras,
      body: JSON.stringify(payload),
    };
    fetch(url,request)
    .then( response => response.json() )
    .then( r => {
      print(r);
      document.querySelector("#f3 input[name='item']").value= '';
      document.querySelector("#f3 input[name='cantidad']").value= '';
      muestraCarrito();
    })
    .catch( error => printError(error) );
  }
  

  function borraItem (event) {
    event.preventDefault();
    const item= document.querySelector("#f4 input[name='item']").value;
    const url= `${base}/${carrito}/productos/${item}`;
    const payload= {}; 
    var request = {
      method: 'DELETE', 
      headers: cabeceras,
      body: JSON.stringify(payload),
    };
    fetch(url,request)
    .then( response => response.json() )
    .then( r => {
      print(r);
      document.querySelector("#f4 input[name='item']").value= '';
      muestraCarrito();
    })
    .catch( error => printError(error) );
  }
  

  function eliminaCarrito (event) {
    event.preventDefault();
    const url= `${base}/${carrito}`;
    const payload= {};
    var request = {
      method: 'DELETE', 
      headers: cabeceras,
      body: JSON.stringify(payload),
    };
    fetch(url,request)
    .then( response => response.json() )
    .then( r  => {
      print(r); 
      muestraCarrito();
    })
    .catch( error => printError(error) );
  }


  // Función de inicialización:
  function init () {
    let e= document.querySelector('#f0');
    e.addEventListener('submit',usaCarrito,false); 
    e= document.querySelector('#f1');
    e.addEventListener('submit',creaCarrito,false);
    e= document.querySelector('#f2');
    e.addEventListener('submit',nuevoItem,false);
    e= document.querySelector('#f3');
    e.addEventListener('submit',actualizaItem,false);
    e= document.querySelector('#f4');
    e.addEventListener('submit',borraItem,false);
    e= document.querySelector('#f5');
    e.addEventListener('submit',eliminaCarrito,false);
  }
    
  document.addEventListener('DOMContentLoaded',init,false);
</script>

</body>

</html>

4.7.2. Peticiones CORS

Hazlo tú ahora

La API REST del carrito soporta peticiones Fetch realizadas desde programas en JavaScript descargados de dominios diferentes al dominio en el que está ubicada la API. Para comprobarlo, abre el fichero carrito.html desde un servidor web local; recuerda cambiar antes la variable base de JavaScript para que apunte al endpoint de la API que has usado en la actividad anterior.

Si tienes Python 2 instalado, ejecuta desde el directorio donde está carrito.html una de las dos siguientes órdenes:

python -m SimpleHttpServer
python2 -m SimpleHttpServer

Si tienes Python 3 instalado en tu sistema, ejecuta desde el directorio donde está carrito.html una de las dos siguientes órdenes:

python -m http.server
python3 -m http.server

El servidor te informará del puerto en localhost desde el que puedes acceder al contenido del directorio. Realiza peticiones desde la aplicación web del carrito y analiza las cabeceras relacionadas con CORS de la petición y la respuesta. Observa cómo las peticiones de tipo POST, PUT o DELETE realizan una comprobación pre-vuelo con el verbo OPTIONS. Modifica el cliente para que envíe una cabecera adicional no convencional y observa cómo la respuesta del servidor hace que la petición falle al no devolver el servidor el nombre de la cabecera en la lista devuelta en Access-Control-Allow-Headers.

4.8. Programación de servicios web en Node.js

Los servicios web se pueden programar en prácticamente cualquier lenguaje de programación existente hoy día. Para el servicio web anterior, hemos usado JavaScript con Node.js y el framework para desarrollo de aplicaciones web Express. Este es el código de la parte del servidor:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
'use strict';

const express = require('express');
const app = express();

const config = require('./config.js');

var knex= null;

// inicializa Knex.js para usar diferentes bases de datos según el entorno:
function conectaBD () {
  if (knex===null) {
    var options;
    if (process.env.CARRITO_ENV === 'gae') {
      options= config.gae;
      console.log('Usando Cloud SQL (MySQL) como base de datos en Google App Engine');
    } else if (process.env.CARRITO_ENV === 'heroku') {
      options= config.heroku;
      console.log('Usando PostgreSQL como base de datos en Heroku');
    } else {
      options= config.localbd;
      console.log('Usando SQLite como base de datos local');
    }
    // Muestra la conversión a SQL de cada consulta:
    // options.debug= true;
    knex= require('knex')(options);
  }
}

// crea las tablas si no existen:
async function creaEsquema(res) {
  try {
    let existeTabla= await knex.schema.hasTable('carritos');
    if (!existeTabla) {
      await knex.schema.createTable('carritos', (tabla) => {
        tabla.increments('carritoId').primary();
        tabla.string('nombre', 100).notNullable();
      });
      console.log("Se ha creado la tabla carritos");
    }
    existeTabla= await knex.schema.hasTable('productos');
    if (!existeTabla) {
      await knex.schema.createTable('productos', (table) => {
        table.increments('productoId').primary();
        table.string('carrito', 100).notNullable();
        table.string('item', 100).notNullable();
        table.integer('cantidad').unsigned().notNullable();
      });
      console.log("Se ha creado la tabla productos");
    }
  }
  catch (error) {
    console.log(`Error al crear las tablas: ${error}`);
    res.status(404).send({ result:null,error:'error al crear la tabla; contacta con el administrador' });
  }
}

async function numeroCarritos() {
  return await knex('carritos').countDistinct('nombre');
}

async function numeroItems(carrito) {
  let r= await knex('productos').select('item')
                                .where('carrito',carrito);
  return r.length;
}

async function existeCarrito(carrito) {
  let r= await knex('carritos').select('nombre')
                               .where('nombre',carrito);
  return r.length>0;
}

async function existeItem(item,carrito) {
  let r= await knex('productos').select('item')
                                .where('item',item)
                                .andWhere('carrito',carrito);
  return r.length>0;
}


// asume que el cuerpo del mensaje de la petición está en JSON:
app.use(express.json());

// middleware para aceptar caracteres UTF-8 en la URL:
app.use( (req, res, next) => {
  req.url = decodeURI(req.url);
  next();
});

// middleware para las cabeceras de CORS:
app.use( (req, res, next) => {
  res.header("Access-Control-Allow-Origin", "*");
  res.header('Access-Control-Allow-Methods', 'DELETE, PUT, GET, POST, OPTIONS');
  res.header("Access-Control-Allow-Headers", "content-type");
  next();
});


// middleware que establece la conexión con la base de datos y crea las 
// tablas si no existen; en una aplicación más compleja se crearía el
// esquema fuera del código del servidor:
app.use( async (req, res, next) => {
  app.locals.knex= conectaBD(app.locals.knex);
  await creaEsquema(res);
  next();
});


// crea un carrito:
app.post(config.app.base+'/creacarrito', async (req,res) => {
  try {
    let n= await numeroCarritos();
    if (n>=config.app.maxCarritos) {
      res.status(404).send({ result:null,error:'No caben más carritos; contacta con el administrador' });
      return;
    }
    let existe= true;
    while (existe) {
      var nuevo = Math.random().toString(36).substring(7);
      existe= await existeCarrito(nuevo);
    }
    var c= { nombre:nuevo };
    await knex('carritos').insert(c);
    res.status(200).send({ result:{ nombre:nuevo },error:null });
  } catch (error) {
    console.log(`No se puede crear el carrito: ${error}`);
    res.status(404).send({ result:null,error:'no se pudo crear el carrito' });
  }
});


// crea un nuevo item:
app.post(config.app.base+'/:carrito/productos', async (req, res) => {
  if (!req.body.item || !req.body.cantidad) {
    res.status(404).send({ result:null,error:'datos mal formados' });
    return;
  }
  try {
    let existe= await existeCarrito(req.params.carrito);
    if (!existe) {
      res.status(404).send({ result:null,error:`carrito ${req.params.carrito} no existente` });
      return;  
    }
    existe= await existeItem(req.body.item,req.params.carrito);
    if (existe) {
      res.status(404).send({ result:null,error:`item ${req.body.item} ya existente` });
      return;
    }
    let n= await numeroItems(req.params.carrito);
    if (n>=config.app.maxProductos) {
      res.status(404).send({ result:null,error:`No caben más productos en el carrito ${req.params.carrito}` });
      return;
    }
    var i= { carrito:req.params.carrito,item:req.body.item,cantidad:req.body.cantidad };
    await knex('productos').insert(i);
    res.status(200).send({ result:'ok',error:null });
  } catch (error) {
    console.log(`No se puede añadir el item: ${error}`);
    res.status(404).send({ result:null,error:'no se pudo añadir el item' });
  }
});


// lista los items de un carrito:
app.get(config.app.base+'/:carrito/productos', async (req, res) => {
  try {
    let existe= await existeCarrito(req.params.carrito);
    if (!existe) {
      res.status(404).send({ result:null,error:`carrito ${req.params.carrito} no existente` });
      return;  
    }
    let i= await knex('productos').select(['item','cantidad'])
                                  .where('carrito',req.params.carrito);
    res.status(200).send({ result:i,error:null });
  } catch (error) {
    console.log(`No se puede obtener los productos del carrito: ${error}`);
    res.status(404).send({ result:null,error:'no se pudo obtener los datos del carrito' });
  }
});


// lista los datos de un item:
app.get(config.app.base+'/:carrito/productos/:item', async (req, res) => {
  try {
    let existe= await existeCarrito(req.params.carrito);
    if (!existe) {
      res.status(404).send({ result:null,error:`carrito ${req.params.carrito} no existente` });
      return;  
    }
    existe= await existeItem(req.params.item,req.params.carrito);
    if (!existe) {
      res.status(404).send({ result:null,error:`item ${req.params.item} no existente` });
      return;
    }
    let i= await knex('productos').select(['item','cantidad'])
                                  .where('carrito',req.params.carrito)
                                  .andWhere('item',req.params.item);
    res.status(200).send({ result:i[0],error:null });
  } catch (error) {
    console.log(`No se pudo obtener el item: ${error}`);
    res.status(404).send({ result:null,error:'no se pudo obtener el item' });
  }
});


// modifica un item:
app.put(config.app.base+'/:carrito/productos/:item', async (req, res) => {
  if (!req.body.cantidad) {
    res.status(404).send({ result:null,error:'datos mal formados' });
    return;
  }
  try {
    let existe= await existeCarrito(req.params.carrito);
    if (!existe) {
      res.status(404).send({ result:null,error:`carrito ${req.params.carrito} no existente` });
      return;  
    }
    existe= await existeItem(req.params.item,req.params.carrito);
    if (!existe) {
      res.status(404).send({ result:null,error:`item ${req.params.item} no existente` });
      return;
    }
    await knex('productos').update('cantidad',req.body.cantidad)
                           .where('carrito',req.params.carrito)
                           .andWhere('item',req.params.item);
    res.status(200).send({ result:'ok',error:null });
  } catch (error) {
    console.log(`No se pudo obtener el item: ${error}`);
    res.status(404).send({ result:null,error:'no se pudo obtener el item' });
  }
});


// borra un item:
app.delete(config.app.base+'/:carrito/productos/:item', async (req, res) => {
  try {
    let existe= await existeCarrito(req.params.carrito);
    if (!existe) {
      res.status(404).send({ result:null,error:`carrito ${req.params.carrito} no existente` });
      return;  
    }
    existe= await existeItem(req.params.item,req.params.carrito);
    if (!existe) {
      res.status(404).send({ result:null,error:`item ${req.paramsº.item} no existente` });
      return;
    }
    await knex('productos').where('carrito',req.params.carrito).andWhere('item',req.params.item).del();
    res.status(200).send({ result:'ok',error:null });
  } catch (error) {
    console.log(`No se pudo obtener el item: ${error}`);
    res.status(404).send({ result:null,error:'no se pudo obtener el item' });
  }
});


// borra un carrito:
app.delete(config.app.base+'/:carrito', async (req, res) => {
  try {
    let existe= await existeCarrito(req.params.carrito);
    if (!existe) {
      res.status(404).send({ result:null,error:`carrito ${req.params.carrito} no existente` });
      return;  
    }
    await knex('productos').where('carrito',req.params.carrito)
                           .del();
    await knex('carritos').where('nombre',req.params.carrito)
                          .del();
    res.status(200).send({ result:'ok',error:null });
  } catch (error) {
    console.log(`No se pudo encontrar el carrito: ${error}`);
    res.status(404).send({ result:null,error:'no se pudo encontrar el carrito' });
  }
});


const secret= '12345';

// borra toda la base de datos:
app.get(config.app.base+'/clear', async (req,res) => {
  try {
    await knex('productos').where('carrito',req.params.carrito)
                           .del();
    await knex('carrito').where('nombre',req.params.carrito)
                         .del();
    res.status(200).send({ result:'ok',error:null });
  } catch (error) {
    console.log(`No se pudo borrar la base de datos: ${error}`);
  }
});


const path = require('path');
const publico = path.join(__dirname, 'public');
// __dirname: carpeta del proyecto

app.get(config.app.base+'/', (req, res) => {
  res.status(200).send('API web para gestionar carritos de la compra');
});

app.get(config.app.base+'/ayuda', (req, res) => res.sendFile(path.join(publico, 'index.html'))
);

app.use('/', express.static(publico));

const PORT = process.env.PORT || 5000;
app.listen(PORT, function () {
  console.log(`Aplicación lanzada en el puerto ${ PORT }!`);
});

Express es un módulo de Node.js. La función de Node.js require carga un módulo (de forma similar a los import o include de otros lenguajes) y devuelve un objeto que representa los elementos que exporte el módulo. En este caso, Express exporta una función de inicialización que permite usar el resto de funciones. El código de Express contendrá unas líneas similares a estas:

module.exports = createApplication;

function createApplication() {
  ...
  var app= ...
  app.init();
  return app;
}

El código registra (llamando a app.use) varias funciones de middleware, que son invocadas para todas las peticiones y que se encargan de diversos aspectos: indicar que el bloque de datos de la petición contendrá información en JSON, descodificar los caracteres que usen la notación por ciento (véase una actividad anterior en este tema), devolver las cabeceras necesarias para permitir peticiones CORS (como también hemos visto) o establecer la conexión con el gestor de base de datos. Todas estas funciones de middleware son llamadas por Express con tres parámetros: un objeto que representa la solicitud (req en el código), un objeto que representa la respuesta que se devolverá al cliente (res en el código) y un objeto que representa la siguiente función de middleware (next en el código); cada función de middleware ha de llamar a la siguiente función de middleware, excepto cuando se detecta algún error y se desea enviar inmediatamente una respuesta al cliente con la función res.send (como ocurre en la función creaEsquema).

Express añade automáticamente algunas cabeceras a la respuesta. Por ejemplo, si usamos un objeto de JavaScript como argumento de la llamada a send, los datos se convierten a JSON y la cabecera Content-Type se establece a application/json; charset=utf-8.

El código principal de la aplicación está formado por una serie de llamadas a funciones get, post, put y delete que registran las funciones de callback asociadas a las peticiones realizadas con los verbos y los URLs correspondientes. Observa cómo una subcadena del URL que comienza por el carácter de dos puntos (por ejemplo, :item) no se interpreta literalmente, sino que la subcadena real puesta en el URL de la llamada se usa para dar valor al atributo del objeto req.params ( en ese caso, req.params.item). A los atributos de los datos en JSON del bloque de datos de la petición nos podemos referir mediante el objeto req.body. A los atributos pasados en el propio URL tras el carácter de interrogación se puede acceder mediante el objeto req.query.

4.8.1. Interfaz común de acceso a bases de datos

Como queremos que nuestra aplicación web pueda funcionar con distintos gestores de bases de datos (Heroku permite usar PostgreSQL, Google Cloud Platform permite usar MySQL y en modo local vamos a usar una base de datos ligera con SQLite para hacer pruebas), nos interesa no tener que escribir código diferente para cada uno. Node.js no tiene un equivalente exacto a, por ejemplo, la tecnología JDBC de Java, pero el paquete Knex.js (pronunciado como konnex) se acerca bastante al permitirnos interactuar con diferentes gestores de bases de datos con una interfaz única. Con Knex.js usaremos funciones para construir las consultas a la base de datos que serán transformadas internamente en instrucciones SQL; las peticiones a la base de datos son asíncronas y se gestionan mediante promesas o mediante callbacks. Las funciones que a este respecto se usan en el código son bastante autoexplicativas y es muy sencillo deducir cuál es su transformación en SQL. Por ejemplo, las líneas de código:

let i= await knex('productos').select(['item','cantidad'])
                              .where('carrito',req.params.carrito)
                              .andWhere('item',req.params.item);

generan una petición SQL como la siguiente:

select `item`, `cantidad` from `productos` where `carrito` = ? and `item` = ?

4.8.2. Async/await

Las promesas de la API Fetch son, como hemos visto, una forma muy conveniente de gestionar peticiones a un servidor, pero cuando la llamada a un servicio web depende del resultado de una llamada anterior a otro servicio web y este anidamiento se va haciendo más y más complejo, la escritura del código puede ser muy dificultosa (especialmente a la hora de sangrarlo o de emparejar las llaves y los paréntesis). Para simplificarlo, se añadieron a JavaScript los modificadores async y await.

Cuando se invoca una función anotada con async, la respuesta a la llamada se procesa como si fuera una promesa. Si la función async devuelve un valor, dicha promesa se resolverá con el valor devuelto: si la función asíncrona generara una excepción, la promesa se rechazaría con el valor devuelto. Dentro de una función asíncrona puede aparecer cualquier número de expresiones await (estas expresiones, de hecho, solo pueden aparecer dentro de funciones async); una expresión await pausa la ejecución de la función asíncrona y espera a que la promesa se resuelva para continuar la ejecución de la función asíncrona.

El código de más arriba que accedía con Fetch a la API de películas del Studio Ghibli quedaría de la siguiente manera con async y await:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
async function ghibli() {
  let s= "";
  try {
    let response= await fetch('https://ghibliapi.herokuapp.com/films/');
    if (!response.ok) {
      throw Error(response.statusText);
    }
    let responseAsObject= await response.json();
    for (var i=0; i<responseAsObject.length;i++) {
      s+= responseAsObject[i].title+"; ";
    }
    return s;
  } catch(error) {
    console.log('Ha habido un problema: ', error);
  }
  return s;
}

async function print() {
  var resultado= document.querySelector("#results");
  resultado.textContent= await ghibli();
}

print();

4.9. Configuración del entorno de trabajo para ejecutar localmente una aplicación web

En esta actividad se explica cómo configurar el entorno de trabajo para poder lanzar aplicaciones web escritas en Node.js que usan una base de datos SQLite3.

Importante

El sistema operativo oficial en esta asignatura es Linux. Puedes utilizar otros sistemas operativos para desarrollar, pero tendrás que solucionar tú mismo los problemas relacionados con la configuración del entorno de trabajo que te encuentres. Las instrucciones que siguen son para el sistema operativo Linux, pero es muy probable que las puedas ignorar si usas el fichero dai-bundle-dev para instalar los programas necesarios. Para ello, descarga el fichero, descomprímelo y ejecuta el script install.sh.

Puedes editar el fichero para indicar qué programas quieres instalar. El script solo instala Node.js por defecto. Comprueba si tienes instalado sqlite3 en tu ordenador para saber si lo necesitas y pon a true la variable correspondiente en caso negativo. El script también permite instalar el SDK de Google Cloud Platform, que usaremos más adelante.

El script funciona sin problemas en el sistema Linux instalado en los ordenadores de los laboratorios, donde SQLite3 ya está instalado.

Comienza instalando Node.js, el entorno que te permitirá ejecutar programas en JavaScript fuera del navegador. Las instrucciones para cada sistema operativo son diferentes. Para el caso de Linux, la instalación se puede realizar fácilmente sin necesidad de tener privilegios de administrador descargando uno de los paquetes disponibles en la web de Node.js.

Nota

Si tu distribución de Linux tiene ya instalada una versión muy antigua de Node.js es recomendable que la quites antes de tu sistema con:

sudo apt-get remove nodejs

Este curso vamos a usar la versión 12 de Node.js. Descárgala con:

curl -O https://nodejs.org/download/release/v12.13.1/node-v12.13.1-linux-x64.tar.gz

Descomprime el fichero anterior en tu directorio raíz:

tar xzf node-v12.13.1-linux-x64.tar.gz -C $HOME

Añade el directorio bin a la variable PATH del sistema:

echo 'export PATH=$HOME/node-v12.13.1-linux-x64/bin:$PATH' >> $HOME/.bashrc

Abre un nuevo terminal para que el nuevo valor de la variable de entorno PATH se aplique. Ahora deberías poder ver la versión de Node.js instalada con:

node -v

La aplicación del carrito usa en modo local el gestor de base de datos ligero SQLite3 para no depender de gestores más complejos. Cuando la aplicación se despliegue en la nube (un poco más adelante lo haremos en Heroku y, posteriormente, en Google Cloud Platform), usará otros gestores de bases de datos, lo que explica las diferentes opciones dentro de la función conectaBD. Comprueba si ya tienes SQLite instalado ejecutando sqlite3 desde la línea de órdenes. Si no lo tienes, para el caso de Linux puedes descargar este fichero:

curl -O https://www.sqlite.org/2019/sqlite-tools-linux-x86-3300100.zip

Descomprime el fichero anterior en tu directorio raíz y añade el nuevo directorio a la variable PATH del sistema:

unzip -q sqlite-tools-linux-x86-3300100.zip -d $HOME
echo 'export PATH=$HOME/sqlite-tools-linux-x86-3300100:$PATH' >> $HOME/.bashrc

Abre un nuevo terminal para que el nuevo valor de la variable de entorno PATH se aplique.

Los binarios de SQLite están compilados para 32 bits, por lo que es posible que necesites instalar algunas librerías adicionales de 32 bits, ya que tu sistema es proablemente de 64 bits; la siguiente orden es para Ubuntu:

sudo apt-get install libc6-i386 lib32z1

Ahora deberías poder ver la versión de SQLite3 instalada con:

sqlite3 -version

A continuación, descarga el código del cliente y del servidor de la aplicación del carrito; clona para ello el repositorio de la asignatura haciendo:

git clone https://github.com/jaspock/dai1920.git

Entra en el directorio code/carrito y ejecuta:

npm install
node app.js

La primera línea instala en la carpeta node_modules todas las dependencias indicadas en el fichero package.json. La segunda línea lanza el motor de JavaScript sobre el fichero indicado. Como este fichero contiene una aplicación web escrita con el framework Express, este la ejecuta sobre un puerto local, por lo que podremos acceder a ella abriendo en el navegador una dirección como localhost:5000 o similar.

Nota

Si quisieras usar un nuevo paquete en tu aplicación (lo que probablemente no ocurrirá en esta asignatura), deberías ejecutar:

npm install paquete

donde paquete es el nombre del nuevo módulo; esta orden instala el nuevo módulo en la carpeta node_modules y, además, añade la línea adecuada al fichero package.json.

Nota

Observa la sección scripts del fichero package.json. Allí puedes definir diversas maneras de arrancar tu aplicación con diferentes configuraciones. En este caso, solo hay una entrada start que te permitiría lanzar también tu aplicación haciendo:

npm start

4.9.1. Depuración y prueba de la aplicación

Si deseas depurar el código del servidor en modo local puedes usar el editor de texto Visual Studio Code. Abre con él el fichero app.js y selecciona Debug / Start debugging. Puedes definir puntos de ruptura haciendo click en el borde de la línea de código oportuna.

Suele ser útil también poder ver un informe detallado de qué rutas puede procesar Express y qué hace con cada petición que llega; para ello puedes lanzar el servidor en modo depuración en Linux con:

DEBUG=express:* node app.js

Para depurar la aplicación cuando esta se encuentra desplegada en la nube, se necesitan algunas instrucciones adicionales. En el caso de nuestra aplicación, como el código que se ejecuta en localhost o en la nube es prácticamente el mismo, si la aplicación funciona en local, apenas deberían aparecer problemas en la nube.

Nota

Ten en cuenta que el código de JavaScript que se ejecuta en el navegador se seguirá depurando desde las Chrome DevTools. Durante la depuración de la aplicación irás alternando entre el navegador y Visual Studio Code según se esté ejecutando el código del lado del cliente o del servidor, respectivamente.

Al ejecutar la aplicación en modo local se usa el gestor de base de datos SQLite, que almacena la base de datos en un fichero indicado como opción de inicialización a Knex.js (en nuestra aplicación el fichero se indica en config.js). Si quieres borrar toda la base de datos para empezar de cero, basta con que borres ese fichero, que será creado de nuevo la siguiente vez que Knex.js quiera acceder a él.

Si quieres, realizar consultas a la base de datos local desde un cliente de SQL puedes hacer desde la línea de órdenes:

sqlite3 <ficheroBD>

donde has de indicar como argumento el nombre del fichero de la base de datos (si no has editado los ficheros de configuración del carrito se llamará midb.sqlite). Desde dentro del cliente puedes ejecutar instrucciones como:

.tables

para ver las tablas de la base de datos o:

select * from productos;

para consultarlas.

4.10. Modificación de la API REST

En esta actividad, vas a realizar una pequeña modificación a la API del carrito y a la aplicación web que la utiliza. El desarrollo lo realizarás en tu máquina y, cuando hayas comprobado que todo funciona correctamente, lo subirás en la siguiente actividad a un servidor de aplicaciones en la nube.

Consejo

Si vas a desarrollar frecuentemente con Node.js, te vendrá bien utilizar la herramienta nodemon, que evita que tengas que matar y volver a lanzar el servidor local cada vez que hagas un cambio en la aplicación.

Hazlo tú ahora

Modifica la parte del cliente y del servidor de la aplicación del carrito para que junto con la cantidad se pueda añadir el precio unitario de cada item. Necesitarás instalar en tu sistema Node.js y el gestor de base de datos SQLite3; sigue para ello los pasos detallados en la actividad «Configuración del entorno de trabajo para ejecutar localmente una aplicación web». Salvo que uses nodemon, como se ha comentado antes, tendrás que matar y relanzar el servidor para que se apliquen los cambios. Como siempre, tendrás que recargar la página en el navegador siempre que realices algún cambio en el código del cliente.

4.11. Despliegue de la aplicación web en Heroku

Cuando tengas la aplicación lista en modo local, puedes desplegarla en la plataforma en la nube de Heroku como sigue. Copia para empezar la carpeta dai1920/code/carrito en otra ubicación de tu sistema. Al copiar la carpeta a una ubicación diferente haces que su contenido no esté ligado al repositorio de Github, ya que para desplegar la aplicación en Heroku necesitas vincularla a otro repositorio.

Instala el cliente de línea de órdenes (CLI, por command-line interface) de Heroku con las instrucciones de esta página. En el caso de Linux basta con descargar el fichero con los binarios, descomprimirlo y añadir la carpeta bin a la variable PATH del sistema:

curl -O https://cli-assets.heroku.com/heroku-linux-x64.tar.gz
tar xzf heroku-linux-x64.tar.gz -C $HOME
echo 'export PATH=$HOME/heroku/bin:$PATH' >> $HOME/.bashrc

Si tienes permisos de administrador puedes instalar de forma alternativa el cliente de línea de órdenes de Heroku en Ubuntu con:

sudo curl https://cli-assets.heroku.com/install.sh | sh

o alternativamente:

sudo snap install --classic heroku

Continúa ahora configurando tu proyecto haciendo:

git init
git add .
git commit -m "cambios"

Con lo anterior, se crea un repositorio con los ficheros del proyecto, que podrás subir (push) a Heroku. Crea una cuenta en la web de Heroku e identifícate en el cliente de línea de órdenes ejecutando:

heroku login

Crea un proyecto en la nube haciendo:

heroku create --region eu

Desde este momento ya podrás desplegar la aplicación con:

git push heroku master

Nota

Heroku puede, en principio, leer del fichero app.json datos como el gestor de base de datos a utilizar o el valor de ciertas variables de entorno que estarán definidas en el entorno de producción, pero en el momento de escribir esto no funciona. Por ello, has de configurar estos aspectos de tu aplicación ejecutando lo siguiente desde la línea de órdenes:

heroku addons:create heroku-postgresql:hobby-dev
heroku config:set CARRITO_ENV=heroku

Si ejecutas heroku config podrás ver que ahora hay dos variables de entorno: CARRITO_ENV y DATABASE_URL.

Finalmente, abre la aplicación en el navegador con:

heroku open

Puedes estudiar los mensajes de actividad emitidos a la consola por tu aplicación implementada en Heroku con:

heroku logs

Para verlos conforme se van produciendo:

heroku logs --tail

Si haces cambios en la aplicación, basta con repetir estos pasos para actualizar la aplicación en Heroku:

git add .
git commit -m "cambios"
git push heroku master

Finalmente, puedes usar el panel de control de tu aplicación en Heroku para acceder a ciertas opciones adicionales de configuración. Por otro lado, en el panel de control de la base de datos PostgreSQL puedes visitar la sección Dataclips para poder ver las tablas de tu base de datos y lanzar consultas SQL sobre ellas.

4.12. Autenticación de usuarios

Una componente importante de la mayoría de aplicaciones web es la que permite que los usuarios se identifiquen o autentiquen en la aplicación y puedan gestionar así sus propios datos. La gestión de cuentas de usuario requiere un esfuerzo adicional (validación de cuentas de correo electrónico, almacenamiento seguro de las contraseñas, gestión de ataques cibernéticos, olvidos de contraseña, etc.) que podemos delegar en servicios de terceros como Facebook Login o Google Sign-in; este último será el que usaremos en esta actividad. De esta forma, el usuario se identifica en una ventana del navegador vinculada a un URL de Google y autoriza a nuestra aplicación a acceder a cierta información de su perfil (nombre y correo electrónico, por ejemplo) sin compartir el resto de sus datos (o permitiendo el acceso a un subconjunto de ellos, como, por ejemplo, los ficheros almacenados en una carpeta de Google Drive).

Nota

Esta actividad solo es necesaria este curso si vas a implementar la parte de la práctica 4 relacionada con la identificación de usuarios. Si no es así, puedes ignorar el resto, salvo que tengas curiosidad en aprender cómo se hace.

El primer paso para que una aplicación pueda acceder al servicio de identificación de Google Sign-in es obtener las credenciales adecuadas que nos permitan obtener el id del cliente, una secuencia de caracteres que necesitamos para poder usar el servicio desde el código JavaScript del navegador y desde el código en Node.js del servidor. Para ello accede en la consola web de Google Cloud Platform a la opción Credenciales dentro del menú APIs y servicios. Elije crear una credencial de tipo ID de cliente de OAuth. En la nueva pantalla, escoge web como tipo de aplicación, introduce un nombre para la aplicación, y en Orígenes de JavaScript autorizados indica los URLs desde los que harás peticiones: normalmente, indicarás un URL del tipo http://localhost:5000 para cuando la aplicación se lance en local y uno del tipo https://proyecto-10002.appspot.com/ para cuando se despliegue en un servidor en la nube. No has de indicar nada en la sección URIs de redirección autorizados. Con esta información, ya podrás obtener el id del cliente.

A continuación se describe una aplicación sencilla que permite que el usuario se identifique en el navegador con su cuenta de Google. La aplicación completa está en la carpeta code/gsignin del repositorio de la asignatura . Tras la autenticación, el código puede obtener una serie de datos del usuario entre los que es especialmente relevante el token id, que será el dato que se enviará al servidor y del que este extraerá un id del usuario que será el que se almacenará en las bases de datos para indicar el usuario asociado a un registro dado. Observa que no se envía al servidor un dato como la dirección de correo electrónico para usarlo como identificador del usuario porque podría cambiar en algún momento del futuro. Tampoco se le envía el id del usuario, sino un token más largo que codifica diferentes datos, incluyendo el id del usuario. Este token deberá ser validado por el servidor antes de dar por bueno el id que incluye.

Este es el código de la parte del cliente:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
<!doctype html>
<!-- Ejemplo de uso de la API de autenticación de Google Sign-in -->
<html lang="es">
<head>
  <meta charset="utf-8">
  <title>Autenticación de usuarios</title>

  <style>
    * {
      margin: 0;
      padding: 0;
    }
    .visible {
      display: inline;
    }
    .invisible {
      display: none;
    }
    #info {
      margin-left: 10px;
      margin-top: 10px;
    }
    #info p {
      margin-bottom: 5px;
      font-size: 90%;
      font-family: monospace;
    }
    section {
      margin-top: 15px;
      margin-bottom: 15px;
    }
    body {
      margin: 10px;
    }
  </style>
</head>
<body>

  <h1>Autenticación de usuarios con Google Sign-in</h1>

  <section>
    <form id="f0">
      <label>Mensaje:
        <input type="text" name="mensaje" required>
      </label>
      <button>Envía</button>
    </form>
  </section>

  <section>
    👤 <span id="usuario"></span>
    <button id="entrar" class="entrar" class="invisible">Identificarse</button>
    <button id="salir" class="invisible">Salir</button>
  </section>

  <section id="panel">
    <div>Información de autenticación:<div id="info"></div></div>
  </section>

  <!-- Librería para usar Google Sign-in -->
  <script src="https://apis.google.com/js/platform.js?onload=initGoogleAPI" async defer></script>

<script>

  const base="/mensajes"; // endpoint
  const cabeceras= {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  }

  // envía una petición al servidor con el texto del mensaje y el token id en la cabecera 'Authorization'
  function envíaMensaje(event) {
    event.preventDefault();  // evita que se recargue la página
    const url= `${base}/nuevomensaje`;
    const payload= {
      texto:document.querySelector("#f0 input[name='mensaje']").value,
    };
    const request = {
      method: 'POST', 
      headers: cabeceras,
      body: JSON.stringify(payload),
    };
    fetch(url,request)
    .then( response => response.json() )
    .then( r => {
      printInfo(`respuesta del servidor: ${JSON.stringify(r)}`);
      document.querySelector("#f0 input[name='mensaje']").value= '';
    })
    .catch( error => printInfo(`problema de conexión: ${error}`) );
  }

  function loginFirst (event) {
    event.preventDefault();
    alert("Identifícate antes de usar la aplicación")
  }

  // función de inicialización:
  function initApp () {
    let e= document.querySelector('#f0');
    // si no hay token id, el manejador es 'loginFirst'; si no, usa 'envíaMensaje'
    e.addEventListener('submit',e => id_token?envíaMensaje(e):loginFirst(e),false);
    e= document.querySelector("#salir");
    e.addEventListener('click',signOut,false);
  }

  document.addEventListener('DOMContentLoaded',initApp,false);

  // ---- código que se encarga de la identificación del usuario

  var id_token= null; // token a enviar al servidor

  // id de cliente obtenido en la consola web de Google Cloud Platform:
  const clientId= 'SUSTITUIR POR El CLIENT_ID OBTENIDO EN GOOGLE CLOUD!!!!!!';

  // muestra el mensaje en la página web junto con la hora:
  function printInfo (c) {
    let addZero= (x, n) => { while (x.toString().length < n) x = '0' + x; return x; }
    let d = new Date();
    let h = addZero(d.getHours(),2);
    let m = addZero(d.getMinutes(),2);
    let s = addZero(d.getSeconds(), 2);
    let ms = addZero(d.getMilliseconds(), 3);

    let e= document.querySelector('#info');
    e.innerHTML+= `<p>${h}:${m}:${s}:${ms}: ${c}</p>`;
  }

  // función que se invoca cuando la librería de Google está cargada; el nombre de esta función
  // se pasa como parámetro al elemento script:
  function initGoogleAPI() { 
    // inicializa el objeto GoogleAuth:
    gapi.load('auth2', function(){
      auth2 = gapi.auth2.init({
          client_id: clientId,
          scope: 'profile email'
      });

      const func= 'initGoogleAPI';
      printInfo(`${func}: valor de isSignedIn: ${auth2.isSignedIn.get()}`);

      document.querySelector('#usuario').textContent= '';
      // al principio se muestra el botón de identificación y se oculta el de cerrar sesión:
      document.querySelector('#salir').classList.add('invisible');
      document.querySelector('#entrar').classList.remove('invisible');

      // registra el botón que permite iniciar el proceso de autenticación y los funciones
      // callback correspondientes:
      auth2.attachClickHandler('entrar', {}, onSuccessSignIn, onFailureSignIn);

      // registra la función callback que se invoca cuando cambia el estado de logueado/no logueado del usuario:
      auth2.isSignedIn.listen(signinChanged);

      // registra la función callback que se llama cada vez que cambia el valor de currentUser:
      auth2.currentUser.listen(userChanged);
    });
  }

  // se ejecuta cuando cambia el estado de login del usuario:
  var signinChanged = function(val) {
    const func= 'signinChanged';
    printInfo(`${func}: el nuevo estado es ${val}`);
    if (val) {
      printInfo(`${func}: autenticado como ${auth2.currentUser.get().getBasicProfile().getName()}`);
      // token que se enviará al servidor para que lo valide e identifique al usuario:
      id_token= auth2.currentUser.get().getAuthResponse().id_token;
      // se añade a las cabeceras de las peticiones al servidor el token de autenticación:
      cabeceras["Authorization"]= `Bearer ${id_token}`;
      printInfo(`${func}: id token: ${id_token.substr(0,40)}...`);
      document.querySelector('#usuario').textContent= `${auth2.currentUser.get().getBasicProfile().getName()} `;
      document.querySelector('#salir').classList.remove('invisible');
      document.querySelector('#entrar').classList.add('invisible');
    }
    else {
      cabeceras["Authorization"]= '';
      document.querySelector('#usuario').textContent= '';
      document.querySelector('#salir').classList.add('invisible');
      document.querySelector('#entrar').classList.remove('invisible');
    }
    printInfo(`${func}: valor de isSignedIn: ${auth2.isSignedIn.get()}`);
  };

  // esta función se invoca cuando el usuario se loguea tras pulsar el botón; si el usuario estaba identificado
  // anteriormente y se recarga la página, esta función no se invoca (no se ha pulsado el botón), pero la
  // librería intenta identificar automáticamente al usuario y, si lo consigue, sí se invoca 'signinChanged' y
  // 'userChanged':
  function onSuccessSignIn(user) {
    const func= 'onSuccessSignIn';
    printInfo(`${func}: autenticado como ${user.getBasicProfile().getName()}`);
    // otra forma de obtener el mismo valor:
    printInfo(`${func}: autenticado como ${auth2.currentUser.get().getBasicProfile().getName()}`);

    var profile = user.getBasicProfile();
    // no enviar getId al servidor, sino el id token que puede ser validado en el servidor:
    printInfo(`${func}: id: ${profile.getId()}`); 
    printInfo(`${func}: name: ${profile.getName()}`);
    printInfo(`${func}: image URL: ${profile.getImageUrl().substr(0,40)}...`);
    printInfo(`${func}: email: ${profile.getEmail()}`); // nulo si no usamos el scope 'email'
    
    printInfo(`${func}: id token: ${user.getAuthResponse().id_token.substr(0,40)}...`);
  };

  // función de callback cuando el usuario no puede loguearse:
  function onFailureSignIn(error) {
    id_token= null;
    const func= 'onFailureSignIn';
    printInfo(`${func}: ${JSON.stringify(error)}`);
  };

  // manejador del evento de hacer clic en el botón de salir de la sesion:
  function signOut() {
    auth2.signOut().then(function () {
      const func= 'signOut';
      printInfo(`${func}: el usuario salió`);
      id_token= null;
      cabeceras["Authorization"]= '';
    });
  }        

  // función de callback invocada cuando cambia el usuario logueado:
  function userChanged(user) {
    if(user.getId()){
      const func= 'userChanged';
      printInfo(`${func}: id: ${user.getId()}`);
      var profile = user.getBasicProfile();
      printInfo(`${func}: name: ${profile.getName()}`);
    }
  };

</script>

</body>

</html>

El código del lado del navegador es el que lleva el mayor peso de la tarea. Como puedes ver, la librería de Google apis.google.com/js/platform.js simplifica gran parte del proceso. La función initGoogleAPI inicializa el objeto auth2 y registra diferentes funciones de callback que serán invocadas cuando el usuario se logre autenticar pulsando el botón (onSuccessSignIn), cuando el paso anterior falle (onFailureSignIn) o cuando el usuario se identifique por cualquier medio (signinChanged). Observa que es posible que se invoque signinChanged sin invocar onSuccessSignIn; por ejemplo, si un usuario se ha identificado previamente, no ha salido de la aplicación, pero cierra la página y vuelve a abrirla, entonces la librería autentica automáticamente al usuario y llama a signinChanged pero no a onSuccessSignIn. Se incluye también una función (signOut) para salir de la aplicación cuando se pulsa el botón correspondiente; en este caso, también se llamará a signinChanged con el valor false como parámetro.

El token id se obtiene con auth2.currentUser.get().getAuthResponse().id_token y se envía al servidor en la cabecera de HTTP Authorization con el prefijo estándar Bearer que identifica el tipo de autenticación.

En el lado del servidor, hay pocas novedades reseñables:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
'use strict';

const express = require('express');
const app = express();

const CLIENT_ID= 'SUSTITUIR POR El CLIENT_ID OBTENIDO EN GOOGLE CLOUD!!!!!!';

const {OAuth2Client} = require('google-auth-library');
const oauth2Client = new OAuth2Client(CLIENT_ID);

var user= null;
var base= '/mensajes';

app.use(express.json());

async function verify(token) {
  const ticket = await oauth2Client.verifyIdToken({
      idToken: token,
      audience: CLIENT_ID, 
  });
  console.log("Ticket: "+JSON.stringify(ticket));
  const payload = ticket.getPayload();
  return payload;
}

// middleware que comprueba que el usuario está autenticado, antes de pasar el contro a la siguiente función:
app.use(base+'/*', async (req, res, next) => {
  let token = req.headers['authorization']; // 'authorization' porque Express convierte las cabeceras a minúsculas
  console.log(`Cabecera Authorization: ${req.headers.authorization.substr(0,40)}`);
  if (!token) {
    res.status(404).send({ result:null,error:'falta la cabecera authorization' });
    return;
  }
  if (token.startsWith('Bearer ')) {
    token = token.slice(7,token.length);
  }
  else {
    res.status(404).send({ result:null,error:'cabecera authorization mal formada' });
    return;
  }
  try {
    // valida el token enviado por el cliente:
    let payload= await verify(token);
    // extrae del token el id del usuario:
    user= payload['sub'];
    console.log(`Id asignado al usuario ${payload.email} por el middleware: ${payload['sub']}`);
    next();
  } catch (error) {
    res.status(404).send({ result:null,error:`error en la cabecera authorization: ${error}` });
    return;
  }
});

app.post(base+'/nuevomensaje', (req,res) => {
  if (!user) {
    res.status(404).send({ result:null,error:'usuario no autenticado' });
    return;
  }
  // se omite, pero aquí se realizaría cualquier procesamiento del texto del mensaje ligado al usuario
  // identificado (como, por ejemplo, insertarlo en una base de datos vinculada al usuario):
  // ...
  console.log(`Procesando mensaje '${req.body.texto}' para el usuario ${user}...`)
  res.status(200).send({ result:`procesando mensaje '${req.body.texto}' para el usuario ${user}`,error:null });
});

const path = require('path');
const publico = path.join(__dirname, 'public');
// __dirname: carpeta del proyecto

app.use('/', express.static(publico));

const PORT = process.env.PORT || 5000;
app.listen(PORT, function () {
  console.log(`Aplicación lanzada en el puerto ${ PORT }!`);
});

Se ha añadido un middleware que obtiene el valor de la cabecera Authorization y lo verifica usando la función verifyIdToken del módulo de Node.js google-auth-library. Del objeto devuelto se puede extraer el id del usuario con getPayload()['sub'] y usar este valor como identificador del usuario en la base de datos (esta parte se ha omitido en el código, ya que ya se estudió en un apartado anterior).

Nota

Si necesitas aclarar algún aspecto del código anterior, puedes consultar la referencia del cliente de JavaScript de Google Sign-in, la guía Integrating Google Sign-in into your web app, la documentación de la librería de Google o la descripción del acceso con OAuth 2.0 a la API de Google.

Con lo anterior, la autenticación se realiza almacenando los datos del usuario en un token que genera una vez el servidor y que el cliente envía como cabecera en cada petición posterior, lo que constituye un ejemplo de autenticación sin sesión (session-less). El token suele codificar la información siguiendo el estándar JWT (por JSON web token) y es validado por el servidor para cada petición. La comunicación entre el cliente y el servidor se ha de realizar encriptada mediante protocolos seguros como SSL/HTTPS para que terceros no puedan obtener el token y suplantar la identidad del usuario. Los enfoques de autenticación basados en sesión, por otro lado, almacenarían los datos del usuario en la memoria del servidor (lo que pude plantear problemas si la cantidad de usuarios es muy grande) y enviarían una cookie con el identificador de sesión al cliente, que a su vez la adjuntaría a cada petición.

4.13. Términos de uso de las APIs web

Aunque no lo estudiaremos en esta asignatura, hay que tener en cuenta que existen en la web multitud de APIs disponibles para su uso desde aplicaciones de terceros, pero estas APIs suelen tener términos de uso (mira las condiciones de la API de Twitter, por ejemplo) que es importante leer antes de decidirse a basar una determinada aplicación en ellas.