oprava konvertoru
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
|
||||
namespace Idoklad.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Makes iDoklad SDK models serializable. Some SDK converters, attached through
|
||||
/// <see cref="JsonConverterAttribute"/>, only support reading and their <c>WriteJson</c> throws
|
||||
/// <see cref="NotImplementedException"/>, which turns any response carrying such a model into a
|
||||
/// 500. This resolver replaces them with <see cref="SdkWriteBypassJsonConverter"/>: reading keeps
|
||||
/// using the SDK converter, writing falls back to the standard serialization. Everything else is
|
||||
/// left to <see cref="DefaultContractResolver"/>.
|
||||
/// </summary>
|
||||
public sealed class SdkContractResolver : DefaultContractResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// SDK converters that cannot write. Converters that do implement writing (for example
|
||||
/// <c>NullablePropertyJsonConverter</c>) are deliberately not listed here.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Idoklad.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an iDoklad SDK converter that only supports reading. Reading is delegated to the SDK
|
||||
/// converter, while <see cref="CanWrite"/> is false, so Newtonsoft writes the value with the
|
||||
/// standard member-by-member serialization instead of calling the SDK's <c>WriteJson</c>.
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
+13
@@ -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();
|
||||
|
||||
@@ -120,4 +120,4 @@ Swagger UI otevřete na `http://localhost:<port>/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` | – |
|
||||
|
||||
@@ -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<T>`,
|
||||
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í.
|
||||
Reference in New Issue
Block a user