Skip to Content

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 : POST en form-encoded sur /webservice/rest/server.php, ajoute wstoken/wsfunction/moodlewsrestformat=json, parse le JSON, et détecte les erreurs Moodle (HTTP 200 + exception dans le corps) pour jeter une vraie erreur.
  • Aplatir les paramètres imbriqués en notation crochets (enrolments[0][userid]) — un helper flatten() 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 des warnings (é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 (et MOODLE_URL), sans le préfixe NEXT_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 un fetch dé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 des enrolments[0][userid] à la main.
  • Booléens'1'/'0' (Moodle attend des entiers).
  • Détection d’erreur Moodle : présence de exceptionthrow MoodleError. Vous codez ensuite avec try/catch comme 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 liriez undefined plus 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 callMoodle pour attraper une dérive de schéma après un upgrade Moodle. Le schéma execute_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’objetsflatten() produit enrolments[0][userid]=123, etc. Vous écrivez du JS naturel, le client s’occupe de la notation Moodle.

⚠️ Piège : MoodleError (exception) et warnings (échecs partiels) sont deux mécanismes distincts. Un try/catch attrape les exceptions ; il faut en plus inspecter warnings sur 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 :

  1. Token serveur uniquement (server-only, pas de NEXT_PUBLIC_).
  2. Un client callMoodle qui centralise form-encode, aplatissement, et détection d’erreur (le 200 trompeur).
  3. Next.js comme proxy de confiance — le navigateur ne parle jamais à Moodle (token + CORS).
  4. Lecture en Server Component (avec revalidate), écriture en server action (avec revalidatePath).
  5. 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

Côté serveur uniquement : variable d’env sans 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

Form-encode + les trois paramètres obligatoires (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

Parce que cela exposerait le token (identité + permissions) et buterait sur CORS (Moodle n’autorise pas vos origines). Le bon modèle : le navigateur appelle votre backend Next.js, qui relaie vers Moodle avec le token serveur.

Q4. Comment structure-t-on lecture et écriture ?

Réponse

Lecture dans un Server Component 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

Les exceptions (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.