diff --git a/Infrastructure/SdkContractResolver.cs b/Infrastructure/SdkContractResolver.cs new file mode 100644 index 0000000..52b4dc3 --- /dev/null +++ b/Infrastructure/SdkContractResolver.cs @@ -0,0 +1,56 @@ +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Idoklad.Infrastructure; + +/// +/// Makes iDoklad SDK models serializable. Some SDK converters, attached through +/// , only support reading and their WriteJson throws +/// , which turns any response carrying such a model into a +/// 500. This resolver replaces them with : reading keeps +/// using the SDK converter, writing falls back to the standard serialization. Everything else is +/// left to . +/// +public sealed class SdkContractResolver : DefaultContractResolver +{ + /// + /// SDK converters that cannot write. Converters that do implement writing (for example + /// NullablePropertyJsonConverter) are deliberately not listed here. + /// + private static readonly HashSet WriteUnsupportedConverterTypeNames = new(StringComparer.Ordinal) + { + // Attached to DateTime members of the invoice, bank account, cash register and similar models. + "IdokladSdk.Serialization.DateTimeConverter", + + // Attached to the NotificationListGetModel type itself. + "IdokladSdk.Serialization.NotificationJsonConverter", + }; + + protected override JsonContract CreateContract(Type objectType) + { + var contract = base.CreateContract(objectType); + contract.Converter = Replace(contract.Converter); + return contract; + } + + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + var property = base.CreateProperty(member, memberSerialization); + property.Converter = Replace(property.Converter); + + // Collections carry the member converter as the item converter instead. + property.ItemConverter = Replace(property.ItemConverter); + + return property; + } + + private static JsonConverter? Replace(JsonConverter? converter) + { + var typeName = converter?.GetType().FullName; + + return typeName is not null && WriteUnsupportedConverterTypeNames.Contains(typeName) + ? new SdkWriteBypassJsonConverter(converter!) + : converter; + } +} diff --git a/Infrastructure/SdkWriteBypassJsonConverter.cs b/Infrastructure/SdkWriteBypassJsonConverter.cs new file mode 100644 index 0000000..f6993ab --- /dev/null +++ b/Infrastructure/SdkWriteBypassJsonConverter.cs @@ -0,0 +1,30 @@ +using Newtonsoft.Json; + +namespace Idoklad.Infrastructure; + +/// +/// Wraps an iDoklad SDK converter that only supports reading. Reading is delegated to the SDK +/// converter, while is false, so Newtonsoft writes the value with the +/// standard member-by-member serialization instead of calling the SDK's WriteJson. +/// +public sealed class SdkWriteBypassJsonConverter : JsonConverter +{ + private readonly JsonConverter _sdkConverter; + + public SdkWriteBypassJsonConverter(JsonConverter sdkConverter) + { + _sdkConverter = sdkConverter; + } + + public override bool CanRead => _sdkConverter.CanRead; + + public override bool CanWrite => false; + + public override bool CanConvert(Type objectType) => _sdkConverter.CanConvert(objectType); + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + => _sdkConverter.ReadJson(reader, objectType, existingValue, serializer); + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + => throw new InvalidOperationException("CanWrite is false; Newtonsoft must not call this converter for writing."); +} diff --git a/Program.cs b/Program.cs index f4daec8..0e12cbd 100644 --- a/Program.cs +++ b/Program.cs @@ -1,5 +1,6 @@ using Microsoft.OpenApi.Models; using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; using Idoklad.Client; using Idoklad.Configuration; using Idoklad.Credentials; @@ -47,6 +48,18 @@ builder.Services .AddNewtonsoftJson(options => { options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; + + // Some IdokladSdk converters attached to model members only support reading (their + // WriteJson throws NotImplementedException), which makes responses carrying such models + // fail. The resolver bypasses them on the write path; the existing naming strategy is + // preserved so property names in responses do not change. + var contractResolver = new SdkContractResolver(); + if (options.SerializerSettings.ContractResolver is DefaultContractResolver defaultResolver) + { + contractResolver.NamingStrategy = defaultResolver.NamingStrategy; + } + + options.SerializerSettings.ContractResolver = contractResolver; }); builder.Services.AddEndpointsApiExplorer(); diff --git a/README.md b/README.md index 929b07a..9cc5afe 100644 --- a/README.md +++ b/README.md @@ -120,4 +120,4 @@ Swagger UI otevřete na `http://localhost:/docs`. | Klient SDK | `Client/DokladApiFactory.cs`, `Client/IdokladApiAccessor.cs` | `graph_client.py` | | Service vrstva | `Services/*.cs` | `services.py` | | Controllery | `Controllers/*.cs` | `routes.py` | -| Mapování chyb + Swagger | `Infrastructure/*.cs` | – | +| Mapování chyb, serializace a Swagger | `Infrastructure/*.cs` | – | diff --git a/documentation/serializace-odpovedi.md b/documentation/serializace-odpovedi.md new file mode 100644 index 0000000..7980a5a --- /dev/null +++ b/documentation/serializace-odpovedi.md @@ -0,0 +1,92 @@ +# Serializace odpovědí (JSON) a konvertory SDK + +## Problém + +Endpointy vracející některé modely z IdokladSdk končily chybou 500 a v logu byla výjimka: + +```text +System.NotImplementedException: The method or operation is not implemented. + at IdokladSdk.Serialization.DateTimeConverter.WriteJson(JsonWriter writer, Object value, JsonSerializer serializer) + at Newtonsoft.Json.Serialization.JsonSerializerInternalWriter.SerializeConvertable(...) +``` + +Příčiny: + +- Část modelů SDK má konvertor přiřazený atributem `[JsonConverter(...)]`. +- Dva z těchto konvertorů umí jen čtení (deserializaci odpovědí z iDokladu), jejich `WriteJson` + vyhazuje `NotImplementedException`: + - `IdokladSdk.Serialization.DateTimeConverter` u 14 položek typu `DateTime` + - `IdokladSdk.Serialization.NotificationJsonConverter` přímo u typu `NotificationListGetModel` +- Služba používá Newtonsoft.Json i pro výstup (`AddNewtonsoftJson`), takže při serializaci + odpovědi Newtonsoft konvertor z atributu použil a request spadl. + +Konvertor uvedený atributem má přednost před konvertory registrovanými v `SerializerSettings`, +takže pouhé přidání vlastního konvertoru do nastavení problém neřeší. Vyměnit se musí +na úrovni kontraktu, tedy v `IContractResolver`. + +Postižené bylo 14 modelů SDK, mimo jiné: + +```text +ReceivedInvoiceGetModel, ReceivedInvoiceListGetModel, BankAccountGetModel, +BankAccountListGetModel, CashRegisterGetModel, CashRegisterListGetModel, +CreditNoteGetModel, CreditNoteListGetModel, IssuedInvoiceCopyGetModel, +ProformaInvoiceCopyGetModel, RecurringSettingGetModel, RecurringSettingListGetModel, +SubscriptionGetModel, NotificationListGetModel +``` + +Prakticky to znamená, že padaly například `GET /received-invoices`, `GET /registers/bank-accounts` +a `GET /integration/notifications`. + +## Řešení + +| Soubor | Účel | +| --- | --- | +| `Infrastructure/SdkWriteBypassJsonConverter.cs` | Obal nad konvertorem SDK. Čtení deleguje na něj, `CanWrite` je `false`, takže zápis provede standardní serializace po položkách. | +| `Infrastructure/SdkContractResolver.cs` | Potomek `DefaultContractResolver`, který tímto obalem nahradí konvertory SDK neumějící zápis. | +| `Program.cs` | Registrace resolveru v `AddNewtonsoftJson`. | + +Detaily: + +- Nahrazují se jen konvertory ze jmenného seznamu (`DateTimeConverter`, `NotificationJsonConverter`). + Konvertory SDK, které zápis umí, například `NullablePropertyJsonConverter` u `NullableProperty`, + zůstávají beze změny. +- Nahrazuje se konvertor na položce (`Converter`), na kolekci (`ItemConverter`) i na typu + (`JsonContract.Converter`), protože `NotificationJsonConverter` je uvedený u typu. +- Čtení se nemění, `CanRead` i `ReadJson` se delegují na původní konvertor SDK. Deserializace + requestů je tedy stejná jako předtím. +- Zápis datumů se neformátuje vlastním způsobem. Jde stejnou cestou jako u položek `DateTime`, + které konvertor nikdy neměly, takže je v odpovědi jednotný tvar. +- Původní `NamingStrategy` z výchozího resolveru MVC se přenese, aby se nezměnily názvy + vlastností v JSON odpovědích. + +## Ověření + +Provedeno před nasazením: + +1. `dotnet build -c Release` bez chyb. +2. Serializace všech 536 veřejných typů SDK s bezparametrickým konstruktorem, se stejným + nastavením, jaké dostane `AddNewtonsoftJson`: + - před opravou selhalo 14 typů na `NotImplementedException` + - po opravě 0 typů + - 521 typů serializovatelných v obou případech dalo identický JSON, názvy vlastností se tedy nezměnily +3. Round trip: serializace a zpětná deserializace modelu zachovala všech 7 položek `DateTime`. +4. Čtení payloadu ve tvaru iDokladu (`"DateOfIssue":"2026-08-25T00:00:00"`) dává stejný výsledek + před i po opravě. +5. Běh služby proti falešnému iDoklad API (`IDOKLAD_API_URL` a `IDOKLAD_IDENTITY_URL`): + `GET /health` vrátil 200, `GET /docs/v1/swagger.json` 200 a `GET /received-invoices`, + který dříve padal, vrátil 200 s vyplněnými datumy. + +Po nasazení stačí zavolat: + +```text +GET https://services.csbot.cz/apps/idoklad/received-invoices?page=1&pageSize=1 +``` + +Očekávaný výsledek je HTTP 200 a v logu žádná `NotImplementedException`. + +## Poznámka mimo rozsah opravy + +Pokud iDoklad vrátí odpověď, kterou SDK neumí zpracovat, vyhodí `IdokladValidationException`. +Ta není potomkem `IdokladBaseException`, takže ji `ExceptionHandlingMiddleware` nezachytí +a request skončí jako neošetřená 500 místo 502. Zjištěno při testu proti falešnému API, +neopravováno, protože to nesouvisí se serializací odpovědí.