Cialtr.One

Organizzare le chiamate API in Javascript

Scritto il — 10 ago 2023
#javascript #api #axios #frontend #pattern
A very tidy bookshelf

Photo by Garmin B on Unsplash

E’ da molto che non scrivo nulla, era prevedibile, sono un cialtrone. Ho due o tre articoli marcescenti nella repository Git, incompiuti, mai pubblicati, vediamo se questo riesco a finirlo.

Dai, dai, dai!

Oggi vorrei scrivere di una cosa a me cara: come organizzare il codice in modo che non sembri una discarica, nello specifico, come organizzare il codice per le chiamate API di un progetto frontend basato genericamente su Javascript e Axios. Se usate librerie differenti per le chiamate HTTP o i metodi nativi Javascript l’approccio descritto andrà ugualmente bene, magari adattandolo un pochettino ecco.

Io sto un pò in fissa con l’organizzazione del codice, già le mie doti da sviluppatore sono esigue, se poi è tutto un gran casino chi ci capisce qualcosa? C’è bisogno di ordine!

Ho già detto che sono scarso no? Ecco, inizialmente nei progetti scrivevo cose simili:

1
2
3
4
const auth = `Bearer ${token}`;
const data = await Axios.get("http://localhost:3000/api/v1/users", auth)
    .then(res => res.data)
    .catch(err => console.error(err));

Tutte le pagine o componenti del progetto avevano la loro lista di chiamate scritte in questo modo, uno schifo immondo.

Ogni volta dovevo ripetere URL base, endpoint e stringa di autorizzazione, demenziale.

Configurazione di Axios, zero. Gestione errori, ogni volta diversa. Cambiare un endpoint era una pena.

Service Pattern, scelgo te

Con un pò di ricerca sui motori di ricerca e studiando altri piccoli progetti open-source su GitHub sono venuto a conoscenza del Service Pattern.

Utilizzando parole a caso e confuse definiamo il Service Pattern:

Il Service Pattern permette di creare tanti piccoli servizi da associare idealmente a ciascun controller nel nostro backend e scrivere al loro interno le funzioni che si occupano di fare le chiamate e recuperare i dati. Così facendo si centralizzano le chiamate in un singolo file, riducendo il costo degli interventi futuri.

Anche il client Axios viene centralizzato, la sua inizializzazione avviene in un file Javascript separato dove comodamente possiamo configurare tutto quello che ci pare e sfruttare caratteristiche avanzate di Axios come gli interceptors.

Tutto questo ben di dio viene sistemato in una cartella api del progetto.

Per utilizzarlo poi si tratta di importare il servizio nel componente e chiamare il metodo che ci serve.

Dalle parole ai fatti

La struttura di cartelle che andremo a replicare sarà la seguente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
src/
├── api/
│   ├── client/
│   │   └── index.js
│   ├── services/
│   │   ├── user-service.js
│   │   ├── product-service.js
│   │   ├── .
│   │   └── .
│   └── index.js
├── .
├── .
└── .

Creiamo il file index.js all’interno della cartella api/client.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// api/client/index.js
import axios from 'axios';

const axiosClient = axios.create({
    baseURL: 'http://localhost:3000/',
    headers: {
        Accept: "application/json",
        'Content-Type': 'application/json',
    },
    // ... opzioni ulteriori ...
});

export default axiosClient;

Il file è basilare e configura solamente l’URL base per tutte le chiamate e l’header. Le opzioni Accept e Content-type indicano rispettivamente che il client axios si aspetta di ricevere risposte JSON da parte del backend e che invierà dati in formato JSON al backend.

Se volessimo configurare altre cose elaborate e fighissime (esempio, il token di autenticazione) nel client Axios prima di utilizzarlo in giro nell’applicazione, questo è il posto perfetto, tutto bello ordinato in un singolo file e perfettamente gestibile / espandibile nel futuro. Il potere dell’essere ordinati!

Creiamo ora il file user-service.js all’interno della cartella api/services. Ci servirà per recuperare informazioni da un ipotetico UsersController nel backend dell’applicazione.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// api/services/user-service.js
import axiosClient from '../client';

const getUsers = () => axiosClient.get('api/v1/users');
const getUserById = (id) => axiosClient.get(`api/v1/users/${id}`);
const createUser = (data) => axiosClient.post('api/v1/users', data);
// ... altre chiamate API relative all'entità users ...

export default {
    getUsers,
    getUserById,
    createUser,
    // ...
};

Non rimane che esportare il servizio tramite il file index.js (detto anche barrel file) nella cartella api per consolidare i nostri servizi e renderli facilmente importabili nei componenti frontend.

1
2
3
// api/index.js
export { default as UserService } from "./services/user-services";
// ... altri export ...

Ora possiamo usare il servizio nei nostri componenti come segue:

1
2
3
4
5
6
7
// componente 1
import { UserService } from "/api";

async function fetchUsers() {
    const { data } = await UserService.getUsers();
    console.log(data);
}

Questo processo può essere ripetuto per altre entità, ed esempio possiamo creare il product-service.js per l’entità prodotti.

Ho volutamente evitato la gestione degli errori con i blocchi try/catch per non invischiarmi in ulteriori concetti da spiegare. Capitemi, già così non so quello che sto facendo e dicendo. Chiaramente ne eviterò la trattazione anche nelle seguenti parti di questo articolo.

Cosa ci piace

Quanti bei punti a favore!

Cosa non ci piace

Può essere tutto bello bellissimo? Può andare tutto liscio? No.

Utilizzando questo approccio è emersa una criticità che ha catturato la mia attenzione: se devo sistematicamente modificare la risposta dell’enpoint dovrò farlo direttamente nel componente non potendo direttamente agire nel servizio causa potenziali effetti indesiderati.

Facciamo un esempio, stupido.

In alcune sezioni dell’applicazione, una volta recuperatu tutti gli utenti dal backend, ho la necessità di ampliare la risposta con il nome completo dell’utente. In ogni componente in cui è richiesto dovrò modificare come segue:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// componente 2
import { UserService } from "/api";

async function fetchUsers() {
    const data = await UserService.getUsers()
        .then((res) => {
            return res.data.map((user) => {
                return {
                    ...user,
                    fullName: `${user.name} ${user.surname}`
                }
            })
        });
    console.log(data);
}

Ripetere questa modifica, soprattutto se è necessaria in molti punti, diventa un fastidio e rovina quanto di buono fatto fino a ora in ambito manutenzione futura.

In alternativa, potremmo inserire questa logica direttamente nel metodo getUsers() del servizio. Questo però modifica sostanzialmente l’oggetto ritornato dal metodo, che passa da una Promise a un Array di oggetti, di conseguenza vanno controllate e adattate, se possibile, tutte le sezioni di codice dove il metodo viene invocato. Una discreta rottura di palle, oltre ad andare contro ogni tipo di best practice della programmazione.

Introduciamo il Decorator Pattern

Il mondo è bello perchè ci sono tanti pattern!

Siccome ero stufo e mi sembrava ci fosse spazio di manovra per migliorare in maniera sostanziale, ho ripreso le ricerche sul web e con un pò di perseveranza e un lento incedere sono incappato nel Decorator Pattern.

Di nuovo, cerchiamo di spiegarlo con parole semplici ma confuse:

Il Decorator Pattern ci permette di estendere le funzionalità di una classe o un metodo senza alterare il comportamento della classe o metodo base. Una sorta di wrapper.

Utilizzando il Decorator Pattern possiamo, per esempio, estendere il metodo getUsers() così da manipolare la risposta a nostro piacimento senza intaccare il metodo originale.

Implementiamo, implementiamo

Andiamo con calma e facciamo un passo indietro, per implementare il Decorator Pattern la struttura delle cartelle andrà modificata come segue:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
src/
├── api/
│   ├── client/
│   │   └── index.js
│   ├── modules/
│   │   ├── user-api.js
│   │   ├── product-api.js
│   │   ├── .
│   │   └── .
│   └── index.js
├── services/
│   ├── modules/
│   │   ├── user-service.js
│   │   ├── product-service.js
│   │   ├── .
│   │   └── .
│   └── index.js
├── .
├── .
└── .

La parte api/client rimmarrà identica.

Vediamo la struttura del file user-api.js nella cartella api/modules:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// api/modules/user-api.js
import axiosClient from '../client';

const getUsers = () => axiosClient.get('api/v1/users');
const getUserById = (id) => axiosClient.get(`api/v1/users/${id}`);
const createUser = (data) => axiosClient.post('api/v1/users', data);
// ... altre chiamate API relative all'entità users ...

export default {
    getUsers,
    getUserById,
    createUser,
    // ...
};

Il contenuto è quello del precedente file user-service nella cartella api/services, un semplice rename del file.

Il file index.js ora esporterà i file dalla cartella api/modules:

1
2
3
// api/index.js
export { default as UserApi } from "./modules/user-api";
// ... altri export ...

Passiamo alla parte interessante, la cartella services, e creiamo il file user-service.js nella cartella services/modules:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// services/modules/user-service.js
import UserApi from "../api"

const getUsers = async () => {
    const { data } = await userAPI.getUsers();
    return data;
};

const getUsersWithFullName = async () => {
    const { data } = await userAPI.getUsers();
    return data.map(user => ({ ...user, fullName: `${user.firstName} ${user.lastName}` }));
};

export default {
    getUsers,
    getUsersWithFullName,
    // ... altri metodi
};

Di fatto abbiamo preso il metodo getUsers() delle nostre API e l’abbiamo abbellito con dell’ulteriore codice che ci permette di manipolare i dati che ci arrivano dal backend senza rompere nulla.

Abbiamo disaccoppiato il recupero dei dati dal backend dall’esposizione dei dati stessi all’interno del nostro frontend.

Come fatto in precedenza, è il momento del file index.js.

1
2
3
// services/index.js
export { default as UserService } from "./modules/user-service";
// ... altri export ...

Passiamo ai componenti frontend:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// componente 1
import { UserService } from "/api";

async function fetchUsers() {
    const { data } = await UserService.getUsers();
    console.log(data);
}

// componente 2
import { UserService } from "/api";

async function fetchUsers() {
    const { data } = await UserService.getUsersWithFullName();
    console.log(data);
}

Ma quanto è bello e ordinato? La bellezza.

Cosa ci piace

La separazione dei ruoli è uno dei principi che compongono l’acronimo SOLID, nello specifico la S (Single Responsibility Principle). Una buona pratica della programmazione. Non siete contenti?

Cosa non ci piace

Trovo questo secondo approccio piacevole da utilizzare e facilmente scalabile ad applicazioni anche relativamente complesse.

Una pecca però che, a mio avviso, non posso non menzionare è il quantitativo di codice, c’è tanto codice da scrivere. Questo può impattare in due modi:

Conclusioni

Sfruttando concetti di Javascript (ES modules per import/export) e design pattern reperiti qua e la, l’organizzazione del mio codice è notevolmente migliorata negli anni.

La struttura che organizza sia le chiamate API che i servizi per esporre i dati ricevuti dalle chiamate in moduli, permette di scrivere un codice più snello, di più facile manutenzione, che si presta maggiormente a essere esteso in futuro senza crisi isteriche e, ultimo ma non ultimo, che non ti genera quella sensazione di profonda nausa quando lo devi debuggare dopo sei mesi.

Non è tutto oro quel che luccica, seguire questa via porta con se la necessità di scrivere più codice che, soprattutto nelle fasi iniziali, può sembrare inutile ma che sulla lunga distanza porta, da mio punto di vista, benefici che sovrastano di gran lunga il fastidio iniziale, soprattutto se le dimensioni dell’applicazione crescono.

Sta a noi decidere se, per il nostro progettino scalcagnato, vale la pena seguire questa rotta o andare più allo sbaraglio.

Fine.