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.
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.
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 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ță.
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.
| 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.
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.
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; }
}
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.
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:
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.
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.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.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.
Atos IT Solutions and Services
Software Engineer
oct. 2019 – feb. 2022
Dacă lucrezi la ceva din zona asta, hai să vorbim 30 de minute.
Programează o discuție