Skip to Content
React Native7. Réseau & offline ⭐7.3 Le pattern Drill ⭐

Chapitre 7.3 — ⭐ Le pattern Drill : questions JSON sur GitHub

Où on en est : le pipeline fetch + cache tourne. Reste à construire ce qu’il transporte : le repo drill-questions — la banque de questions vivante, SANS serveur. Le chapitre signature du cours.

⏱️ TL;DR — Un repo GitHub public : manifest.json (la liste des thèmes) + un themes/<slug>.json par banque. Servi gratuitement par raw.githubusercontent.com, en HTTPS, avec le CDN de GitHub. Le schéma est versionné (schemaVersion) et validé par zod à l’entrée de l’app — une question malformée est rejetée SANS crasher le jeu. Mise à jour = git push (par vous… ou par une IA qui génère des banques entières).

🎯 Objectifs

  • Créer le repo drill-questions : structure, manifest, premier thème.
  • Écrire le schéma zod et le brancher dans le pipeline (parse, filtrage des invalides).
  • Versionner le schéma pour survivre aux évolutions (app ancienne ↔ banques nouvelles).
  • Comprendre les limites du pattern (et pourquoi elles n’en sont pas pour ce cas).

Le repo drill-questions

Un repo séparé de l’app (décision ADR 0001 : les données vivent hors du code) :

drill-questions/ ├── manifest.json ← la liste des thèmes (ce que l'app découvre) ├── themes/ │ ├── bootstrap.json │ ├── nextjs.json │ ├── php.json │ └── symfony.json └── README.md ← le contrat du schéma, pour les contributeurs (vous ou une IA)
// manifest.json { "schemaVersion": 1, "updatedAt": "2026-07-10", "themes": [ { "slug": "bootstrap", "title": "Bootstrap 5", "emoji": "🅱️", "file": "themes/bootstrap.json" }, { "slug": "nextjs", "title": "Next.js 15", "emoji": "▲", "file": "themes/nextjs.json" }, { "slug": "php", "title": "PHP 8.5", "emoji": "🐘", "file": "themes/php.json" }, { "slug": "symfony", "title": "Symfony 7", "emoji": "🎼", "file": "themes/symfony.json" } ] }
// themes/symfony.json (extrait) { "schemaVersion": 1, "slug": "symfony", "questions": [ { "id": "sf-0001", "prompt": "Quel attribut PHP déclare une route sur une méthode de contrôleur ?", "choices": ["#[Route]", "#[Path]", "#[Url]", "#[Endpoint]"], "correctIndex": 0, "explanation": "Depuis Symfony 6, l'attribut #[Route('/chemin', name: '…')] remplace les annotations. Il se pose sur la méthode (ou la classe pour un préfixe).", "difficulty": "facile", "tags": ["routing", "attributs"] } ] }

Le contrat de contenu (dans le README du repo, pour vos futures sessions de génération) : ids stables et préfixés (sf-0001 — l’historique SQLite s’y référera), exactement 4 choices, correctIndex 0-3, une explanation substantielle (c’est le cœur pédagogique), difficulty ∈ facile/moyen/difficile, tags libres (la révision espacée de Drill v2 s’en servira).

Pourquoi raw.githubusercontent.com est un « backend » légitime

  • HTTPS + CDN mondial (Fastly) : latence correcte partout, fiabilité GitHub.
  • Gratuit sans limite réaliste pour ce volume (des Mo de JSON, quelques hits/jour/user — les rate limits de raw ne concernent pas cet ordre de grandeur).
  • Versionné par nature : l’URL /main/ suit la branche ; un tag /v1/ fige une version ; l’historique git EST l’audit log des questions.
  • Éditable de partout : l’interface web GitHub suffit pour corriger une typo depuis le téléphone. Et une IA (moi) peut générer/mettre à jour des banques entières par PR.

Les limites assumées (et pourquoi on s’en moque ici) : ~5 min de cache CDN sur raw (un push met quelques minutes à se propager — non-problème pour des questions) ; pas de requêtes (on télécharge le fichier entier — nos banques font quelques centaines de Ko max) ; public (c’est le but).

zod : la frontière de confiance

Le JSON vient du réseau : il peut être malformé (typo dans un push manuel, schéma qui évolue). Un as Question[] optimiste = crash au milieu d’une partie. On valide à l’entrée :

pnpm add zod
// lib/schemas.ts import { z } from 'zod' export const questionSchema = z.object({ id: z.string().min(1), prompt: z.string().min(8), choices: z.tuple([z.string(), z.string(), z.string(), z.string()]), correctIndex: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]), explanation: z.string().min(8), difficulty: z.enum(['facile', 'moyen', 'difficile']), tags: z.array(z.string()).default([]), }) export const themeFileSchema = z.object({ schemaVersion: z.literal(1), slug: z.string(), questions: z.array(z.unknown()), // ← validées UNE PAR UNE ci-dessous }) export const manifestSchema = z.object({ schemaVersion: z.literal(1), updatedAt: z.string(), themes: z.array(z.object({ slug: z.string(), title: z.string(), emoji: z.string(), file: z.string(), })), }) export type Question = z.infer<typeof questionSchema> export type Manifest = z.infer<typeof manifestSchema>

Et le pipeline devient tolérant aux questions pourries :

// hooks/queries.ts — la queryFn blindée async function fetchQuestions(slug: string): Promise<Question[]> { const raw = await fetchJson<unknown>(`${BASE}/themes/${slug}.json`) const file = themeFileSchema.parse(raw) // structure du fichier : stricte const valid: Question[] = [] for (const q of file.questions) { const parsed = questionSchema.safeParse(q) // question par question : tolérant if (parsed.success) valid.push(parsed.data) else console.warn(`Question invalide ignorée dans ${slug}:`, parsed.error.issues[0]) } if (valid.length === 0) throw new Error(`Aucune question valide dans ${slug}`) return valid }

La nuance stratégique : parse strict sur l’enveloppe (un fichier cassé = erreur franche), safeParse par question (une question sur 200 mal formée = ignorée avec warning, le jeu continue). Une typo dans un push ne bloque jamais toute une banque.

Bonus majeur : z.infer — les types de l’app dérivent du schéma. Le type Question de la Partie 6 vient maintenant d’ici : une seule source de vérité, du JSON au TS.

schemaVersion : le contrat dans le temps — L’app v1 valide schemaVersion: 1. Le jour où le schéma évolue (ex. choix multiples), les nouvelles banques passent en schemaVersion: 2 : les vieilles apps rejettent proprement (message « mets à jour Drill ») au lieu de crasher sur un format inconnu, et l’app v2 accepte les deux. Un champ, une discipline — des années de tranquillité. (Le même principe pilotera les runtime versions OTA en Partie 12.)

💡 Pour un dev Next.js — Ce pattern (contenu statique versionné + CDN + validation client) est un cousin du JAMstack : « le git est le CMS ». Vous l’avez peut-être pratiqué avec du markdown + Vercel ; ici, le « site » est une app dans la poche et le déploiement… un git push. Réutilisez-le : configs distantes, changelogs in-app, catalogues — toute donnée lue-souvent-écrite-rarement.

✏️ Exercices

1. Créez réellement le repo drill-questions (public) avec le manifest et un premier thème de 10 questions sur un sujet que vous maîtrisez. Branchez BASE dessus et jouez une partie sur VOS questions.

✅ Solution

Repo → manifest.json + themes/<slug>.json conformes au schéma → remplacer <user> dans BASE → npx expo start -c (nouvelle URL) → l’écran Thèmes affiche votre thème, la partie se joue. Vérification du pattern complet : modifier une explanation sur GitHub (interface web), attendre ~2 min de CDN, pull-to-refresh → le texte a changé. Aucun rebuild. C’est le moment ADR 0001.

2. Sabotez volontairement une question du JSON (correctIndex: 7). Que se passe-t-il dans l’app, et où est la ligne de code qui vous a sauvé ?

✅ Solution

L’app charge la banque sans cette question (warning en console : « Question invalide ignorée… »), le jeu tourne. Le sauveur : le safeParse par question — l’union literal(0|1|2|3) a rejeté 7. Avec un as Question[], la question serait entrée et le jeu n’aurait JAMAIS trouvé la bonne réponse (correctIndex 7 sur 4 choix) : bug silencieux le pire genre.

3. Rédigez le README.md du repo : le contrat qu’une future session d’IA devra respecter pour générer 200 questions Symfony valides du premier coup.

✅ Solution

Sections : schéma JSON complet avec exemple ; règles des ids (sf-NNNN, jamais réutilisés — l’historique s’y réfère) ; consignes qualité (explanation = le POURQUOI en 1-3 phrases, pas de paraphrase de la réponse ; distracteurs plausibles ; difficulty calibrée : facile = définition, moyen = usage, difficile = piège/edge case) ; répartition cible par difficulté (40/40/20) ; interdiction de modifier les questions existantes autrement que pour corriger (append-only sauf fix). Ce README est littéralement le prompt de vos futures sessions de génération.

🧠 Quiz

1. Pourquoi un repo séparé de l’app pour les questions ?

Réponse

Cycles de vie différents : les questions changent souvent (push quotidien possible), l’app rarement (builds). Séparation = mise à jour des données sans toucher au code, et génération de banques (IA) sans accès au code de l’app. ADR 0001.

2. Que garantit raw.githubusercontent.com, et quelle est sa principale latence ?

Réponse

HTTPS + CDN Fastly gratuit, URL par branche/tag. Latence principale : le cache CDN (~5 min) entre un push et sa visibilité — négligeable pour des questions de quiz.

3. Pourquoi parse strict sur le fichier mais safeParse par question ?

Réponse

Fichier corrompu = tout est suspect → erreur franche. Une question invalide parmi des centaines = dégât localisé → on l’ignore avec warning et le jeu continue. Tolérance proportionnée au rayon du dégât.

4. À quoi sert schemaVersion ?

Réponse

Contrat d’évolution : une app ancienne rejette proprement (message clair) un format plus récent au lieu de crasher ; une app récente peut accepter plusieurs versions. Le champ qui achète des années de compatibilité.

5. D’où vient désormais le type Question de l’app ?

Réponse

De z.infer<typeof questionSchema> : le schéma zod est LA source de vérité — validation runtime et types compilés ne peuvent plus diverger.


👉 Chapitre suivant : 7.4 — Offline-first — le métro, le mode avion, et pourquoi Drill s’en moque.