From 2f3afb6af0915387f2745b077ed3325394dce063 Mon Sep 17 00:00:00 2001
From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com>
Date: Tue, 25 Aug 2026 08:53:52 +0200
Subject: [PATCH] oprava konvertoru
---
Infrastructure/SdkContractResolver.cs | 56 +++++++++++
Infrastructure/SdkWriteBypassJsonConverter.cs | 30 ++++++
Program.cs | 13 +++
README.md | 2 +-
documentation/serializace-odpovedi.md | 92 +++++++++++++++++++
5 files changed, 192 insertions(+), 1 deletion(-)
create mode 100644 Infrastructure/SdkContractResolver.cs
create mode 100644 Infrastructure/SdkWriteBypassJsonConverter.cs
create mode 100644 documentation/serializace-odpovedi.md
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í.