Chapitre 7.2 — Requêter le LRS
⏱️ TL;DR — Lire dans un LRS, c’est un
GET statementsavec des filtres en paramètres d’URL :agent,verb,activity,registration,since,until,limit,ascending. La réponse est un objet avec un tableaustatementset, s’il reste des résultats, une propriétémore: une URL relative à suivre pour la page suivante — c’est la pagination. Deux subtilités qui piègent : l’agentse passe en JSON encodé dans l’URL (pas juste un e-mail), et les statements annulés (voided) n’apparaissent plus dans les requêtes normales — on les annule en émettant un statement avec le verbevoided. Ce chapitre montre comment récupérer, par exemple, « toutes les complétions d’un apprenant » proprement.
🎯 Objectifs
- Construire un
GET statementsfiltré et lire l’objet de résultat. - Encoder correctement un
agentet une IRI deverb/activitydans l’URL. - Paginer en suivant
morejusqu’à épuisement. - Comprendre le mécanisme d’annulation (voiding) et son effet sur les requêtes.
- Écrire la requête « toutes les complétions de tel apprenant » de bout en bout.
Un GET filtré
Sans filtre, GET statements renvoie tout (paginé) — rarement ce qu’on veut. On affine avec des paramètres de requête. Les principaux :
| Filtre | Type | Sélectionne… |
|---|---|---|
agent | Agent en JSON, URL-encodé | les statements où cet agent est l’acteur (ou l’objet) |
verb | IRI | les statements portant ce verbe |
activity | IRI | les statements dont l’objet est cette activité |
registration | UUID | les statements de cette inscription (une tentative de cours) |
since | horodatage ISO 8601 | les statements stockés après cette date (exclus) |
until | horodatage ISO 8601 | les statements stockés jusqu’à cette date (inclus) |
limit | entier | nombre max de statements par page (0 = défaut du LRS) |
ascending | booléen | ordre chronologique croissant si true (défaut : décroissant) |
Exemple : les statements de complétion d’une apprenante, du plus ancien au plus récent.
GET https://lrs.formacampus.fr/xapi/statements?verb=http%3A%2F%2Fadlnet.gov%2Fexpapi%2Fverbs%2Fcompleted&agent=%7B%22mbox%22%3A%22mailto%3Aawa.diop%40ecole.fr%22%7D&ascending=true&limit=50 HTTP/1.1
Host: lrs.formacampus.fr
Authorization: Basic Zm9ybWFjYW1wdXM6czNjcjN0
X-Experience-API-Version: 1.0.3Deux points d’attention dans cette URL :
- Le
verbest une IRI complète, URL-encodée :http://adlnet.gov/expapi/verbs/completeddevienthttp%3A%2F%2F.... - L’
agentn’est pas un simple e-mail : c’est un objet Agent en JSON, lui aussi URL-encodé. Décodé, le paramètre ci-dessus vaut :
{ "mbox": "mailto:awa.diop@ecole.fr" }⚠️ Piège — Passer
agent=awa.diop@ecole.fr. Le filtreagentattend un objet Agent JSON ({"mbox":"mailto:..."}ou{"account":{...}}), pas une chaîne. Et l’identifiant doit correspondre exactement à celui utilisé à l’écriture : si tu as tracé avec unaccount(homePage+name), filtrer parmboxne renverra rien. Trace et interroge avec le même type d’identifiant.
En JavaScript
URLSearchParams gère l’encodage pour toi — y compris le JSON de l’agent, qu’il faut JSON.stringify d’abord.
async function findStatements(filters) {
const qs = new URLSearchParams();
if (filters.verb) qs.set("verb", filters.verb);
if (filters.activity) qs.set("activity", filters.activity);
if (filters.registration) qs.set("registration", filters.registration);
if (filters.since) qs.set("since", filters.since);
if (filters.until) qs.set("until", filters.until);
if (filters.limit) qs.set("limit", String(filters.limit));
if (filters.ascending) qs.set("ascending", "true");
if (filters.agent) qs.set("agent", JSON.stringify(filters.agent)); // objet -> JSON
const res = await fetch(`${LRS}/statements?${qs.toString()}`, {
headers: {
"Authorization": AUTH,
"X-Experience-API-Version": "1.0.3",
},
});
if (!res.ok) throw new Error(`LRS ${res.status}`);
return res.json(); // { statements: [...], more: "..." }
}La réponse : statements + more
Un GET statements filtré ne renvoie pas un tableau brut, mais un objet StatementResult :
{
"statements": [
{
"id": "fd41c918-b88b-4b20-a0a5-a4c32391aaa0",
"actor": { "objectType": "Agent", "mbox": "mailto:awa.diop@ecole.fr" },
"verb": { "id": "http://adlnet.gov/expapi/verbs/completed" },
"object": { "id": "https://formacampus.fr/xapi/activities/module-securite-numerique" },
"result": { "completion": true, "score": { "scaled": 0.85 } },
"timestamp": "2026-03-14T09:32:11Z",
"stored": "2026-03-14T09:32:12Z"
}
],
"more": "/xapi/statements/more/5f2a9c8e10bd"
}statements: la page de résultats (au pluslimitéléments).more: présent s’il reste des pages. C’est une URL relative (à préfixer par l’hôte du LRS) qui, appelée telle quelle, renvoie la page suivante. Quandmoreest absent ou vide (""), tu es au bout.
💡 Réflexe — Ne fabrique jamais l’URL de page suivante toi-même (avec un
offset, par exemple). xAPI ne marche pas comme ça : tu suis l’URL opaque demore, point. Le LRS y encode ce dont il a besoin (curseur, tri, filtres figés). Reconstruire à la main, c’est se tromper de résultats.
Paginer jusqu’au bout
async function findAllStatements(filters) {
const first = await findStatements(filters);
let all = first.statements;
let more = first.more;
while (more) {
const res = await fetch(`https://lrs.formacampus.fr${more}`, {
headers: {
"Authorization": AUTH,
"X-Experience-API-Version": "1.0.3",
},
});
const page = await res.json();
all = all.concat(page.statements);
more = page.more; // vide ou absent => on s'arrête
}
return all;
}⚠️ Piège — Croire que la première réponse contient tout. Si tu oublies de suivre
more, tu ne vois que la première page (souvent quelques dizaines de statements) et tu conclus à tort « il n’y a que ça ». Sur un LRS de production, la pagination est la norme, pas l’exception.
Récupérer un statement précis
Pour un seul statement dont tu connais l’id, pas besoin de filtres : statementId renvoie le statement lui-même (pas un StatementResult).
GET https://lrs.formacampus.fr/xapi/statements?statementId=fd41c918-b88b-4b20-a0a5-a4c32391aaa0 HTTP/1.1
Host: lrs.formacampus.fr
Authorization: Basic Zm9ybWFjYW1wdXM6czNjcjN0
X-Experience-API-Version: 1.0.3Annuler un statement : le voiding
On l’a vu au chapitre 7.1 : un statement est immuable. On ne le supprime pas, on ne le modifie pas. Mais on peut le déclarer erroné : c’est l’annulation (voiding).
Le mécanisme : émettre un nouveau statement dont
- le verbe est
http://adlnet.gov/expapi/verbs/voided(le verbe réservé d’ADL pour ça), - l’objet est une référence au statement à annuler, de type
StatementRef.
{
"actor": { "objectType": "Agent", "name": "Service pédagogique", "mbox": "mailto:admin@formacampus.fr" },
"verb": {
"id": "http://adlnet.gov/expapi/verbs/voided",
"display": { "en-US": "voided", "fr-FR": "a annulé" }
},
"object": {
"objectType": "StatementRef",
"id": "fd41c918-b88b-4b20-a0a5-a4c32391aaa0"
}
}Effet côté requêtes :
- Le statement annulé disparaît des résultats d’un
GET statementsnormal. Il n’est pas effacé (l’historique est préservé) mais il est masqué des lectures courantes. - Le statement d’annulation, lui, reste dans le magasin (c’est un statement comme un autre).
- Pour relire un statement annulé, on utilise le paramètre dédié
voidedStatementId(au lieu destatementId).
⚠️ Piège — Vouloir « supprimer » un statement faux via une requête
DELETE. La ressourcestatementsn’a pas deDELETE: on n’efface pas un fait d’apprentissage. On l’annule (voided+StatementRef), puis on émet le bon. C’est la traçabilité par conception : on voit qu’il y a eu correction, pas juste le résultat final.
💡 Réflexe — Le voiding annule le statement, pas ses effets métier que tu aurais calculés ailleurs (une note recopiée dans un carnet, un badge attribué). Si tu dérives des décisions des statements, prévois de recalculer après une annulation. Le LRS gère l’immuabilité ; la cohérence en aval, c’est toi.
Le cas type : « toutes les complétions d’un apprenant »
Mets bout à bout : on veut, pour Awa, tous ses statements de complétion, du plus ancien au plus récent, en gérant la pagination.
const completions = await findAllStatements({
agent: { mbox: "mailto:awa.diop@ecole.fr" },
verb: "http://adlnet.gov/expapi/verbs/completed",
ascending: true,
limit: 100,
});
// completions = tableau de statements "completed" d'Awa, toutes pages fusionnées.
const modulesTermines = completions.map((s) => s.object.id);Trois décisions y sont visibles : filtrer par agent + verbe (pas juste l’un), demander l’ordre croissant pour une timeline lisible, et suivre more pour ne rien rater. C’est le patron d’une bonne partie des tableaux de bord.
🧭 Sur FormaCampus — Le tableau de bord formateur de FormaCampus liste, par apprenant, les modules terminés et le score. Sous le capot : un
GET statementsfiltré paragent(l’apprenant) etverb=completed, paginé viamore, côté serveur (le secret LRS ne quitte pas le back). Pour la vue « une session de formation précise », on ajoute le filtreregistration— l’UUID de la tentative — pour ne récupérer que les statements de cette session, sans mélanger les tentatives passées.
🔌 Côté intégration — Quand un LRS tiers alimente ton tableau de bord, deux questions décident de tout : quel type d’identifiant d’acteur est utilisé (tu dois filtrer avec le même :
mbox,account…) et quels verbes/activityIdsont réellement émis. Un LRS où chaque source invente ses verbes est presque ininterrogeable — d’où l’importance d’un profil partagé (chapitre 7.5).
📚 La spec — Les filtres de
GET statements, le format StatementResult, la pagination parmore(URL opaque à suivre), le mécanisme de voiding (verbehttp://adlnet.gov/expapi/verbs/voided+ objetStatementRef) et le paramètrevoidedStatementIdsont définis dans la spec xAPI d’ADL (adlnet.gov).
✏️ Exercices
Exercice 1 — Débuguer un filtre agent. Un dev cherche les statements d’un apprenant et n’obtient jamais rien, alors qu’il sait que l’apprenant a de l’activité. Sa requête (décodée) :
GET statements?agent="mailto:leo@ecole.fr"&verb=http://adlnet.gov/expapi/verbs/completedQu’est-ce qui cloche (deux problèmes possibles) ?
✅ Solution
Problème 1 — le format de l’agent. agent attend un objet Agent JSON, pas une chaîne. Il faut passer {"mbox":"mailto:leo@ecole.fr"} (URL-encodé), pas "mailto:leo@ecole.fr".
Problème 2 — l’identifiant doit correspondre. Si l’activité de Léo a été tracée avec un account ({"account":{"homePage":"https://formacampus.fr","name":"leo-123"}}) et non un mbox, alors filtrer par mbox renvoie zéro résultat même bien formaté. On interroge avec le même type d’identifiant que celui utilisé à l’écriture. (Sans oublier d’encoder les IRI et le JSON dans l’URL.)
Exercice 2 — Pagination. Cette fonction ne renvoie que les 50 premiers statements alors qu’il y en a des centaines. Corrige-la.
async function getAll(filters) {
const res = await fetch(`${LRS}/statements?limit=50`, { headers: H });
const data = await res.json();
return data.statements;
}✅ Solution
Elle ignore more. Il faut boucler tant que la réponse contient un more non vide, en appelant l’URL de more (relative, à préfixer par l’hôte du LRS) et en concaténant les pages :
async function getAll(filters) {
let url = `${LRS}/statements?limit=50`;
let out = [];
while (url) {
const data = await (await fetch(url, { headers: H })).json();
out = out.concat(data.statements);
url = data.more ? `https://lrs.formacampus.fr${data.more}` : null;
}
return out;
}On suit l’URL de more sans jamais la reconstruire à la main.
Exercice 3 — Corriger sans effacer. Un statement « a réussi » a été émis pour le mauvais apprenant. On veut « le supprimer ». Décris la marche à suivre conforme.
✅ Solution
On n’efface pas (pas de DELETE sur statements). On annule : émettre un statement avec le verbe http://adlnet.gov/expapi/verbs/voided et un objet StatementRef pointant l’id du statement fautif. Il disparaît alors des requêtes normales (relisible via voidedStatementId). Puis, si nécessaire, on émet un nouveau statement correct (bon apprenant, nouvel id). L’historique montre la correction plutôt que de la cacher.
🧠 Quiz de révision
1. Que contient la réponse d’un GET statements filtré ?
GET statements filtré ?Un objet StatementResult : un tableau statements (au plus limit éléments) et, s’il reste des résultats, une propriété more — une URL relative à suivre pour la page suivante. Pas un tableau brut.
2. Comment passe-t-on le filtre agent dans l’URL ?
agent dans l’URL ?Comme un objet Agent JSON ({"mbox":"mailto:..."} ou {"account":{...}}), URL-encodé — pas comme une simple chaîne e-mail. Et l’identifiant doit être du même type que celui utilisé à l’écriture, sinon zéro résultat.
3. Comment récupère-t-on la page suivante de résultats ?
En suivant l’URL fournie dans more (relative, à préfixer par l’hôte du LRS), telle quelle, jusqu’à ce que more soit absent ou vide. On ne reconstruit jamais cette URL soi-même (pas d’offset maison).
4. Comment « corrige-t-on » un statement erroné ?
On l’annule (voiding) : un nouveau statement avec le verbe http://adlnet.gov/expapi/verbs/voided et un objet StatementRef visant l’id fautif. Il est alors masqué des requêtes normales (relisible via voidedStatementId), puis on émet le bon statement. Aucune suppression : l’historique est préservé.
5. À quoi sert le filtre registration ?
registration ?À ne récupérer que les statements d’une tentative de cours précise (identifiée par un UUID). Indispensable pour isoler une session d’apprentissage d’un apprenant sans mélanger ses tentatives antérieures — par exemple pour un tableau de bord « cette formation-ci ».
Chapitre suivant : 7.3 — State & profils — au-delà du journal d’événements, comment le LRS mémorise l’état courant d’un apprenant pour lui permettre de reprendre.