Toate articolele
.NET & integrări · 8 min citire ·

Contracte JSON tolerante: cum supraviețuiești unui API inconsistent

Când furnizorul trimite un număr ca string, un obiect unde era documentat un array și un string gol în loc de dată — cum absorbi forma fără să absorbi și ambiguitatea.

.NETSystem.Text.JsonintegrăriAPIreziliență

Există un tip de incident de producție care apare doar la integrările cu terți. Nu s-a schimbat nimic în codul tău. N-a plecat niciun deploy. Iar la 02:40 sincronizarea se oprește, cu o excepție de deserializare pe un câmp care a funcționat opt luni.

Furnizorul a schimbat ceva. Posibil din greșeală. Posibil consideră că e o schimbare necompatibilă cu nimic — pentru el chiar nu e.

A cui e vina și ajută asta cu ceva?

A lor și nu.

Merită să fim lucizi: nu ai nicio pârghie asupra disciplinei de payload a unui API extern, iar un deserializator rigid transformă fiecare mică inconsistență a lor în avaria ta. Întrebarea nu e cine a greșit, ci unde absorbi variația.

Răspunsul clasic e legea lui Postel — fii conservator în ce trimiți, liberal în ce accepți. A îmbătrânit prost ca sfat general, pentru că îngăduința nemărginită ascunde bug-uri și lasă implementările divergente să se pietrifice. Dar rezistă într-o formă îngustă și disciplinată:

Fii liberal cu forma a ceea ce accepți. Fii strict cu sensul ei. Și înregistrează fiecare toleranță pe care o exerciți.

A treia clauză e cea care separă asta de neglijență.

Ce rezolvă deja platforma?

Mai mult decât motivele pentru care lumea se repede la un convertor. Înainte să scrii cod propriu, epuizează opțiunile:

// Ține-l în cache. JsonSerializerOptions se îngheață la prima folosire, iar
// construirea unuia nou la fiecare apel aruncă la gunoi cache-ul de metadate
// al serializatorului — e un bug de performanță măsurabil, nu o preferință.
private static readonly JsonSerializerOptions Options = new()
{
    PropertyNameCaseInsensitive = true,
    NumberHandling = JsonNumberHandling.AllowReadingFromString
                   | JsonNumberHandling.AllowNamedFloatingPointLiterals,
    ReadCommentHandling = JsonCommentHandling.Skip,
    AllowTrailingCommas = true,
};

Singur, AllowReadingFromString omoară cel mai frecvent mod de eșec — "quantity": "12" acolo unde documentația promitea 12. Costă o linie și zero cod propriu.

Ce cazuri mai cer un convertor?

Simptom în payload Mecanism Capcana
"12" unde era documentat un număr JsonNumberHandling.AllowReadingFromString Niciuna — folosește ce e în platformă
Un obiect unde era documentat un array Convertor „unul sau mai multe“ Nu arunca tăcut cazul în care nu e niciunul din două
"" pentru o dată Convertor DateTimeOffset îngăduitor → null Doar dacă ținta e nullable; niciodată DateTime.MinValue
O valoare de enum necunoscută Convertor de enum cu un Unknown explicit Nu mapa niciodată necunoscutul pe un membru valid
"1" / "Y" / "true" pentru un boolean Convertor bool îngăduitor Enumeră valorile acceptate; nu trata „orice nu e gol“ ca adevărat
Un câmp care dispare complet Proprietate nullable + required pe cele care chiar contează Distinge „absent“ de „zero“

Convertorul „unul sau mai multe“ e cel pe care îl vei scrie cel mai des, pentru că vine dintr-un tipar foarte răspândit pe server — serializarea unei colecții care se întâmplă să aibă un singur element ca obiect simplu:

public sealed class SingleOrArrayConverter<T> : JsonConverter<T[]>
{
    // Fără asta, System.Text.Json scurtcircuitează un token null și nu apelează
    // deloc Read — ramura Null de mai jos ar fi cod mort.
    public override bool HandleNull => true;

    public override T[] Read(ref Utf8JsonReader reader, Type _, JsonSerializerOptions options)
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.Null:
                return [];

            case JsonTokenType.StartObject:
                return [JsonSerializer.Deserialize<T>(ref reader, options)!];

            case JsonTokenType.StartArray:
                // Citește tu elementele. Un Deserialize<T[]>(ref reader, options) aici
                // reintră în ACEST convertor — o recursie nemărginită care se termină
                // într-un StackOverflowException, pe care .NET nu te lasă să-l prinzi.
                var items = new List<T>();
                while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
                    items.Add(JsonSerializer.Deserialize<T>(ref reader, options)!);
                return items.ToArray();

            default:
                // Orice altceva e cu adevărat neașteptat — lasă-l să arunce și lasă
                // excepția să numească tipul. NU întoarce un array gol aici.
                throw new JsonException($"Token neașteptat {reader.TokenType} pentru {typeof(T).Name}");
        }
    }
}

Două lucruri de acolo valorează mai mult decât par.

Comentariul despre recursie nu e ipotetic. Evidentul JsonSerializer.Deserialize<T[]>(ref reader, options) pentru cazul array se citește ca „drumul normal, tratat normal“ — dar options conține în continuare acest convertor, deci se apelează pe sine. Eșecul e o depășire de stivă, care termină procesul fără o excepție pe care s-o poți loga. Se declanșează doar pe intrarea array, adică forma comună, deci va trece nevăzut de un test care acoperă doar cazul interesant, cel cu obiect singular.

Ramura default e disciplina. Ar fi ușor să întorci [] acolo și integrarea să „nu crape niciodată“. Exact așa se naște un bug de pierdere tăcută de date: sosește un payload într-o formă pe care n-ai mai văzut-o, înregistrezi zero elemente, iar raportul de reconciliere e greșit o lună întreagă înainte să observe cineva.

De ce un enum necunoscut are nevoie de membrul lui

Furnizorii adaugă valori de enum fără să considere că e o schimbare incompatibilă. Un statut nou de curier, o stare nouă de comandă, un tip nou de document — din partea lor e aditiv.

JsonStringEnumConverter va arunca pe o valoare nerecunoscută, ceea ce doboară toată înregistrarea. Instinctul e să mapezi necunoscutul pe un default sigur. Rezistă-i:

public enum OrderStatus
{
    Unknown = 0,     // rezervat pentru valorile pe care nu le-am mai văzut
    New = 1,
    Confirmed = 2,
    Cancelled = 3,
}

Unknown trebuie să fie un membru pe care logica ta de business refuză explicit să acționeze — să trimită înregistrarea într-o coadă de revizuire, nu pe calea fericită. Dacă mapezi un statut nerecunoscut pe New, n-ai făcut sistemul rezilient, l-ai făcut greșit cu încredere. Și observă direcția pericolului: maparea unui necunoscut pe Cancelled ar fi și mai rea.

Tolerează forma, niciodată sensul

Asta e linia care decide dacă parsarea tolerantă e inginerie sau neglijență.

Acceptabil: un preț care vine ca "14.90" în loc de 14.90. Aceeași valoare, altă codificare — sensul e neambiguu.

Inacceptabil: un preț care lipsește, iar tu îl pui pe 0. Zero e un preț real. Ai fabricat un fapt.

Testul pe care îl aplic: ar putea o persoană rezonabilă să citească valoarea tolerată altfel decât pe cea brută? Dacă da, nu e o corecție de formă, e o invenție. Banii lipsă, cantitățile lipsă, identificatorii lipsă și marcajele de timp lipsă intră toate în a doua categorie — trebuie să iasă la suprafață ca null și să forțeze o decizie, sau să eșueze zgomotos.

Membrii required din C# 11 sunt mecanismul de impunere pentru câmpurile unde absența e cu adevărat fatală:

public sealed class OrderDto
{
    public required string Id { get; init; }        // absent → eșec, imediat
    public required decimal Total { get; init; }
    public DateTimeOffset? ShippedAt { get; init; } // legitim absent
    public OrderStatus Status { get; init; }
}

Ține DTO-ul urât departe de domeniu

DTO-ului îi e permis să fie permisiv, nullable și neplăcut. Modelului tău de domeniu nu.

Două tipuri, un pas de mapare explicit între ele. Maparea e locul unde decizi ce înseamnă o valoare absentă în business-ul tău și e un loc mult mai bun pentru decizia asta decât un convertor, pentru că are context pe care un convertor nu-l va avea niciodată.

Îți dă și o cusătură curată pentru validare. Un DTO care s-a deserializat cu succes nu e același lucru cu un payload care are sens — o comandă cu un Total care nu se potrivește cu suma liniilor se parsează perfect.

Cum afli că s-a schimbat de fapt contractul?

Asta e partea care lipsește de obicei, iar fără ea toată abordarea se întoarce împotriva ta. Dacă convertoarele tale absorb totul tăcut, ai construit un sistem incapabil să-ți spună când s-a schimbat furnizorul.

Două mecanisme, amândouă ieftine:

  • Numără fiecare toleranță. Fiecare cădere pe ramura îngăduitoare incrementează o metrică etichetată cu numele câmpului. O singură alertă pe „am început să acceptăm numere scrise ca string pe un câmp care nu trimitea așa“ îți dă zile de avans în loc de un incident.
  • Capturează membrii nemapați. JsonSerializerOptions.UnmappedMemberHandling poate fi pus pe Disallow ca să arunce la proprietăți necunoscute — de obicei prea agresiv pentru o integrare vie, dar excelent într-o suită de teste rulată peste payload-uri înregistrate. În producție, preferă un dicționar [JsonExtensionData] ale cărui chei le loghezi.

Apoi ține fișiere de referință: răspunsuri reale, salvate verbatim, rejucate în teste. Când furnizorul trimite o formă nouă, vrei să afli dintr-o rulare CI eșuată pe o mostră proaspăt capturată, nu dintr-un tichet de suport. Pe integrările unde ieșirea furnizorului derivă regulat, suita pe fișiere de referință a prins mai multe probleme reale decât orice altă categorie de teste.

Ce să nu faci

  • Să deserializezi tot în JsonElement sau dynamic. N-ai rezolvat problema, ai mutat-o la locul apelului — unde acum e împrăștiată în zeci de locuri în loc de un convertor, iar compilatorul nu te ajută cu niciunul.
  • Să învelești deserializarea în try/catch și să întorci null. Arunci toată înregistrarea pentru că un câmp era ciudat și distrugi diagnosticul — știi că ceva a eșuat, dar nu ce.
  • Să faci totul nullable ca să înceteze erorile. Acum fiecare consumator din aval trebuie să trateze null-uri care nu pot apărea de fapt, iar cele care chiar pot sunt indistinctibile.
  • Să repari doar în DTO. Dacă un câmp e nesigur, faptul ăsta e interesant pentru business, nu doar pentru parser. Scoate-l la suprafață.

Principiul de fond e cel adus dintr-un domeniu foarte diferit — interoperabilitatea în telefonie, unde celălalt capăt implementează același protocol ca tine și tot îl greșește subtil. Nu ai cum să repari cealaltă implementare. Ai cum să fii precis în privința devierilor pe care le absorbi și să ții evidența fiecăreia pe care ai absorbit-o.

Dacă construiești o integrare cu un API care te tot surprinde și vrei o a doua opinie pe locul unde pui toleranța, e o discuție pe care o port cu plăcere.

Unde am lucrat sub aceste cerințe

  • Atos IT Solutions and Services

    Software Engineer

    oct. 2019 – feb. 2022

Proiectul din spatele articolului Automatizare Marketplace eMAG Hub SaaS pentru selleri eMAG — comenzi, AWB, pricing automat și detecție de fraudă.

Ai un proiect similar?

Dacă lucrezi la ceva din zona asta, hai să vorbim 30 de minute.

Programează o discuție
Toate articolele