Chapitre 9.5 — Consommer l’API depuis Next.js
Partie 9 : Web Services — Chapitre 5/5 Public : dev Next.js/React — votre terrain. On branche Moodle sur une app Next.js 15 (App Router), proprement et en sécurité. Version de référence : Moodle 5.2 + Next.js 15 (App Router, Server Components, route handlers, server actions).
⏱️ TL;DR
- Le token vit côté serveur uniquement : variable d’env sans
NEXT_PUBLIC_, lue dans des route handlers / server actions / Server Components. Jamais dans un composant client.- On écrit un client
callMoodle()qui :POSTen form-encoded sur/webservice/rest/server.php, ajoutewstoken/wsfunction/moodlewsrestformat=json, parse le JSON, et détecte les erreurs Moodle (HTTP 200 +exceptiondans le corps) pour jeter une vraie erreur.- Aplatir les paramètres imbriqués en notation crochets (
enrolments[0][userid]) — un helperflatten()le fait.- Typage : on déclare des types TS pour les retours (le contrat de
execute_returns, ch. 9.4) — Moodle ne fournit pas de types générés.- Pas de CORS : n’appelez pas Moodle depuis le navigateur (token exposé + CORS). Tout passe par votre backend Next.js qui relaie.
- Patterns : Server Component pour lire (avec
revalidate), server action / route handler pour écrire, gestion deswarnings(échecs partiels, ch. 9.3).
🎯 Objectifs
- Stocker et utiliser le token sans jamais l’exposer au client.
- Écrire un client typé robuste (form-encode, parse, erreurs Moodle).
- Gérer les paramètres imbriqués et le typage des réponses.
- Structurer lecture/écriture avec Server Components et server actions.
- Éviter les pièges (CORS, HTTP 200 trompeur,
warnings).
1. Où vit le token : la règle non négociable
Un token = une identité + ses permissions (ch. 9.2). Il ne doit jamais atteindre le navigateur. En Next.js :
- Variable d’environnement serveur :
MOODLE_WS_TOKEN(etMOODLE_URL), sans le préfixeNEXT_PUBLIC_(qui l’inlinerait dans le bundle client, ch. 9.2). - Utilisé uniquement dans : Server Components, route handlers (
app/api/*/route.ts), server actions ('use server'). Ces contextes s’exécutent côté serveur. - Jamais dans un composant
'use client', ni passé en prop à un composant client, ni dans unfetchdéclenché par le navigateur vers Moodle.
.env.local (jamais commité)
────────────────────────────
MOODLE_URL=https://moodle.example.com
MOODLE_WS_TOKEN=a1b2c3d4e5f6... # SANS NEXT_PUBLIC_ : reste serveur⚠️ Piège CORS/exposition : n’appelez pas Moodle directement depuis le navigateur. Cela (1) exposerait le token et (2) buterait sur CORS (Moodle n’est pas configuré pour vos origines). Le bon modèle : le navigateur appelle votre backend Next.js, qui relaie vers Moodle avec le token serveur. Votre Next.js est le proxy de confiance.
2. Le client callMoodle()
Le cœur : un helper serveur qui encapsule form-encode, parse, et détection d’erreur Moodle.
// lib/moodle.ts — SERVEUR UNIQUEMENT (importé par des Server Components / route handlers / actions)
import 'server-only'; // garde-fou : erreur de build si importé côté client
const MOODLE_URL = process.env.MOODLE_URL!;
const TOKEN = process.env.MOODLE_WS_TOKEN!;
/** Erreur typée représentant une exception Moodle (renvoyée en HTTP 200). */
export class MoodleError extends Error {
constructor(
public errorcode: string,
message: string,
public debuginfo?: string,
) {
super(message);
this.name = 'MoodleError';
}
}
/** Aplati un objet en notation crochets Moodle : {a:{b:1}} → {'a[b]':1}, tableaux → a[0]. */
function flatten(obj: Record<string, unknown>, prefix = ''): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
const name = prefix ? `${prefix}[${key}]` : key;
if (value === null || value === undefined) {
continue;
}
if (Array.isArray(value)) {
value.forEach((v, i) => {
if (typeof v === 'object' && v !== null) {
Object.assign(out, flatten(v as Record<string, unknown>, `${name}[${i}]`));
} else {
out[`${name}[${i}]`] = String(v);
}
});
} else if (typeof value === 'object') {
Object.assign(out, flatten(value as Record<string, unknown>, name));
} else if (typeof value === 'boolean') {
out[name] = value ? '1' : '0';
} else {
out[name] = String(value);
}
}
return out;
}
/**
* Appelle une external function Moodle en REST.
*
* @param wsfunction nom de la fonction (ex. 'core_enrol_get_users_courses')
* @param args paramètres (objets/tableaux imbriqués acceptés)
* @param init options fetch (ex. { next: { revalidate: 60 } })
*/
export async function callMoodle<T>(
wsfunction: string,
args: Record<string, unknown> = {},
init?: RequestInit & { next?: { revalidate?: number } },
): Promise<T> {
const body = new URLSearchParams({
wstoken: TOKEN,
wsfunction,
moodlewsrestformat: 'json',
...flatten(args),
});
const res = await fetch(`${MOODLE_URL}/webservice/rest/server.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
...init,
});
// Moodle renvoie du JSON ; le status HTTP peut être 200 même en erreur.
const data = await res.json();
// Détection de l'exception Moodle (le piège du 200, ch. 9.1).
if (data && typeof data === 'object' && 'exception' in data) {
throw new MoodleError(data.errorcode, data.message, data.debuginfo);
}
return data as T;
}Ce que ce client résout une fois pour toutes :
- Form-encode avec les trois paramètres obligatoires.
- Aplatissement des paramètres imbriqués (
flatten) — vous écrivez des objets JS naturels, pas desenrolments[0][userid]à la main. - Booléens →
'1'/'0'(Moodle attend des entiers). - Détection d’erreur Moodle : présence de
exception→throw MoodleError. Vous codez ensuite avectry/catchcomme une API normale. server-only: empêche l’import accidentel côté client (le token ne peut pas fuir).
⚠️ Piège du 200 (rappel) : sans la détection
'exception' in data, un token invalide ou une capability manquante passerait pour un succès (HTTP 200), et vous liriezundefinedplus loin sans comprendre. La détection dans le client est la protection centrale.
3. Typer les réponses
Moodle ne génère pas de types TS. On les déclare d’après le schéma execute_returns (ch. 9.4) / la doc API (ch. 9.3) :
// lib/moodle-types.ts
export interface Task {
id: number;
name: string;
completed: boolean;
}
export interface Course {
id: number;
shortname: string;
fullname: string;
progress?: number | null;
}
export interface Warning {
item: string;
itemid: number;
warningcode: string;
message: string;
}Puis les appels sont typés :
import { callMoodle } from '@/lib/moodle';
import type { Task, Course } from '@/lib/moodle-types';
const tasks = await callMoodle<Task[]>('local_todolist_get_tasks');
const courses = await callMoodle<Course[]>('core_enrol_get_users_courses', { userid: 123 });💡 Pour un dev React : c’est le même contrat qu’une API REST non typée que vous « habillez » de types TS à la main (ou avec Zod pour valider à l’exécution). Vous pouvez aller plus loin : valider les réponses avec Zod dans
callMoodlepour attraper une dérive de schéma après un upgrade Moodle. Le schémaexecute_returns(ch. 9.4) est votre source de vérité pour écrire ces types.
4. Lire : Server Component
Lecture directe dans un Server Component (pas de round-trip client → serveur → Moodle inutile) :
// app/courses/page.tsx — Server Component (par défaut dans l'App Router)
import { callMoodle } from '@/lib/moodle';
import type { Course } from '@/lib/moodle-types';
export default async function CoursesPage() {
// Le token est utilisé côté serveur ; revalidation ISR toutes les 60 s.
const courses = await callMoodle<Course[]>(
'core_enrol_get_users_courses',
{ userid: 123 },
{ next: { revalidate: 60 } },
);
return (
<ul>
{courses.map((c) => (
<li key={c.id}>
{c.fullname} {c.progress != null && `— ${Math.round(c.progress)}%`}
</li>
))}
</ul>
);
}Le { next: { revalidate: 60 } } s’appuie sur le cache de fetch de Next.js : la réponse Moodle est mise en cache 60 s (moins d’appels, plus rapide). Ajustez selon la fraîcheur voulue.
5. Écrire : server action
Pour une écriture (ajouter une tâche, inscrire un utilisateur), une server action exécutée côté serveur, appelée depuis un composant client via <form> :
// app/todo/actions.ts
'use server';
import { callMoodle, MoodleError } from '@/lib/moodle';
import type { Task } from '@/lib/moodle-types';
import { revalidatePath } from 'next/cache';
export async function addTask(formData: FormData): Promise<{ ok: boolean; error?: string }> {
const name = String(formData.get('name') ?? '').trim();
if (!name) {
return { ok: false, error: 'Nom requis' };
}
try {
await callMoodle<Task>('local_todolist_add_task', { name });
revalidatePath('/todo'); // rafraîchit la liste (invalide le cache)
return { ok: true };
} catch (e) {
if (e instanceof MoodleError) {
return { ok: false, error: e.message }; // ex. maxtasksreached (ch. 6.3)
}
throw e;
}
}// app/todo/AddForm.tsx — composant client, n'a JAMAIS le token
'use client';
import { addTask } from './actions';
export function AddForm() {
return (
<form action={addTask}>
<input name="name" placeholder="Nouvelle tâche" />
<button type="submit">Ajouter</button>
</form>
);
}Le composant client ne connaît pas le token : il déclenche la server action, qui appelle Moodle côté serveur. C’est le modèle sûr (§1).
6. Gérer les échecs partiels (warnings)
Rappel du ch. 9.3 : les opérations de masse renvoient des warnings (échecs par élément) sans exception globale. Traitez-les explicitement :
interface EnrolResult { warnings?: Warning[] }
const result = await callMoodle<EnrolResult>('enrol_manual_enrol_users', {
enrolments: [
{ roleid: 5, userid: 123, courseid: 2 },
{ roleid: 5, userid: 999, courseid: 2 }, // peut échouer
],
});
if (result?.warnings?.length) {
// Logger / remonter les échecs partiels — NE PAS considérer comme succès total.
console.warn('Inscriptions partiellement échouées', result.warnings);
}Remarquez que enrolments est passé comme tableau d’objets — flatten() produit enrolments[0][userid]=123, etc. Vous écrivez du JS naturel, le client s’occupe de la notation Moodle.
⚠️ Piège :
MoodleError(exception) etwarnings(échecs partiels) sont deux mécanismes distincts. Untry/catchattrape les exceptions ; il faut en plus inspecterwarningssur les fonctions qui en renvoient. Gérer l’un sans l’autre laisse passer des erreurs.
7. Récapitulatif : l’architecture d’intégration
Les principes de l’intégration Moodle ↔ Next.js :
- Token serveur uniquement (
server-only, pas deNEXT_PUBLIC_). - Un client
callMoodlequi centralise form-encode, aplatissement, et détection d’erreur (le 200 trompeur). - Next.js comme proxy de confiance — le navigateur ne parle jamais à Moodle (token + CORS).
- Lecture en Server Component (avec
revalidate), écriture en server action (avecrevalidatePath). - Typage manuel des réponses (+ Zod optionnel), gestion des
warnings.
8. Synthèse de la Partie 9
Vous avez fait le tour complet des web services : l’architecture (fonctions typées, services, tokens, REST — les mêmes fonctions que l’AJAX interne), la sécurité (token = identité, compte de service à moindre privilège, restrictions), l’usage des fonctions du core, la création de vos propres fonctions (execute_parameters/execute/execute_returns + db/services.php), et leur consommation depuis Next.js en sécurité. Moodle n’est pas headless par défaut, mais son API en fait un backend intégrable à votre écosystème JavaScript — le pont que cherchait le dev Next.js.
✏️ Exercices
Exercice 1 — Token exposé.
Un dev met NEXT_PUBLIC_MOODLE_TOKEN et appelle Moodle depuis un composant 'use client'. Deux problèmes, et l’architecture correcte ?
✅ Solution
Problèmes : (1) NEXT_PUBLIC_ inline le token dans le bundle client → token public, identité compromise ; (2) l’appel navigateur → Moodle bute sur CORS (origines non autorisées) et exposerait le token en transit. Architecture correcte : token en variable serveur (MOODLE_WS_TOKEN, sans NEXT_PUBLIC_), utilisé seulement dans Server Components / server actions / route handlers via un module server-only. Le navigateur appelle votre backend Next.js (proxy de confiance), qui relaie vers Moodle. Révoquer le token exposé.
Exercice 2 — Le succès qui n’en est pas un.
Sans détection d’erreur dans callMoodle, un appel avec un token expiré renvoie 200. Que lit le code ensuite, et comment le client corrige-t-il ?
✅ Solution
Moodle renvoie HTTP 200 avec un corps {"exception":..., "errorcode":"invalidtoken", "message":...}. Sans détection, callMoodle retourne cet objet d’erreur comme s’il s’agissait de données ; le code appelant lit des champs undefined (ex. courses.map sur un non-tableau) et plante ailleurs, loin de la cause. Correctif (dans le client) : après res.json(), tester 'exception' in data et jeter une MoodleError. Le code appelant gère alors proprement avec try/catch.
Exercice 3 — Paramètres imbriqués.
Vous devez appeler enrol_manual_enrol_users pour deux inscriptions. Montrez l’objet JS passé à callMoodle et ce que flatten en fait.
✅ Solution
await callMoodle('enrol_manual_enrol_users', {
enrolments: [
{ roleid: 5, userid: 123, courseid: 2 },
{ roleid: 5, userid: 456, courseid: 2 },
],
});flatten produit : enrolments[0][roleid]=5, enrolments[0][userid]=123, enrolments[0][courseid]=2, enrolments[1][roleid]=5, enrolments[1][userid]=456, enrolments[1][courseid]=2. Vous écrivez du JS naturel ; le client génère la notation crochets attendue par Moodle (ch. 9.3), évitant les erreurs invalidparameter.
Exercice 4 — Lire vs écrire. Quel mécanisme Next.js pour (a) afficher les cours d’un utilisateur, (b) ajouter une tâche depuis un formulaire ? Justifiez le placement du token.
✅ Solution
(a) Server Component async qui appelle callMoodle('core_enrol_get_users_courses', ...) directement (avec next: { revalidate } pour cacher) — pas de round-trip client inutile. (b) Server action ('use server') appelée par <form action={...}> depuis un composant client ; elle appelle callMoodle('local_todolist_add_task', {name}) puis revalidatePath. Dans les deux cas, le token est lu côté serveur (Server Component / server action) via lib/moodle marqué server-only ; le composant client ne le voit jamais.
Exercice 5 — Deux mécanismes d’erreur.
Sur une inscription de masse, vous entourez l’appel d’un try/catch. Est-ce suffisant ? Que manque-t-il ?
✅ Solution
Non. Le try/catch attrape les exceptions (MoodleError : token invalide, capability manquante, erreur globale). Mais les échecs partiels (certains utilisateurs non inscrits) sont renvoyés dans le tableau warnings sans exception. Il faut en plus inspecter result.warnings et traiter chaque entrée comme un échec à logger/remonter. Gérer seulement les exceptions laisse passer les échecs par élément (ch. 9.3).
🧠 Quiz de révision
Q1. Où doit vivre le token dans une app Next.js et pourquoi ?
Réponse
NEXT_PUBLIC_, utilisée dans Server Components / server actions / route handlers via un module server-only. Sinon il serait inliné dans le bundle client (public) et exposé au navigateur. Le navigateur passe par votre backend Next.js (proxy), jamais directement par Moodle (token + CORS).Q2. Quelles responsabilités le client callMoodle centralise-t-il ?
Réponse
wstoken, wsfunction, moodlewsrestformat), aplatissement des paramètres imbriqués (notation crochets), conversion des booléens, et surtout détection de l’exception Moodle (HTTP 200 + exception dans le corps) qu’il transforme en throw MoodleError.Q3. Pourquoi ne pas appeler Moodle depuis le navigateur ?
Réponse
Q4. Comment structure-t-on lecture et écriture ?
Réponse
async (avec next: { revalidate } pour cacher). Écriture dans une server action (ou route handler) appelée depuis un composant client via <form action>, suivie de revalidatePath. Le token reste côté serveur dans les deux cas.Q5. Quels deux mécanismes d’erreur faut-il gérer, et comment ?
Réponse
MoodleError, via try/catch — token/capability/erreur globale) et les warnings (échecs partiels par élément, à inspecter explicitement même en cas de succès global). Gérer l’un sans l’autre laisse passer des erreurs.Fin de la Partie 9. Vous savez ouvrir Moodle vers l’extérieur : architecture des web services, sécurité des tokens, fonctions du core, création de vos propres fonctions typées, et consommation depuis Next.js en toute sécurité. Moodle est désormais un backend intégrable dans votre écosystème JavaScript.
Partie suivante : Partie 10 — LTI (Learning Tools Interoperability). L’autre grande voie d’intégration : le standard LTI 1.3 Advantage (OIDC, Deep Linking, notes AGS, rosters NRPS), Moodle comme plateforme et comme outil, et la construction d’un outil LTI complet en Next.js.