API Fetch (AJAX)

<< Promesas NPM >>

Una llamada AJAX es una petición al servidor web que se realiza desde JavaScript tras haberse cargado la página. La característica más importante de utilizar estas llamadas es no necesitar volver a recargar la página (actual u otra) desde cero. Es por tanto una buena opción para ahorrar ancho de banda, ya que el servidor envía sólo los datos que necesita (por ejemplo para actualizar alguna información sobre un producto en la web), además de mejorar en gran medida el rendimiento.

AJAX es el acrónimo de Asynchronous JavaScript And XML, aunque hoy en día JSON es el formato más comúnmente usado para enviar y recibir datos del servidor (JSON se integra de forma nativa con JavaScript). Asíncrono significa que si realizamos una petición, la respuesta no la recibiremos de forma inmediata. Utilizaremos promesas para gestionar estas peticiones.

Métodos básicos HTTP

Cuando hacemos una petición HTTP, el navegador envía al servidor: cabeceras (sirven para identificar el cliente, las preferencias de idioma, etc.), el método de petición HTTP, la URL y datos o cuerpo de la petición (si es necesario).

Los métodos de petición más usados son:

  • GET → Generalmente es una petición para obtener datos sin realizar modificaciones a los mismos. Si el backend trabaja con bases de datos SQL, generaría una consulta (o varias) SELECT. Este tipo de petición no suele enviar datos asociados, toda la información para el servidor va contenida en la URL. Normalmente la respuesta contendrá los datos que hemos pedido al servidor (siempre que no se produzca algún tipo de error).
  • POST → Normalmente se utiliza para peticiones que implican la creación de un nuevo recurso en el backend (registro de usuario, comentario, producto, etc.). Esto equivale a realizar una consulta INSERT en bases de datos SQL. Los datos a registrar se incluyen en el cuerpo de la petición, no en la URL. Hay peticiones como el login que aunque no implican la creación de un recurso, se hacen usando POST para ocultar datos sensibles como la contraseña y no exponerlos en la URL.
  • PUT → Esta operación actualiza un recurso existente en el servidor. Equivale a UPDATE en SQL. La información que identifica el objeto a actualizar se suele enviar en la URL (como en la petición GET), y los datos a modificar se envían en el cuerpo de la llamada (como en la petición POST). Hay servidores que diferencian entre PUT (sustituir un recurso, obliga a enviar todos los campos aunque algunos no hayan cambiado) y PATCH (permite incluir solo aquellos campos cuyo valor haya cambiado).
  • DELETE → Esta operación elimina un dato del servidor, lo que equivale a la operación DELETE de SQL. La información que identifica al objeto a eliminar será enviada en la URL (como en GET).

La API Fetch

Esta nueva API de JavaScript nos facilita mucho el trabajo comparado con las llamadas clásicas al servidor usando XMLHttpRequest. Está soportada por todos los navegadores modernos.

Para utilizar esta API, tenemos la función global fetch. Esta función devuelve una promesa con un objeto del tipo Response. La promesa puede ser rechazada cuando hay un error con la comunicación con el servidor, pero no cuando el servidor devuelve un código de error. Esto lo debemos controlar manualmente y decidir si lanzar un error.

Petición GET

Por defecto, si no especificamos un método, la petición es de tipo GET. Basta con poner la URL a la que queremos acceder y procesar la respuesta.

Si la respuesta contiene datos (con este tipo de peticiones lo normal es que sí), tendremos que llamar al método json() del objeto Response, para deserializar la respuesta como objeto JSON. Este método devuelve a su vez una promesa con el objeto JSON resultante. Otros métodos que podemos usar son: text() → Respuesta en texto plano, blob() → La respuesta es un archivo, arrayBuffer() → Array de bytes.

Ejemplo de petición GET:

function getProductos() {
    fetch(`${SERVER}/products`).then(resp => {
        if(!resp.ok) throw new Error(resp.statusText); // El servidor devuelve un código de error
        return resp.json(); // Promesa
    }).then(respJSON => {
        respJSON.products.forEach(p => mostrarProducto(p));
    }).catch(error => console.error(error));
}

Alternativamente, si usamos la sintaxis async/await:

async function getProductos() {
    try{
        const resp = await fetch(`${SERVER}/products`)
        if(!resp.ok) throw new Error(resp.statusText); // El servidor devuelve un código de error
        const respJSON = await resp.json(); // Promesa
        respJSON.products.forEach(p => mostrarProducto(p));
    } catch(error) {
       console.error(error);
    }
}

Petición POST

Normalmente, cuando hacemos una petición POST, enviamos el contenido de un formulario al servidor. En este caso tenemos 2 opciones para enviar el formulario, que dependerán de lo que acepte el servidor al que vamos a enviar la información. La primera sería con la codificación (cabecera Content-Type) 'multipart/form-data' (por defecto).

<form id="formProducto">
    <p><input type="text" name="nombre" id="name" placeholder="Nombre" required></p>
    <p><input type="text" name="descripcion" id="description" placeholder="Descripcion" required></p>
    <p>Foto: <input type="file" name="foto" id="foto" required></p>
    <p><img id="imgPreview" src=""></p> <!-- Para previsualizar la imagen seleccionada -->
    <button type="submit">Añadir</button>
</form>

Si el servidor requiere que le enviemos los datos en formato JSON, debemos además serializar los posibles archivos como imágenes en base64 para enviarlos junto al resto de los datos. También debemos indicar en la cabecera Content-Type el valor 'application/json', así como serializar los datos a enviar con el método JSON.stringify.

<form id="formProducto">
    <p><input type="text" name="nombre" id="name" placeholder="Nombre" required></p>
    <p><input type="text" name="descripcion" id="description" placeholder="Descripcion" required></p>
    <p>Foto: <input type="file" name="foto" id="foto" required></p>
    <p><img id="imgPreview" src=""></p> <!-- Para previsualizar la imagen seleccionada -->
    <button type="submit">Añadir</button>
</form>

Petición PUT

El mecanismo para realizar una petición PUT (o PATCH) es el mismo que para una petición POST. Muchas veces debemos especificar en la url el identificador del recurso que vamos a actualizar en el servidor. Al igual que ocurre en una petición POST, el resto de datos irán en el cuerpo de la petición en formato 'multipart/form-data' (por defecto), o en formato 'application/json'.

// ...
const resp = await fetch(`${SERVER}/products/${id}`, {
    method: 'PUT',
    body: JSON.stringify(producto),
    headers: {
        'Content-Type': 'application/json'
    }
});
// Resto del programa

Petición DELETE

Es el tipo de petición más simple ya que no contiene datos en la petición (todo va en la url) y generalmente el servidor no devuelve tampoco ningún dato. Simplemente analizando el código de respuesta sabremos si se ha borrado correctamente o no.

async function borraProducto(id, prodHTML) {
    try {
        const resp = await fetch(`${SERVER}/products/${id}`, {
            method: 'DELETE'
        });
        if (!resp.ok) throw new Error(resp.statusText);
        prodHTML.remove(); // Eliminamos el producto del DOM si todo ha ido bien
    } catch (error) {
        console.error("Error borrando producto: " + error);
    }
}

Organizar código AJAX en clases

Con el objetivo de reducir al máximo el código necesario para realizar peticiones con fetch y centralizar la gestión de dichas peticiones (control de respuestas del servidor con error, etc.) podemos crear clases que nos ayuden a tener un código más limpio.

Por ejemplo, basándonos en como funcionan ciertas librerías o frameworks como Angular, podríamos tener una clase llamada Http con los métodos necesarios para hacer peticiones GET, POST, PUT y DELETE de manera más directa.

export class Http {
  async ajax(method, url, body = null) {
    const json = body && ! (body instanceof FormData);
    const headers = body && json ? { "Content-Type": "application/json" } : {};
    const resp = await fetch(url, { method, headers, body : (json ? JSON.stringify(body) : body) });

    if (!resp.ok) throw new Error(resp.statusText);

    if (resp.status != 204) {
      return resp.json();
    } else {
      return null; // 204 implica una respuesta sin datos
    }
  }

  get(url) {
    return this.ajax("GET", url);
  }

  post(url, body) {
    return this.ajax("POST", url, body);
  }

  put(url, body) {
    return this.ajax("PUT", url,  body);
  }

  delete(url) {
    return this.ajax("DELETE", url);
  }
}

Además, por cada recurso que gestionemos en nuestra aplicación, podríamos crear una clase que maneje las peticiones relacionadas con dicho recurso, con un método por cada tipo de operación que realicemos. Al ser clases auxiliares que van en módulos diferentes, no se aconseja que realicen operaciones con el DOM (únicamente con los datos). De esta manera, dejamos la gestión del DOM al módulo principal (el que se referencia desde el HTML).

Para ceñirnos a una filosofía de código, vamos a hacer clases parecidas a las que en el framework de Angular se denominan servicios. La clase que gestiona los productos, por ejemplo, la llamaremos ProductoService. El trabajo de esta clase es gestionar el cómo se realizan las peticiones y los datos que recibimos del backend que estemos utilizando.

import { Http } from './http.class.js';
import { SERVER } from './constants.js';

export class ProductoService {
  #http;
  constructor() {
    this.#http = new Http();
  }

  async getProductos() {
    const resp = await this.#http.get(`${SERVER}/productos`);
    return resp.productos;
  }

  async add(producto) {
    const resp = await this.#http.post(`${SERVER}/productos`, producto);
    return resp.producto;
  }

  async update(producto) {
    const resp = await this.#http.put(`${SERVER}/productos/${producto.id}`, product);
    return resp.producto;
  }

  delete(id) {
    return this.#http.delete(`${SERVER}/productos/${id}`);
  }
}

De esta manera, en el módulo principal, se simplifica bastante el código para gestionar las peticiones. Aquí podemos ver un ejemplo de obtención de datos de productos o insertar un nuevo producto.

import { ProductoService } from './producto-service.class.js';

let productoService = new ProductoService();

async function getProductos() { // Obtener productos y añadirlos al DOM
    const productos = await productoService.getProductos();
    productos.forEach(p =>  mostrarProducto(p));
}

async function addProducto() { // Añadir un producto al servidor e insertarlo en el DOM
    let producto = {
        nombre: document.getElementById("nombre").value,
        descripcion: document.getElementById("descripcion").value,
        foto: imagePreview.src
    };

    const p = productoService.add(producto);
    mostrarProducto(p);
}

// Resto del código

<< Promesas NPM >>