Skip to content

How-to : Validation JSON imbriquée

Référence FT : FT322 (NENE2-FT/nestedlog) — API de commande avec validation d'articles imbriqués, chemins d'erreur items.N.field, réponse unique multi-erreurs, codes d'erreur, calcul de total, 19 tests / 43 assertions PASS.

Ce guide montre comment valider des tableaux JSON imbriqués (ex: lignes de commande) et retourner des chemins d'erreur structurés qui identifient exactement quel champ imbriqué a échoué.

Schéma

sql
CREATE TABLE orders (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    customer   TEXT    NOT NULL,
    total      REAL    NOT NULL DEFAULT 0.0,
    created_at TEXT    NOT NULL
);

CREATE TABLE order_items (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    order_id   INTEGER NOT NULL REFERENCES orders(id),
    product_id INTEGER NOT NULL,
    quantity   INTEGER NOT NULL,
    unit_price REAL    NOT NULL
);

Endpoints

MéthodeCheminDescription
POST/ordersCréer une commande avec articles
GET/ordersLister les commandes
GET/orders/{id}Obtenir une commande avec ses articles

Créer une commande

php
POST /orders
{
  "customer": "Alice",
  "items": [
    {"product_id": 1, "quantity": 2, "unit_price": 9.99},
    {"product_id": 2, "quantity": 1, "unit_price": 4.50}
  ]
}
 201
{
  "id": 1,
  "customer": "Alice",
  "items": [...],
  "total": 24.48      // 2×9.99 + 1×4.50
}

Chemins d'erreur imbriqués — items.N.field

Chaque erreur d'article inclut l'index du tableau dans le chemin du champ :

php
POST /orders
{
  "customer": "Alice",
  "items": [
    {"product_id": "not-an-int", "quantity": 2, "unit_price": 9.99},
    {"product_id": 1, "quantity": 1, "unit_price": -5.0}
  ]
}
 422
{
  "errors": [
    {"field": "items.0.product_id", "message": "...", "code": "invalid-type"},
    {"field": "items.1.unit_price",  "message": "...", "code": "min-value"}
  ]
}

Toutes les erreurs en une seule réponse

Tous les échecs de validation — tant au niveau supérieur qu'imbriqués — sont collectés et retournés ensemble. Ne jamais retourner une seule erreur à la fois pour les soumissions en lot :

php
POST /orders
{
  "customer": "",      // erreur : required
  "items": [
    {"product_id": 0, "quantity": -1, "unit_price": 1.0}  // 2 erreurs
  ]
}
 422
{
  "errors": [
    {"field": "customer",          "code": "required"},
    {"field": "items.0.product_id","code": "min-value"},
    {"field": "items.0.quantity",  "code": "min-value"}
  ]
}

Règles de validation

ChampRègle
customerRequis, non-vide, max 200 caractères
itemsRequis, tableau non-vide
items[].product_idEntier, ≥ 1
items[].quantityEntier, ≥ 1
items[].unit_priceNombre (int ou float), > 0

Pattern d'implémentation

php
final class OrderValidator
{
    /** @return list<ValidationError> */
    public function validate(array $data): array
    {
        $errors = [];

        // Validation au niveau supérieur
        $customer = trim($data['customer'] ?? '');
        if ($customer === '') {
            $errors[] = new ValidationError('customer', 'required', 'required');
        } elseif (strlen($customer) > 200) {
            $errors[] = new ValidationError('customer', 'max 200 chars', 'max-length');
        }

        $items = $data['items'] ?? null;
        if (!is_array($items) || $items === []) {
            $errors[] = new ValidationError('items', 'required non-empty array', 'required');
            return $errors;  // impossible de valider les articles plus loin
        }

        // Validation d'article imbriqué avec index
        foreach ($items as $i => $item) {
            $prefix = "items.{$i}";

            $productId = $item['product_id'] ?? null;
            if (!is_int($productId) || $productId < 1) {
                $errors[] = new ValidationError("{$prefix}.product_id", 'must be int >= 1', 'min-value');
            }

            $quantity = $item['quantity'] ?? null;
            if (!is_int($quantity) || $quantity < 1) {
                $errors[] = new ValidationError("{$prefix}.quantity", 'must be int >= 1', 'min-value');
            }

            $price = $item['unit_price'] ?? null;
            if ((!is_int($price) && !is_float($price)) || $price <= 0) {
                $errors[] = new ValidationError("{$prefix}.unit_price", 'must be number > 0', 'min-value');
            }
        }

        return $errors;
    }
}

Codes d'erreur

CodeSignification
requiredChamp manquant ou vide
max-lengthDépasse la longueur maximale
min-valueEn dessous de la valeur minimale (int/float)
invalid-typeMauvais type (ex: chaîne là où un int est attendu)

À ne pas faire

Anti-patternRisque
Retourner uniquement la première erreurLe client doit soumettre, obtenir l'erreur, corriger, resoumettre N fois — UX terrible pour les formulaires en lot
Chemin d'erreur plat "product_id" pour les articles imbriquésLe client ne peut pas identifier quel article (index 0, 1, ...) a échoué
Accepter silencieusement unit_price: 0Les articles à prix zéro corrompent les totaux de commande
Valider les articles seulement après que le niveau supérieur passeRetarde le feedback ; collecter toutes les erreurs en un seul passage
Arrêter la validation à la première erreur d'articleMasque d'autres erreurs dans les articles restants

Publié sous licence MIT.