Sauter au contenu

Cours 04 - Récupération de données externes

La fonction est fetch permet d’exécuter des requêtes http par code. Celle-ci est autant utilisable du côté serveur que du côté client malgré que certaines contraintes limites sont utilsation par le navigateur. Son appel retourne une promesse résolue par un type Response:

//Requête en GET
fetch('https://url-vers-la-source-de-donnees')
.then(resp => resp.json()) //la réponse "incomplète" à convertir (en json ici)
.then(data => console.log(data)); //les données retournées par le serveur
//Requête en POST
fetch('url', {
method: 'POST',
headers: { 'Content-Type' : 'application/json'}, //dans la plupart des cas
body: JSON.stringify({ //informations envoyées au serveur
prop1: val1,
prop2: val2
})
}).then(resp => resp.json())
.then(data => console.log(data));

Les promesses permettent donc de garder le code ordonné et lisible sans se perdre dans une série de callbacks entrecroisés.

Le support général par Nodejs et les navigateurs d’async / await rend le tout plus lisible et améliore l’expérience de développement:

const fetchData = async () => {
const resp = await fetch('https://url-vers-la-source-de-donnees');
const data = await resp.json();
console.log(data);
};

Await fait une pause (en quelque sorte) dans le code en attendant que la promesse soit résolue. Il est impossible d’utiliser await si la fonction qui englobe cette commande n’est pas marquée par un async.

Les deux méthodes then et async/await peuvent aussi être mélangées:

const fetchData = async () => {
const data = await fetch('url')
.then(resp => resp.json());
console.log(data);
};

On utilisera un fichier loading.jsx lorsque l’on veut afficher une alternative UI temporaire à la page entière pendant l’exécution asynchrone du code s’y trouvant.

export default function Loading() {
// Or a custom loading skeleton component
return <p>Chargement...</p>
}

On utilisera Suspense pour afficher une alternative aux composantes async (comme un spinner ou un skeleton) jusqu’au moment où leurs promesses seront résolues:

/app/_components/MaComposanteAsync.jsx
const MaComposanteAsync = async () => {
const resp = await fetch('https://url-vers-la-source-de-donnees');
const taches = await resp.json();
return (
<ul>
{ /* Affichage des tâches */}
</ul>
);
};
/app/taches/page.jsx
import { Suspense } from 'react';
// La page est affichée à l'utilisateur, mais un Skeleton s'affiche à la
// place de MaComposanteAsync temporairement.
// Une fois la promesse de MaComposanteAsync résolue,
// le UI de la composante sera "streamé" vers le navigateur.
return (
<main>
<header><h1>Titre</h1></header>
<Suspense fallback={<Skeleton/>}>
<MaComposanteAsync/>
</Suspense>
</main>
);

On regroupera dans un même fichier les interactions correspondantes à une même ressource/table dans la base de données. On retrouvera par exemple dans un fichier _data/tasks.js les opérations CRUD liées à la table tasks ou les tâches récupérées depuis une source externe par fetch.

Comme dans Nextjs tout le code client et serveur est rédigé en JavaScript, il sera nécessaire d’ajouter la mention import "server-only"; en haut de chacun de ces fichiers correspondant à la couche de données afin d’éviter de les importer par erreur durant le développement depuis une composante exécutée du côté client.

La centralisation du code dans un même fichier améliore l’organisation et l’entretien des interactions avec la base de données et peut permettre de remplacer facilement le contenu des fonction si la source de données venait qu’à changer sans avoir à reconsidérer la logique de l’application.

Dans une conception MVC des choses, on considérerait cette partie du code comme le modèle.

/app/_data/tasks.js
import 'server-only';
// Récupération des tâches depuis un API externe
export const getTasks = async () => {
try {
const resp = await fetch('https://jsonplaceholder.typicode.com/todos');
if (!resp.ok) {
throw new Error("Données non accessibles");
}
const tasks = await resp.json();
return tasks;
} catch(err) {
console.log('[GET TASKS ERROR]', err);
return [];
}
};

On les récupère ensuite depuis n’importe quelle composante serveur de l’application.

/app/taches/page.jsx
import {getTasks} from '@app/_data/tasks';
const PageTaches = async() => {
const taches = await getTasks();
return (
<ul>
{ /* Affichage des tâches */ }
</ul>
);
};

Afin d’exécuter des requêtes aux bases de données, l’ORM Drizzle sera utilisé. Un ORM permet de faire le pont entre le code de l’application et la base de données. On y appelle donc des méthodes plutôt que de fabriquer des chaînes de caractères qu’on enverrait à la base de données.

  • Contrairement à son plus grand compétiteur, Prisma, la syntaxe de Drizzle ressemble beaucoup aux requêtes SQL classiques. Son fonctionnement est davantage transparent;
  • Le poids de la la libtaire est très léger;
  • La rédaction de ses schémas n’introduit pas de nouveau langage de programmation;
  • Les informations retournées par les requêtes sont typées et compatibles avec Typescript;
  • Drizzle est compatibles avec plusieurs types de bases de données;
  • Drizzle
  • libsql: version modernisée de sqlite
  • dotenv: récupération des variables d’environnement
  • Drizzle-Kit: permet l’utilisation des migrations et de Drizzle-Studio:
Fenêtre de terminal
npm i drizzle-orm@rc @libsql/client dotenv
npm i -D drizzle-kit@rc

La configuration se fait en plusieurs étapes. Il est cependant essentiel de bien comprendre le rôle de chacune de ces étapes.

Ajouter une propriété "type": "module" au fichier package.json:

/package.json
{
"name": "titre-app",
"version": "0.1.0",
"private": true,
"type: "module",
}

Afin de ne pas laisser d’informations sensibles dans le code de l’application ainsi que de permettre de changer facilement certaines valeurs en fonction de l’environnement de diffusion, créer un fichier .env à la racine du projet:

.env
# chemin vers la base de données à venir
DATABASE_URL="file:./dev.db"

Afin d’éviter que la base de données soit éventuellement ajoutée à votre repositoire git, on l’ajoutera au fichier .gitignore:

.gitignore
# ajouter cette ligne à la fin du fichier
*.db

Créer des fichiers index.js et schema.js à l’endroit suivant:

  • Répertoiresrc/
    • Répertoiredb/ // nouveau dossier
      • index.js
      • schema.js
import 'dotenv/config'; // pour lire les variables d'environnement
import { drizzle } from 'drizzle-orm/libsql';
// correspond au nom de la variable dans .env
export const db = drizzle({
// logger: true,
connection: {url: process.env.DATABASE_URL},
});

Créer un fichier drizzle.config.js à la racine du projet:

drizzle.config.js
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./src/db/schema.js",
dialect: "sqlite",
dbCredentials: {
// correspond au nom de la variable dans .env
url: process.env.DATABASE_URL,
},
});

La base de données dev.db ainsi que la table posts seront créés en exécutant la commande suivante dans le terminal:

Fenêtre de terminal
npx drizzle-kit push

Afin de créer les tables dans la bd, on peut utiliser la commande:

Fenêtre de terminal
npx drizzle-kit push

Celle-ci permet de tester rapidement les nouveaux schémas en développement sans se soucier de la gestion des fichiers de migration.

Sinon, on peut également utiliser:

Fenêtre de terminal
npx drizzle-kit generate

Vérifie les différences entre les schémas actuels et les schémas de la version précédente et génère une migration. On pousse ensuite la migration avec:

Fenêtre de terminal
npx drizzle-kit migrate

Drizzle Studio permet de visualiser et modifier les données dans les tables à l’image de PhpMyAdmin. On le démarre avec la commande:

Fenêtre de terminal
npx drizzle-kit studio

Documentation

Dans le fichier /src/db/schema.js, on déclare donc un schéma pour chaque table devant être ajoutée à la base de données.

On crée un schéma avec sqliteTable en lui passant le nom réellement donné à la table. Favoriser le snake_case. Chaque schéma doit être exporté afin d’être accessible par Drizzle-Kit et le code de l’application.

import { sqliteTable } from "drizzle-orm/sqlite-core";
export const nomTable = sqliteTable("nom_table", {
//déclarer les champs ici
});

On déclare ensuite les champs dans le schéma selon la syntaxe suivante:

import { sqliteTable } from "drizzle-orm/sqlite-core";
export const nomTable = sqliteTable("nom_table", {
nomPropriete: typeDeChamp("nom_propriété"),
nomPropriete2: typeDeChamp2("nom_propriété2"),
});

L’alias (“nom_propriété”) est optionnel sur les champs, mais il permet de le renommer en snake_case (par exemple) dans la base de données.

Documentation

En SQLite, seuls les champs de type NULL, INTEGER, REAL, TEXT et BLOB sont supportés. Drizzle permet cependant d’étendre ces types en prenant en charge la conversion.

// nombre
integer()
// clé primaire avec auto-incrémentation
integer().primaryKey({ autoIncrement: true })
// booléen
integer({ mode: 'boolean' })
// timestamp
integer({ mode: 'timestamp' })
// valeur textuelle
text()
// clé primaire textuelle
text().primaryKey()
// sauvegarde de données mixtes / structure changeante
text({ mode: 'json' })

Par défaut, les champs déclarés peuvent être null, il est donc important d’utiliser l’attribut notNull():

export const nomTable = sqliteTable("nom_table", {
id: integer().primaryKey({ autoIncrement: true }),
titre: text().notNull(),
});
import { sql } from "drizzle-orm";
import { text, sqliteTable } from "drizzle-orm/sqlite-core";
export const nomTable = sqliteTable("nom_table", {
id: text().primaryKey(),
title: text().default('test'),
// valeur par défaut d'un timestamp = Maintenant
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(CURRENT_TIMESTAMP)`),
});

Documentation

Depuis une composante serveur ou préférablement depuis la couche d’accès aux données, on pourra exécuter la requête de base suivante:

import 'server-only';
import { db } from "@/db";
import { nomTable } from "@/db/schema";
import {eq} from 'drizzle-orm';
export const getAllPosts = async () => {
// posts sera un tableau contenant les éléments
const posts = await db.select().from(nomTable).limit(5);
return posts;
};
export const getAllPostsTitle = async () => {
// ne récupère que le champ title de la table et converti ensuite le tableau d'objets
// en tableau de titres
const posts = await db.select({title: nomTable.title})
.from(nomTable)
.limit(5);
return posts.map((p) => p.title);
};
export const getPostById = async (unId) => {
const post = await db.select().from(nomTable).where(eq(nomTable.id, unId));
// il est possible que post soit null si le id est introuvable
return post;
};

Documentation

Depuis les schémas suivants:

export const users = sqliteTable('users', {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(),
});
export const pets = sqliteTable('pets', {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(),
ownerId: integer('owner_id').notNull().references(() => users.id),
})

La requête:

const result = await db.select().from(users).leftJoin(pets, eq(users.id, pets.ownerId));
console.log(result[0]);
/*
{
user: {
id: 1,
name: 'Bob'
},
pets: {
id: 2,
name: 'Henri',
ownerId: 1
}
}
*/

Documentation

await db.insert(users).values({ id: 1, name: 'Andrew' });
await db.insert(users).values([{ name: 'Andrew' }, { name: 'Dan' }]);

Documentation

await db.update(users).set({ name: "Mr. Dan" }).where(eq(users.name, "Dan"));

Documentation

await db.delete(users).where(eq(users.name, 'Dan'));