Valider JSON Merge Patch pour iOS sur un Mac distant

Valider JSON Merge Patch pour iOS sur un Mac distant

Valider JSON Merge Patch pour iOS sur un Mac distant

Lorsqu’une équipe passe aux mises à jour partielles des paramètres utilisateur, le problème le plus courant n’est pas l’échec d’une requête, mais une requête qui réussit tout en laissant subsister d’anciens champs : le client traite null comme une valeur à ignorer, alors que le serveur y voit une instruction de suppression. Ajouter des tests de contrat Swift aux tâches de compilation sur un Mac distant permet de vérifier, au-delà de la compilation, que les deux parties interprètent les mises à jour de la même manière. Nous nous limiterons ici à la transformation des documents JSON Merge Patch, sans appeler d’API en production ni dépendre d’un simulateur.

Fixer les quatre règles du protocole de mise à jour

La RFC 7396 définit les règles de JSON Merge Patch. Un patch objet est traité champ par champ : les champs absents du patch sont conservés, ceux dont la valeur est null sont supprimés, et les autres valeurs sont appliquées récursivement. Si le patch n’est pas un objet, il remplace directement toute la valeur cible. Les tableaux ne sont donc pas fusionnés par indice.

Commencez par préciser que l’API utilise application/merge-patch+json, plutôt que de déduire le protocole de la seule méthode HTTP. Une requête JSON classique peut elle aussi utiliser PATCH tout en suivant des règles métier totalement différentes.

Dans un membre d’objet, null signifie « supprimer ». À la racine du patch, null remplace tout le document par la valeur nulle. Si le modèle métier doit conserver une valeur nulle explicite, un même null ne peut pas servir à la fois à affecter cette valeur et à supprimer le champ au même emplacement.

Il faut aussi distinguer l’objet vide du tableau vide : appliqué à un objet existant, {} en conserve le contenu, tandis que [] remplace toute la cible par un tableau vide. Si la cible n’est pas un objet, un patch constitué d’un objet vide produit un objet vide.

Écrire un moteur de fusion Swift autonome

Créez le fichier ci/merge-patch-check.swift dans le dépôt et ajoutez-y le code suivant. L’analyse JSON repose sur Foundation, avec la prise en charge des fragments activée pour pouvoir également tester des nombres, des chaînes et une valeur nulle à la racine, au lieu de n’accepter que des objets et des tableaux.

import Foundation

enum PatchError: Error {
    case depthExceeded
    case mismatch(String)
}

func parse(_ text: String) throws -> Any {
    try JSONSerialization.jsonObject(
        with: Data(text.utf8),
        options: [.fragmentsAllowed]
    )
}

func merge(
    _ target: Any,
    _ patch: Any,
    depth: Int = 0
) throws -> Any {
    guard depth <= 64 else {
        throw PatchError.depthExceeded
    }
    guard let fields = patch as? [String: Any] else {
        return patch
    }

    var result = target as? [String: Any] ?? [:]
    for (key, value) in fields {
        if value is NSNull {
            result.removeValue(forKey: key)
        } else {
            result[key] = try merge(
                result[key] ?? NSNull(),
                value,
                depth: depth + 1
            )
        }
    }
    return result
}

func canonical(_ value: Any) throws -> Data {
    try JSONSerialization.data(
        withJSONObject: value,
        options: [.sortedKeys, .fragmentsAllowed]
    )
}

func check(
    _ name: String,
    _ target: String,
    _ patch: String,
    _ expected: String
) throws {
    let actual = try merge(try parse(target), try parse(patch))
    let expectedValue = try parse(expected)
    guard try canonical(actual) == canonical(expectedValue) else {
        throw PatchError.mismatch(name)
    }
}

La limite de profondeur relève de la politique de gestion des ressources de cette implémentation, pas du protocole. L’appel initial est à la profondeur zéro ; tout appel récursif au-delà de 64 échoue. Le point d’entrée des requêtes en production doit également limiter la taille du corps en octets et la profondeur totale d’imbrication : les tableaux sont remplacés directement, si bien que ce compteur de récursion sur les objets ne les contrôle pas entièrement.

Ce moteur n’accepte que des valeurs issues de l’analyse JSON, pas des objets métier quelconques. Évitez d’insérer directement des dates ou des données binaires dans un dictionnaire en espérant que la sérialisation corrigera automatiquement les types.

Vérifier les suppressions et les remplacements avec des cas fixes

Ajoutez les vérifications suivantes à la suite du même fichier. Chaque cas porte sur une règle sémantique principale ; son nom permet de retrouver directement la clause du protocole concernée lors de la revue de code.

try check(
    "delete-and-preserve",
    #"{"theme":"dark","badge":3}"#,
    #"{"badge":null}"#,
    #"{"theme":"dark"}"#
)

try check(
    "nested-object",
    #"{"ui":{"theme":"dark","scale":2}}"#,
    #"{"ui":{"scale":3}}"#,
    #"{"ui":{"theme":"dark","scale":3}}"#
)

try check(
    "replace-array",
    #"{"roles":["reader","writer"]}"#,
    #"{"roles":["reader"]}"#,
    #"{"roles":["reader"]}"#
)

try check("root-null", #"{"enabled":true}"#, "null", "null")
try check("empty-object", "[1,2]", "{}", "{}")
try check("empty-array", #"{"items":[1]}"#, #"{"items":[]}"#, #"{"items":[]}"#)
try check("missing-delete", "{}", #"{"absent":null}"#, "{}")
try check("scalar-root", #"{"count":1}"#, "7", "7")

Avant la comparaison, seules les clés des objets sont triées ; l’ordre des éléments des tableaux reste inchangé. Cette comparaison par sérialisation convient à ces cas fixes, mais ne constitue pas une méthode générale de normalisation des nombres et ne doit pas servir à calculer des signatures communes à plusieurs langages.

Si le client représente les champs par de simples valeurs optionnelles, l’encodage risque de traduire aussi bien « supprimer le champ » que « ne pas modifier le champ » par une omission. La couche de construction des requêtes doit distinguer explicitement trois états : inchangé, suppression et affectation. Le JSON produit doit faire l’objet de vérifications distinctes ; tester uniquement le moteur de fusion ne prouve pas que les requêtes sont correctement encodées.

Ajouter des contrôles de limites et d’application répétée

Appliquer plusieurs fois le même patch doit produire le même résultat. Ce contrôle permet notamment de repérer les implémentations qui ajoutent à tort des éléments aux tableaux ou incrémentent les valeurs numériques. Il ne prouve toutefois pas que l’API entière est exempte d’effets de bord, comme l’envoi répété de notifications.

Ajoutez ensuite les vérifications suivantes pour vous assurer qu’un objet trop profond déclenche une erreur explicite au lieu d’être tronqué silencieusement :

let target = try parse(#"{"ui":{"scale":1}}"#)
let patch = try parse(#"{"ui":{"scale":2},"old":null}"#)
let once = try merge(target, patch)
let twice = try merge(once, patch)
guard try canonical(once) == canonical(twice) else {
    throw PatchError.mismatch("idempotence")
}

var deep: Any = 1
for _ in 0..<66 {
    deep = ["child": deep]
}

do {
    _ = try merge(NSNull(), deep)
    throw PatchError.mismatch("depth-limit")
} catch PatchError.depthExceeded {
}

Le JSON invalide est un autre cas souvent oublié. Un échec d’analyse doit interrompre le traitement de la requête : il ne faut pas intercepter l’erreur puis poursuivre la fusion avec un dictionnaire vide, sous peine de traiter une entrée corrompue comme une mise à jour valide. La politique d’acceptation des clés dupliquées doit elle aussi être définie séparément au point d’entrée, sans compter sur le fait que différents analyseurs conserveront par hasard la même valeur.

Intégrer ces tests aux contrôles de commit sur un Mac distant

Vérifiez d’abord que la chaîne d’outils actuelle est disponible, puis exécutez les commandes suivantes à la racine du dépôt :

set -eu
xcrun --find swift
xcrun swift ci/merge-patch-check.swift

Le script n’utilise pas d’assertions susceptibles d’être désactivées par l’optimisation : toute comparaison qui échoue lève une erreur et entraîne un code de sortie non nul. Exécutez-le avant la compilation de l’application, plus longue, et conservez simplement les noms des cas et le code de sortie. Il n’est pas nécessaire de journaliser les paramètres de véritables utilisateurs.

En pratique, validez le comportement à trois niveaux : le moteur exécute les cas fixes, la couche de construction des requêtes vérifie l’encodage réel, et la couche d’intégration de l’API contrôle le document après application par le serveur. Le cache mobile doit utiliser les mêmes cas de test pour éviter que l’interface n’applique d’abord une mise à jour optimiste selon des règles erronées, avant d’être corrigée par le résultat du serveur. La revue finale doit porter sur le sens de la mise à jour de chaque champ, et non sur le simple succès d’une requête.

Questions fréquentes

Peut-on attribuer null à un champ avec JSON Merge Patch ?

Dans un objet de correctif, null supprime le champ. Pour conserver un champ dont la valeur doit être null, il faut un autre protocole ou une opération explicite. Un correctif racine égal à null remplace toutefois tout le document par null.

Les éléments des tableaux sont-ils fusionnés par position ?

Non. Un tableau fourni dans le correctif remplace entièrement le tableau existant, même s’il est vide. Les mises à jour par identifiant nécessitent une opération métier distincte.

Réservez un Mac entier aux builds de votre équipe

Choisissez un Mac mini Apple Silicon M4 dédié et gardez votre propre chaîne d’outils et configuration de runner. Facturation à la journée, à la semaine, au mois ou au trimestre. La disponibilité affichée dans la console fait foi.

Louer un nœud de build

Choisissez la configuration adaptée à vos builds

Commencez par vérifier la mémoire et le stockage, puis choisissez la durée de facturation et le nœud.