revize endpointu: cteni NullableProperty, filtr v UTC, attachments

Kontrola vsech 180 operaci proti falesnemu iDoklad API a round trip
331 modelu SDK v obou smerech.

- SdkNullablePropertyConverter doplnuje cteni NullableProperty<T>.
  Konvertor SDK umi jen zapis, takze PATCH s takovou polozkou koncil
  prazdnou 500 uz pri cteni tela. Tykalo se 29 modelu. Zapis zustava
  na konvertoru SDK, odchozi payload se nemeni.
- Datum uvnitr NullableProperty se normalizuje na UTC stejne jako
  zbytek serializace.
- ListModifiers parsuje datum ve filtru s AdjustToUniversal a
  AssumeUniversal. Filtr se zonou se drive posouval o offset.
- POST /attachments kontroluje FileName a FileBytes, SDK na ne sahalo
  bez kontroly na null a vracelo neosetrenou 500.
- ExceptionHandlingMiddleware ma posledni zachyt, zadny request uz
  nekonci prazdnou 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-25 11:13:21 +02:00
co-authored by Claude Opus 5
parent 73f01d0228
commit 225161e4ca
7 changed files with 194 additions and 11 deletions
@@ -79,6 +79,14 @@ public sealed class ExceptionHandlingMiddleware
// Thrown by the SDK when required credential fields are blank/invalid.
await WriteProblem(context, HttpStatusCode.BadRequest, ex.Message, null);
}
catch (Exception ex)
{
// Last resort: without this the response is a 500 with an empty body, which tells the
// caller nothing. The exception type is enough to locate the cause in the container log;
// no stack trace or request data is exposed.
_logger.LogError(ex, "Unhandled error while processing the request.");
await WriteProblem(context, HttpStatusCode.InternalServerError, $"Unexpected error ({ex.GetType().Name}). See the service log for details.", null);
}
}
private static async Task WriteProblem(HttpContext context, HttpStatusCode status, string detail, IDictionary<string, object?>? extensions)
@@ -114,6 +122,7 @@ public sealed class ExceptionHandlingMiddleware
HttpStatusCode.Unauthorized => "Unauthorized",
HttpStatusCode.BadRequest => "Bad Request",
HttpStatusCode.BadGateway => "Upstream iDoklad API error",
HttpStatusCode.InternalServerError => "Internal Server Error",
_ => status.ToString(),
};
}
+24 -9
View File
@@ -5,18 +5,17 @@ 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
/// Makes iDoklad SDK models usable in both directions. The SDK only ever serializes what it sends
/// and deserializes what it receives, so several of its converters, attached through
/// <see cref="JsonConverterAttribute"/>, implement one direction and throw
/// <see cref="NotImplementedException"/> in the other. This service needs both directions, so this
/// resolver swaps those converters for wrappers that fill in the missing half. 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.
/// SDK converters that cannot write, so responses carrying such a model would fail.
/// </summary>
private static readonly HashSet<string> WriteUnsupportedConverterTypeNames = new(StringComparer.Ordinal)
{
@@ -27,6 +26,11 @@ public sealed class SdkContractResolver : DefaultContractResolver
"IdokladSdk.Serialization.NotificationJsonConverter",
};
/// <summary>
/// SDK converters that cannot read, so a request body carrying such a member would fail.
/// </summary>
private const string NullablePropertyConverterTypeName = "IdokladSdk.Clients.NullablePropertyJsonConverter";
protected override JsonContract CreateContract(Type objectType)
{
var contract = base.CreateContract(objectType);
@@ -49,8 +53,19 @@ public sealed class SdkContractResolver : DefaultContractResolver
{
var typeName = converter?.GetType().FullName;
return typeName is not null && WriteUnsupportedConverterTypeNames.Contains(typeName)
? new SdkWriteBypassJsonConverter(converter!)
if (typeName is null)
{
return converter;
}
if (WriteUnsupportedConverterTypeNames.Contains(typeName))
{
return new SdkWriteBypassJsonConverter(converter!);
}
// Attached to NullableProperty<T> members of Patch models; writing works, reading throws.
return typeName == NullablePropertyConverterTypeName
? new SdkNullablePropertyConverter(converter!)
: converter;
}
}
@@ -0,0 +1,66 @@
using System.Reflection;
using Newtonsoft.Json;
namespace Idoklad.Infrastructure;
/// <summary>
/// Read support for the SDK's <c>NullableProperty&lt;T&gt;</c>, used by Patch models to tell
/// "not set" apart from "set to null". The SDK converter only writes: its <c>ReadJson</c> throws
/// <see cref="NotImplementedException"/>, because the SDK never deserializes a Patch model. This
/// service does, so a PATCH body carrying such a member failed while binding. Writing stays with
/// the SDK converter so the payload sent upstream is unchanged.
/// </summary>
public sealed class SdkNullablePropertyConverter : JsonConverter
{
private readonly JsonConverter _sdkConverter;
public SdkNullablePropertyConverter(JsonConverter sdkConverter)
{
_sdkConverter = sdkConverter;
}
public override bool CanRead => true;
public override bool CanWrite => _sdkConverter.CanWrite;
public override bool CanConvert(Type objectType) => _sdkConverter.CanConvert(objectType);
/// <summary>
/// Reads the JSON scalar the SDK converter writes: a value, or null for an explicit reset.
/// A member missing from the body is never read at all and keeps its unset default.
/// </summary>
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
var propertyType = Nullable.GetUnderlyingType(objectType) ?? objectType;
var valueType = propertyType.GetGenericArguments()[0];
var value = reader.TokenType == JsonToken.Null
? null
: serializer.Deserialize(reader, typeof(Nullable<>).MakeGenericType(valueType));
// The member converter puts the reader in DateParseHandling.None, so the date arrives as a
// string and the serializer's DateTimeZoneHandling never gets applied to it. The SDK
// rejects a DateTime whose Kind is not Utc, so apply the same normalization here.
if (value is DateTime date)
{
value = ToUtc(date);
}
// NullableProperty<T>(T? value) marks the member as set, including when the value is null.
return Activator.CreateInstance(propertyType, BindingFlags.Instance | BindingFlags.Public | BindingFlags.CreateInstance, null, new[] { value }, null);
}
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
=> _sdkConverter.WriteJson(writer, value, serializer);
/// <summary>
/// Matches <see cref="DateTimeZoneHandling.Utc"/>: a value that carries an offset is converted,
/// a value without one is taken as already being UTC and is only marked as such.
/// </summary>
private static DateTime ToUtc(DateTime value) => value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc),
};
}