6. Implementación de servicios web

Al estudiar anteriormente los servicios web, nos hemos centrado en el acceso a servicios ofrecidos por terceros; en este tema veremos cómo crear nuestros propios servicios web. 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.

Importante

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

  • Entender los fundamentos de la arquitectura REST.

  • Implementar servicios web con Node.js mediante Express.

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

  • Desplegar en la nube una aplicación web.

6.1. 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 juguete para gestionar carritos de la compra que se encuentra ya desplegada en la nube. 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:

export endpoint=https://dai2526-martin.garcia-e29533c2.ey.r.appspot.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 mientras se despierta el servidor. 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": [{"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, decodeURI, encodeURIComponent y encodeURI 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.

6.2. 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, que apenas incorpora novedades:

  1<!doctype html>
  2<!-- Ejemplos de uso de la API web del carrito con la API Fetch del navegador -->
  3<html lang="es">
  4<head>
  5  <meta charset="utf-8">
  6  <link href="style.css" rel="stylesheet" type="text/css">
  7  <title>Carrito</title>
  8</head>
  9<body>
 10
 11  <h1>Gestión de carritos de la compra</h1>
 12  <!-- Formularios: -->
 13  <form id="f0">
 14    <fieldset>
 15      <legend>Usar un carrito ya existente</legend>
 16      <!-- forma alternativa de label sin usar "for": rodeando el input afectado -->
 17      <label>Nombre del carrito:
 18        <input type="text" name="nombre" required>
 19      </label>
 20      <button>Usa</button>
 21    </fieldset>
 22  </form>
 23  <form id="f1">
 24    <fieldset>
 25      <legend>Crear un nuevo carrito</legend>
 26      <button>Crea</button>
 27    </fieldset>
 28  </form>
 29  <form id="f2">
 30    <fieldset>
 31      <legend>Añadir item al carrito</legend>
 32      <label>Nombre del item:
 33          <input type="text" name="item" required>
 34      </label> 
 35      <label>Cantidad:
 36          <input type="text" name="cantidad" required pattern="[0-9]+">
 37      </label> 
 38      <button>Añade</button>
 39    </fieldset>
 40  </form>
 41  <form id="f3">
 42    <fieldset>
 43      <legend>Actualizar cantidad de un item</legend>
 44      <label>Nombre del item:
 45          <input type="text" name="item" required>
 46      </label> 
 47      <label>Nueva cantidad:
 48          <input type="text" name="cantidad" required pattern="[0-9]+">
 49      </label> 
 50      <button>Actualiza</button>
 51    </fieldset>
 52  </form>
 53  <form id="f4">
 54    <fieldset>
 55      <legend>Borrar un item del carrito</legend>
 56      <label>Nombre del item:
 57          <input type="text" name="item" required>
 58      </label>
 59      <button>Borra</button> 
 60    </fieldset>
 61  </form>
 62  <form id="f5">
 63    <fieldset>
 64      <legend>Eliminar el carrito actual</legend>
 65      <button>Elimina</button>
 66    </fieldset>
 67  </form>
 68  
 69  <main>
 70    <div>Nombre del carrito actual: <span id="nombre"></span></div>
 71    <div>Última respuesta del servidor: <span id="mensaje"></span></div>
 72    <div>Contenido del carrito:</div>
 73    <table>
 74      <thead>
 75        <tr>
 76          <th>Item</th>
 77          <th>Cantidad</th>
 78        </tr>
 79      </thead>
 80      <tbody id="listado">
 81      </tbody>
 82    </table>
 83  </main>
 84
 85<script>
 86  // Endpoint de la API del carrito:
 87  const base="/carrito/v1";
 88  
 89  var carrito= "";
 90  const cabeceras= {
 91    'Content-Type': 'application/json',
 92    'Accept': 'application/json',
 93  }
 94
 95  function print(r) {
 96    const e= document.querySelector('#mensaje');  
 97    if(r.result) {
 98      e.textContent= JSON.stringify(r.result);
 99    }
100    else {
101      e.textContent= JSON.stringify(r.error);
102    }
103  }
104
105  function printError(s) {
106    const e= document.querySelector('#mensaje');  
107    e.textContent= `Problema de conexión: ${s}`;
108  }
109
110  
111  function usaCarrito (event) {
112    event.preventDefault(); // evita la recarga de la página
113    const e= document.querySelector("#f0 input[name='nombre']");
114    document.querySelector('#nombre').textContent= carrito= e.value;
115    e.value= "";
116    muestraCarrito();
117  }
118
119
120  function creaCarrito(event) {
121    event.preventDefault();
122    const url= `${base}/creacarrito`;
123    const payload= {};
124    const request = {
125      method: 'POST', 
126      headers: cabeceras,
127      body: JSON.stringify(payload),
128    };
129    fetch(url,request)
130    .then( response => response.json() )
131    .then( r => {
132      carrito= r.result.nombre;
133      document.querySelector("#nombre").textContent= carrito;
134      muestraCarrito();
135      print(r);
136    })
137    .catch( error => printError(error) );
138  }
139
140
141  function muestraCarrito() {
142    const url= `${base}/${carrito}/productos`;
143    const request = {
144      method: 'GET', 
145      headers: cabeceras,
146    };
147    fetch(url,request)
148    .then( response => response.json() )
149    .then( r => {
150      const e= document.querySelector('#listado');
151      e.innerHTML= '';
152      if (r.result) {
153        for(var i=0;i<r.result.length;i++) {
154          e.innerHTML+= `<tr>
155            <td>${r.result[i].item}</td>
156            <td>${r.result[i].cantidad}</td>
157            </tr>`;
158        }
159      }
160    })
161    .catch( error => printError(error) );
162  }
163   
164  
165  function nuevoItem (event) {
166    event.preventDefault();
167    const url= `${base}/${carrito}/productos`;
168    const payload= {
169      item:document.querySelector("#f2 input[name='item']").value,
170      cantidad:document.querySelector("#f2 input[name='cantidad']").value,
171    };
172    const request = {
173      method: 'POST', 
174      headers: cabeceras,
175      body: JSON.stringify(payload),
176    };
177    fetch(url,request)
178    .then( response => response.json() )
179    .then( r => {
180      print(r);
181      document.querySelector("#f2 input[name='item']").value= '';
182      document.querySelector("#f2 input[name='cantidad']").value= '';
183      muestraCarrito();
184    })
185    .catch( error => printError(error) );
186  }
187  
188  
189  function actualizaItem (event) {
190    event.preventDefault();
191    const item= document.querySelector("#f3 input[name='item']").value;
192    const url= `${base}/${carrito}/productos/${item}`;
193    const payload= {
194      cantidad:document.querySelector("#f3 input[name='cantidad']").value,
195    }; 
196    const request = {
197      method: 'PUT', 
198      headers: cabeceras,
199      body: JSON.stringify(payload),
200    };
201    fetch(url,request)
202    .then( response => response.json() )
203    .then( r => {
204      print(r);
205      document.querySelector("#f3 input[name='item']").value= '';
206      document.querySelector("#f3 input[name='cantidad']").value= '';
207      muestraCarrito();
208    })
209    .catch( error => printError(error) );
210  }
211  
212
213  function borraItem (event) {
214    event.preventDefault();
215    const item= document.querySelector("#f4 input[name='item']").value;
216    const url= `${base}/${carrito}/productos/${item}`;
217    const payload= {}; 
218    var request = {
219      method: 'DELETE', 
220      headers: cabeceras,
221      body: JSON.stringify(payload),
222    };
223    fetch(url,request)
224    .then( response => response.json() )
225    .then( r => {
226      print(r);
227      document.querySelector("#f4 input[name='item']").value= '';
228      muestraCarrito();
229    })
230    .catch( error => printError(error) );
231  }
232  
233
234  function eliminaCarrito (event) {
235    event.preventDefault();
236    const url= `${base}/${carrito}`;
237    const payload= {};
238    var request = {
239      method: 'DELETE', 
240      headers: cabeceras,
241      body: JSON.stringify(payload),
242    };
243    fetch(url,request)
244    .then( response => response.json() )
245    .then( r  => {
246      print(r); 
247      muestraCarrito();
248    })
249    .catch( error => printError(error) );
250  }
251
252
253  // Función de inicialización:
254  function init () {
255    let e= document.querySelector('#f0');
256    e.addEventListener('submit',usaCarrito,false); 
257    e= document.querySelector('#f1');
258    e.addEventListener('submit',creaCarrito,false);
259    e= document.querySelector('#f2');
260    e.addEventListener('submit',nuevoItem,false);
261    e= document.querySelector('#f3');
262    e.addEventListener('submit',actualizaItem,false);
263    e= document.querySelector('#f4');
264    e.addEventListener('submit',borraItem,false);
265    e= document.querySelector('#f5');
266    e.addEventListener('submit',eliminaCarrito,false);
267  }
268    
269  document.addEventListener('DOMContentLoaded',init,false);
270</script>
271
272</body>
273
274</html>

El código anterior muestra cómo hacer con fetch peticiones con verbos de HTTP diferentes a GET, y que incluyen información tanto en las cabeceras como en el bloque de datos (payload).

6.3. 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 implementar el servicio web al que hemos accedido desde el cliente mostrado en la actividad anterior hemos usado JavaScript con Node.js y el framework para desarrollo de aplicaciones web Express.js. Lo siguiente es el código de la parte del servidor. Más abajo, después del código, se comentan sus principales elementos.

  1'use strict';
  2
  3const express = require('express');
  4const app = express();
  5
  6// carga y ejecuta config.js
  7const config = require('./config.js');
  8
  9// objeto global que referencia a la librería Knex.js
 10var knex= null;
 11
 12// inicializa Knex.js para usar diferentes bases de datos según el entorno:
 13function conectaBD () {
 14  if (knex===null) {
 15    var options;
 16    if (process.env.CARRITO_ENV === 'gae') {
 17      options= config.gae;
 18      console.log('Usando Cloud SQL (MySQL) como base de datos en Google App Engine');
 19    } else if (process.env.CARRITO_ENV === 'gaesqlite3') {
 20      options= config.gaesqlite3;
 21      console.log('Usando SQLite como base de datos en Google App Engine');
 22    } else {
 23      options= config.localbd;
 24      console.log('Usando SQLite como base de datos local');
 25    }
 26    // La siguiente opción muestra la conversión a SQL de cada consulta:
 27    // options.debug= true;
 28    knex= require('knex')(options);
 29  }
 30}
 31
 32// crea las tablas si no existen:
 33async function creaEsquema(res) {
 34  try {
 35    let existeTabla= await knex.schema.hasTable('carritos');
 36    if (!existeTabla) {
 37      await knex.schema.createTable('carritos', (tabla) => {
 38        tabla.increments('carritoId').primary();
 39        tabla.string('nombre', 100).notNullable();
 40      });
 41      console.log("Se ha creado la tabla carritos");
 42    }
 43    existeTabla= await knex.schema.hasTable('productos');
 44    if (!existeTabla) {
 45      await knex.schema.createTable('productos', (table) => {
 46        table.increments('productoId').primary();
 47        table.string('carrito', 100).notNullable();
 48        table.string('item', 100).notNullable();
 49        table.integer('cantidad').unsigned().notNullable();
 50      });
 51      console.log("Se ha creado la tabla productos");
 52    }
 53  }
 54  catch (error) {
 55    console.log(`Error al crear las tablas: ${error}`);
 56    res.status(404).send({ result:null,error:'error al crear la tabla; contacta con el administrador' });
 57  }
 58}
 59
 60async function numeroCarritos() {
 61  let n= await knex('carritos').countDistinct('nombre as n');
 62  // the value returned by count in this case is an array of objects like [ { n: 2 } ]
 63  return n[0]['n'];
 64}
 65
 66async function numeroItems(carrito) {
 67  let r= await knex('productos').select('item')
 68                                .where('carrito',carrito);
 69  return r.length;
 70}
 71
 72async function existeCarrito(carrito) {
 73  let r= await knex('carritos').select('nombre')
 74                               .where('nombre',carrito);
 75  return r.length>0;
 76}
 77
 78async function existeItem(item,carrito) {
 79  let r= await knex('productos').select('item')
 80                                .where('item',item)
 81                                .andWhere('carrito',carrito);
 82  return r.length>0;
 83}
 84
 85
 86// convierte el cuerpo del mensaje de la petición en JSON al objeto de JavaScript req.body:
 87app.use(express.json());
 88
 89// middleware para descodificar caracteres UTF-8 en la URL:
 90app.use( (req, res, next) => {
 91  req.url = decodeURI(req.url);
 92  next();
 93});
 94
 95// middleware para las cabeceras de CORS:
 96app.use( (req, res, next) => {
 97  res.header("Access-Control-Allow-Origin", "*");
 98  res.header('Access-Control-Allow-Methods', 'DELETE, PUT, GET, POST, OPTIONS');
 99  res.header("Access-Control-Allow-Headers", "content-type");
100  next();
101});
102
103
104// middleware que establece la conexión con la base de datos y crea las 
105// tablas si no existen; en una aplicación más compleja se crearía el
106// esquema fuera del código del servidor:
107app.use( async (req, res, next) => {
108  conectaBD();
109  await creaEsquema(res);
110  next();
111});
112
113
114// crea un carrito:
115app.post(config.app.base+'/creacarrito', async (req,res) => {
116  try {
117    let n= await numeroCarritos();
118    if (n>=config.app.maxCarritos) {
119      res.status(404).send({ result:null,error:'No caben más carritos; contacta con el administrador' });
120      return;
121    }
122    let existe= true;
123    while (existe) {
124      var nuevo = Math.random().toString(36).substring(7);
125      existe= await existeCarrito(nuevo);
126    }
127    var c= { nombre:nuevo };
128    await knex('carritos').insert(c);
129    res.status(200).send({ result:{ nombre:nuevo },error:null });
130  } catch (error) {
131    console.log(`No se puede crear el carrito: ${error}`);
132    res.status(404).send({ result:null,error:'no se pudo crear el carrito' });
133  }
134});
135
136
137// crea un nuevo item:
138app.post(config.app.base+'/:carrito/productos', async (req, res) => {
139  if (!req.body.item || !req.body.cantidad) {
140    res.status(404).send({ result:null,error:'datos mal formados' });
141    return;
142  }
143  try {
144    let existe= await existeCarrito(req.params.carrito);
145    if (!existe) {
146      res.status(404).send({ result:null,error:`carrito ${req.params.carrito} no existente` });
147      return;  
148    }
149    existe= await existeItem(req.body.item,req.params.carrito);
150    if (existe) {
151      res.status(404).send({ result:null,error:`item ${req.body.item} ya existente` });
152      return;
153    }
154    let n= await numeroItems(req.params.carrito);
155    if (n>=config.app.maxProductos) {
156      res.status(404).send({ result:null,error:`No caben más productos en el carrito ${req.params.carrito}` });
157      return;
158    }
159    var i= { carrito:req.params.carrito,item:req.body.item,cantidad:req.body.cantidad };
160    await knex('productos').insert(i);
161    res.status(200).send({ result:'ok',error:null });
162  } catch (error) {
163    console.log(`No se puede añadir el item: ${error}`);
164    res.status(404).send({ result:null,error:'no se pudo añadir el item' });
165  }
166});
167
168
169// lista los items de un carrito:
170app.get(config.app.base+'/:carrito/productos', async (req, res) => {
171  try {
172    let existe= await existeCarrito(req.params.carrito);
173    if (!existe) {
174      res.status(404).send({ result:null,error:`carrito ${req.params.carrito} no existente` });
175      return;  
176    }
177    let i= await knex('productos').select(['item','cantidad'])
178                                  .where('carrito',req.params.carrito);
179    res.status(200).send({ result:i,error:null });
180  } catch (error) {
181    console.log(`No se puede obtener los productos del carrito: ${error}`);
182    res.status(404).send({ result:null,error:'no se pudo obtener los datos del carrito' });
183  }
184});
185
186
187// lista los datos de un item:
188app.get(config.app.base+'/:carrito/productos/:item', async (req, res) => {
189  try {
190    let existe= await existeCarrito(req.params.carrito);
191    if (!existe) {
192      res.status(404).send({ result:null,error:`carrito ${req.params.carrito} no existente` });
193      return;  
194    }
195    existe= await existeItem(req.params.item,req.params.carrito);
196    if (!existe) {
197      res.status(404).send({ result:null,error:`item ${req.params.item} no existente` });
198      return;
199    }
200    let i= await knex('productos').select(['item','cantidad'])
201                                  .where('carrito',req.params.carrito)
202                                  .andWhere('item',req.params.item);
203    res.status(200).send({ result:i[0],error:null });
204  } catch (error) {
205    console.log(`No se pudo obtener el item: ${error}`);
206    res.status(404).send({ result:null,error:'no se pudo obtener el item' });
207  }
208});
209
210
211// modifica un item:
212app.put(config.app.base+'/:carrito/productos/:item', async (req, res) => {
213  if (!req.body.cantidad) {
214    res.status(404).send({ result:null,error:'datos mal formados' });
215    return;
216  }
217  try {
218    let existe= await existeCarrito(req.params.carrito);
219    if (!existe) {
220      res.status(404).send({ result:null,error:`carrito ${req.params.carrito} no existente` });
221      return;  
222    }
223    existe= await existeItem(req.params.item,req.params.carrito);
224    if (!existe) {
225      res.status(404).send({ result:null,error:`item ${req.params.item} no existente` });
226      return;
227    }
228    await knex('productos').update('cantidad',req.body.cantidad)
229                           .where('carrito',req.params.carrito)
230                           .andWhere('item',req.params.item);
231    res.status(200).send({ result:'ok',error:null });
232  } catch (error) {
233    console.log(`No se pudo obtener el item: ${error}`);
234    res.status(404).send({ result:null,error:'no se pudo obtener el item' });
235  }
236});
237
238
239// borra un item:
240app.delete(config.app.base+'/:carrito/productos/:item', async (req, res) => {
241  try {
242    let existe= await existeCarrito(req.params.carrito);
243    if (!existe) {
244      res.status(404).send({ result:null,error:`carrito ${req.params.carrito} no existente` });
245      return;  
246    }
247    existe= await existeItem(req.params.item,req.params.carrito);
248    if (!existe) {
249      res.status(404).send({ result:null,error:`item ${req.params.item} no existente` });
250      return;
251    }
252    await knex('productos').where('carrito',req.params.carrito).andWhere('item',req.params.item).del();
253    res.status(200).send({ result:'ok',error:null });
254  } catch (error) {
255    console.log(`No se pudo obtener el item: ${error}`);
256    res.status(404).send({ result:null,error:'no se pudo obtener el item' });
257  }
258});
259
260
261// borra un carrito:
262app.delete(config.app.base+'/:carrito', async (req, res) => {
263  try {
264    let existe= await existeCarrito(req.params.carrito);
265    if (!existe) {
266      res.status(404).send({ result:null,error:`carrito ${req.params.carrito} no existente` });
267      return;  
268    }
269    await knex('productos').where('carrito',req.params.carrito)
270                           .del();
271    await knex('carritos').where('nombre',req.params.carrito)
272                          .del();
273    res.status(200).send({ result:'ok',error:null });
274  } catch (error) {
275    console.log(`No se pudo encontrar el carrito: ${error}`);
276    res.status(404).send({ result:null,error:'no se pudo encontrar el carrito' });
277  }
278});
279
280
281// borra toda la base de datos:
282app.get(config.app.base+'/clear', async (req,res) => {
283  try {
284    await knex('productos').where('carrito',req.params.carrito)
285                           .del();
286    await knex('carrito').where('nombre',req.params.carrito)
287                         .del();
288    res.status(200).send({ result:'ok',error:null });
289  } catch (error) {
290    console.log(`No se pudo borrar la base de datos: ${error}`);
291  }
292});
293
294
295const path = require('path');
296const publico = path.join(__dirname, 'public');
297// __dirname: directorio del fichero que se está ejecutando
298
299app.get(config.app.base+'/', (req, res) => {
300  res.status(200).send('API web para gestionar carritos de la compra');
301});
302
303app.get(config.app.base+'/ayuda', (req, res) => res.sendFile(path.join(publico, 'index.html')));
304
305app.use('/', express.static(publico));
306
307const PORT = process.env.PORT || 5000;
308app.listen(PORT, function () {
309  console.log(`Aplicación lanzada en el puerto ${ PORT }!`);
310});

Express es un módulo de Node.js que permite definir APIs de forma sencilla. 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.

Nota

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 o enlaza (llamando a app.use) varias funciones de middleware, que serán invocadas incondicionalmente por el framework secuencialmente (en el orden en que aparecen en el código) para cada petición y que se encargan de diversos aspectos:

  • el middleware express.json, ya incluido en Express, analiza el JSON de los bloques de datos de las peticiones y crea el objeto de JavaScript correspondientes en req.body;

  • el resto de funciones de middleware que se registran en este código llamando a app.use las definimos nosotros: la primera de ellas descodifica los caracteres que usen la notación por ciento (véase una actividad anterior en este tema); la siguiente añade a la respuesta las cabeceras necesarias para permitir peticiones CORS (como veremos más adelante); la última establece la conexión con el gestor de base de datos (como se comenta más abajo).

Todas estas funciones de middleware son llamadas por Express con tres parámetros convenientemente inicializados: un objeto que representa la solicitud (llamado req en nuestro 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 ejecuta un determinado código (que normalmente consultará o modificará los objetos req o res) y llama a la siguiente función de middleware, excepto cuando se desea acabar inmediatamente el ciclo petición-respuesta y mandar una respuesta al cliente con la función res.send (porque la respuesta está lista o porque se detecta algún error como ocurre en la función creaEsquema). La siguiente función de middleware recibirá sendas referencias a los mismos objetos de solicitud y respuesta que la actual función puede haber modificado. Aunque es más habitual ir modificando el objeto de la respuesta (con valores que finalmente se devolverán al cliente), en ocasiones puede ser de interés modificar datos de la petición (como en nuestra función de middleware que descodifica la URL).

El código principal de la aplicación está formado por una serie de llamadas a los métodos get, post, put y delete, que registran o enlazan funciones de middleware adicionales sobre el objeto de la aplicación; estas funciones se conocen también como funciones manejadoras o de callback, y definen el enrutamiento vinculado con las peticiones en función de su verbo y su URL. Cada petición se realiza con un verbo de HTTP y un URL concreto (a la combinación de ambos se le conoce como endpoint). Estas funciones de middleware, por tanto, no se ejecutan sistemáticamente para cada petición, sino solo cuando la URL y el verbo de la petición casan con el método y ruta indicados.

Nota

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.

Los diferentes métodos de enrutamiento permiten indicar la ruta del recurso como, por ejemplo, /:carrito/productos. 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 a un atributo del objeto req.params ( en ese caso, req.params.item). A los atributos de los datos en JSON que aparecen en el bloque de datos (payload) de la petición nos podemos referir mediante el objeto req.body, como ya hemos comentado. A los atributos pasados en el propio URL tras el carácter de interrogación se puede acceder mediante el objeto req.query.

Si una función de middleware no va a llamar a otra función de middleware posterior (porque devuelve la respuesta al servidor usualmente), no es necesario que declare el tercer parámetro (el que hemos llamado next en otros sitios de nuestro código) en la lista de parámetros del manejador.

Atención

Ten en cuenta que el orden en que se registran las funciones de middleware es relevante porque define el orden en que Express las irá llamando. Si el URL de una petición casa con dos rutas registradas (por ejemplo, el URL /listado/clientes casa tanto con /:id/clientes como con /listado/:id), pero en la función de middleware de la primera no se llama a next, entonces la segunda función de middleware no será invocada. Express invoca únicamente a la primera función de middleware válida; si se invocan más funciones es porque cada una llama a la siguiente a través del tercer parámetro.

Nota

La función de middleware express.static, ya definida en el framework, permite indicar un directorio que contiene recursos estáticos (una imagen o una hoja de estilo, por ejemplo) a los que puede acceder el cliente. En nuestro código se llama a app.use para vincular dicha función a la ruta raíz y poder servir desde ella los archivos incluidos en la carpeta public. El argumento de express.static se interpreta, por defecto, como un directorio relativo al directorio actual. Normalmente nos interesará especificar la ruta absoluta, lo que se consigue añadiéndole como prefijo el contenido de la variable global de Node.js __dirname, que contiene la ruta absoluta del fichero de JavaScript que se está ejecutando.

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

Como queremos que nuestra aplicación web pueda funcionar en distintas plataformas y estas usan distintos gestores de bases de datos (por ejemplo, el proveedor Heroku incluye un servicio basado en PostgreSQL, Google Cloud Platform permite usar MySQL, y en modo local vamos a usar una base de datos ligera mediante SQLite para hacer pruebas), nos interesa no tener que escribir código diferente para cada sistema de base de datos. 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` = ?

Knex.js realiza las consultas a la base de datos de forma asíncrona de manera que nuestro código pueda seguir ejecutándose mientras el gestor de bases de datos devuelve un resultado. Por ello, la mayoría de métodos que invoquemos (como select en el fragmento anterior) devolverán una promesa; pero si es así, ¿dónde está la llamada al método then correspondiente? El pequeño fragmento de código anterior y el código completo del servidor de esta actividad usan una palabra reservada de JavaScript (await) que no conocíamos. Vamos a ver a continuación, como por medio de ella podemos simplificar la escritura de código que usa promesas y se hace innecesario el uso de los métodos then y catch.

6.3.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 dificultosa y afectar negativamente a su legibilidad. Para simplificarlo, entre otros motivos, se añadieron a JavaScript los modificadores async y await.

Una función anotada con async siempre devuelve una promesa. Si la función ya devuelve una promesa, el intérprete no tiene más que hacer. Si devuelve otro tipo de valor, el intérprete crea una promesa que se resuelve (se cumple) directamente con el valor devuelto. Si la función asíncrona lanza una excepción, esta se envuelve en una promesa incumplida.

Observa cómo la siguiente función devuelve una promesa:

1async function foo() {
2  return 1;
3}
4
5foo().then(console.log); // imprime 1

Nota

Asegúrate de que entiendes por qué en el código anterior foo().then(console.log) es una versión más corta que la equivalente foo().then(x => console.log(x)).

Dentro de una función asíncrona pueden aparecer cualquier número de expresiones await (estas expresiones, de hecho, solo pueden aparecer dentro de funciones async). Una expresión await va seguida de un objeto de tipo promesa; al llegar a ella el intérprete de JavaScript pausa la ejecución de la función asíncrona y espera a que la promesa se resuelva para continuar con la ejecución. Mientras tanto, otras funciones que estén esperando en la cola de callbacks serán atendidas como procede. El uso de async, por tanto, constituye una sintaxis alternativa, más elegante y legible, al uso de promesa.then.

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

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

Si una promesa se cumple, entonces await promesa devuelve el resultado (que aquí asignamos a las variables response y responseAsObject). Si la promesa no se cumple, se lanzará automáticamente una excepción en ese punto que podremos capturar dentro de un bloque try..catch; en el ejemplo anterior hay tres posibles motivos por los que se puede terminar ejecutando el código del bloque catch: la excepción lanzada explícitamente con throw y los incumplimientos de las promesas de fetch (cuando no se puede conectar con el servidor) o de json (cuando el bloque de datos de la respuesta del servidor no puede convertirse en un objeto de JavaScript).

Volviendo al código que usa Knex.js, las siguientes dos líneas de código son equivalentes, pero la segunda sintaxis nos permitirá escribir código más limpio cuando tengamos consultas encadenadas a la base de datos (como ocurre en muchos puntos de la API del carrito):

1knex.table('users').first('id', 'name').then(function(row) {console.log(row);});
2
3console.log(await knex.table('users').first('id', 'name'))

Hazlo tú ahora

Tras analizar y entender el código anterior, tanto el del lado del cliente como el del servidor, estudia el vídeo en el que se realiza una traza de su ejecución: parte 1-1.

6.4. 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. Se ofrecen dos vías: una rápida que permite instalar todos los programas necesarios con un script para Linux y otra que requiere ir instalándolos uno a uno.

6.4.1. Instalación rápida en Linux

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, además, funciona sin problemas en el sistema Linux instalado en los ordenadores de los laboratorios, donde SQLite3 ya está instalado.

Si todo ha ido bien, puedes saltar el siguiente apartado y pasar a ejecutar directamente la aplicación del carrito como se comenta en «Prueba de la aplicación del carrito».

6.4.2. Instalación paso a paso

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 20 de Node.js. Descárgala con:

curl -O https://nodejs.org/download/release/v20.9.0/node-v20.9.0-linux-x64.tar.xz

Descomprime el fichero anterior en tu directorio raíz:

tar xvf node-v20.9.0-linux-x64.tar.xz -C $HOME

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

echo 'export PATH=$HOME/node-v20.9.0-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 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/2023/sqlite-tools-linux-x64-3440000.zip

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

unzip -o sqlite-tools-linux-x64-3440000.zip -d $HOME/sqlite-x64-3440000
echo 'export PATH=$HOME/sqlite-x64-3440000:$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 SQLite3 instalada con:

sqlite3 -version

6.4.3. Prueba de la aplicación del carrito

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/martingrm/dai2526.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

6.4.4. 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 Run / 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.

Por último, si añades a la inicialización de Knex.js la propiedad debug:true se mostrará el equivalente en SQL de cada operación realizada con la librería.

6.5. Modificación de la API REST

En esta actividad, vas a realizar una pequeña modificación de la API del carrito y de 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.

Para utilizar nodemon mientras depuras desde Visual Studio Code, has de instalar globalmente el programa con npm install -g nodemon. A continuación, abre desde el editor de texto el directorio donde se almacena la aplicación web y abre el código del servidor. En la vista de Run de la izquierda, pincha en create a launch.json file y selecciona el entorno Node.js. En el fichero launch.json que se te abre, pulsa el botón Add configuration… y selecciona Node.js: Nodemon setup y graba el fichero. Finalmente, en el menú desplegable de la parte superior de la vista Run escoge nodemon y lanza la aplicación en modo depuración desde el menú Run.

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.

6.6. Despliegue básico de la aplicación web en Google App Engine con persistencia limitada vía SQLite3

Cuando tengas la aplicación lista en modo local, puedes desplegarla en la nube de Google Cloud Platform (en concreto, en el servicio Google App Engine) como sigue. De momento lo haremos usando SQLite3 como gestor de base de datos, pero más adelante cambiaremos a MySQL. El inconveniente principal de usar SQLite3 es que la base de datos se almacena en un fichero local que se borrará cada vez que se reinicie la máquina virtual. Cuando pasa un tiempo sin atender ninguna petición, por ejemplo, la máquina se elimina, por lo que no podemos confiar en este enfoque para una aplicación real.

Configura en primer lugar la aplicación de la línea de órdenes gcloud tal como se explica en el apartado «Configuración del entorno de trabajo para Google Cloud Platform». A continuación, colócate en el directorio de la aplicación del carrito, asegúrate de que la variable CARRITO_ENV tiene el valor gaesqlite3 en el fichero app.yaml y ejecuta:

gcloud app deploy
gcloud app browse

Se abrirá en tu navegador la página correspondiente.

6.7. La política del mismo origen y el estándar CORS

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 (es decir, el cliente realizaría una petición a su servidor y desde este, donde ya no existe la restricción del mismo origen al no haber navegador ni riesgos, se realizaría la petición al servidor de terceros). 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.

Nota

Para algunos tipos de peticiones, como las de tipo PUT o DELETE y, en ocasiones, dependiendo de las cabeceras empleadas, también GET o POST, el navegador realiza (no entraremos a analizar los motivos, aunque tienen que ver con no hacer que el servidor modifique su estado ante una petición que no se va a aceptar) una comunicación previa con el servidor (usando el verbo OPTIONS) para realizar algunas comprobaciones pre-vuelo (pre-flight). En esta petición, el navegador añade, además, de Origin, la cabecera Access-Control-Request-Method con el verbo para el que se desea realizar la petición posterior y Access-Control-Request-Headers con las cabeceras que se emplearán en dicha petición. El servidor contesta incluyendo en la respuesta las cabeceras Access-Control-Allow-Origin, Access-Control-Allow-Methods (verbos admitidos, en forma de lista de verbos separados por comas) y Access-Control-Allow-Headers (cabeceras de HTTP admitidas); si los valores permitidos son compatibles con la petición a realizar, esta se lleva finalmente a cabo por parte del navegador.

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 de validación, por ejemplo.

Hazlo tú ahora

En una actividad anterior tienes un ejemplo de acceso a un servicio (el de información sobre las películas del 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.

Importante

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.

6.7.1. 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 correspondiente endpoint de la API que se está ejecutando en otro puerto u otro servidor en la nube. Estudia el middleware del código del servidor que gestiona la respuesta.

Para lanzar el cliente desde un servidor web local, 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 GET, POST, PUT o DELETE realizan aquí una comprobación pre-vuelo con el verbo OPTIONS. Modifica el cliente para que envíe una cabecera adicional no convencional como Authorization 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.

6.8. 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 Sign In with Google for Web; 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 Sign In with Google for Web es obtener las credenciales adecuadas que nos permitan obtener el id del cliente, una secuencia de caracteres que necesitamos para poder identificar unívocamente al usuario 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. Elige crear una credencial de tipo ID de cliente de OAuth. Serás redirigido en primer lugar a la configuración de la pantalla de consentimiento de OAuth: indica como usuarios objetivo a usuarios externos y luego aporta solo los datos obligatorios (nombre de la aplicación y tu dirección de correo electrónico de gcloud.ua.es). De vuelto a la pantalla de credenciales, elige de nuevo 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 (importante: en este caso, has de indicar un segundo URL sin el puerto, es decir, http://localhost) 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 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 que ayuden a identificarlo entre los que es especialmente relevante el id, que será el dato que se enviará al servidor y que se almacenará en las bases de datos, llegado el caso, para indicar el usuario asociado a un registro dado. Observa que no se debería enviar 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. Este es el código:

 1<!doctype html>
 2<!-- Ejemplo de uso de la API de autenticación de Sign In with Google for Web -->
 3<html lang="es">
 4<head>
 5  <meta charset="utf-8">
 6  <title>Autenticación de usuarios con 
 7    Sign In with Google for Web</title>
 8
 9  <style>
10    * {
11      margin: 0;
12      padding: 0;
13    }
14    .invisible {
15      display: none;
16    }
17    body {
18      margin: 10px;
19    }
20    #datos {
21      margin-bottom: 10px;
22    }
23  </style>
24
25  <!-- documentación: https://developers.google.com/identity/gsi/web/guides/overview -->
26  <script src="https://accounts.google.com/gsi/client" async defer></script>
27  <script src="https://unpkg.com/jwt-decode@4.0.0/build/cjs/index.js" async defer></script>
28
29  <script>
30    function handleCredentialResponse(response) {
31      console.log("Encoded JWT ID token: " + response.credential);
32      const responsePayload = jwtDecode(response.credential); 
33      console.log("ID: " + responsePayload.sub);
34      console.log('Full Name: ' + responsePayload.name);
35      console.log('Given Name: ' + responsePayload.given_name);
36      console.log('Family Name: ' + responsePayload.family_name);
37      console.log("Image URL: " + responsePayload.picture);
38      console.log("Email: " + responsePayload.email);
39      document.querySelector("#datos").textContent= `Usuario: 👤 ${responsePayload.email}`;
40      document.querySelector("#signin_button").classList.add("invisible");
41      document.querySelector("#signout_button").classList.remove("invisible");
42    }
43
44    window.onload = function () {
45      google.accounts.id.initialize({
46        // id de cliente obtenido en la consola web de Google Cloud Platform:
47        client_id: 'AÑADE AQUÍ TU ID DE CLIENTE OBTENIDO DE SIG IN WITH GOOGLE FOR WEB',
48        auto_select: "false",
49        callback: handleCredentialResponse
50      });
51      google.accounts.id.renderButton(
52        document.getElementById("signin_button"),
53        { theme: "outline", size: "large" }  
54      );
55      google.accounts.id.prompt(); // also display the One Tap dialog
56      var button = document.getElementById('signout_button');
57      button.addEventListener("click", () => {
58        google.accounts.id.disableAutoSelect();
59        console.log("Sign out button clicked.");
60        document.querySelector("#datos").textContent= `Usuario sin identificar`;
61        document.querySelector("#signin_button").classList.remove("invisible");
62        document.querySelector("#signout_button").classList.add("invisible");
63      })
64    }
65  </script>
66
67</head>
68<body>
69
70  <h1>Autenticación con Sign In with Google for Web</h1>
71
72  <div id="datos">Usuario sin identificar</div>
73  <div id="signin_button"></div>
74  <button class="invisible g_id_signout" id="signout_button">Salir de la sesión</div>
75
76</body>
77</html>

6.9. 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.