Skip to content

Commit

Permalink
Adding a general management of json errors for some types
Browse files Browse the repository at this point in the history
  • Loading branch information
helto4real committed Aug 13, 2024
1 parent f0480a1 commit cbd28fa
Show file tree
Hide file tree
Showing 8 changed files with 312 additions and 42 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
using System.Globalization;
using NetDaemon.Client.Internal.Json;

namespace NetDaemon.HassClient.Tests.Json;

public class EnsureExpectedDatatypeConverterTest
{
private readonly JsonSerializerOptions _defaultSerializerOptions = DefaultSerializerOptions.DeserializationOptions;

[Fact]
public void TestConvertAllSupportedTypesConvertsCorrectly()
{
var json = @"
{
""string"": ""string"",
""int"": 10000,
""short"": 2000,
""float"": 1.1,
""bool"": true,
""datetime"": ""2019-02-16T18:11:44.183673+00:00""
}
";

var record = JsonSerializer.Deserialize<SupportedTypesTestRecord>(json, _defaultSerializerOptions);
record!.SomeString.Should().Be("string");
record!.SomeInt.Should().Be(10000);
record!.SomeShort.Should().Be(2000);
record!.SomeFloat.Should().Be(1.1f);
record!.SomeBool.Should().BeTrue();
record!.SomeDateTime.Should().Be(DateTime.Parse("2019-02-16T18:11:44.183673+00:00", CultureInfo.InvariantCulture));
}

[Fact]
public void TestConvertAllSupportedTypesConvertsToNullWhenWrongDatatypeCorrectly()
{
var json = @"
{
""string"": 1,
""int"": ""10000"",
""short"": ""2000"",
""float"": {""property"": ""100""},
""bool"": ""hello"",
""datetime"": ""test""
}
";

var record = JsonSerializer.Deserialize<SupportedTypesTestRecord>(json, _defaultSerializerOptions);
record!.SomeString.Should().BeNull();
record!.SomeInt.Should().BeNull();
record!.SomeShort.Should().BeNull();
record!.SomeFloat.Should().BeNull();
record!.SomeBool.Should().BeNull();
record!.SomeDateTime.Should().BeNull();
}

[Fact]
public void TestConvertAllSupportedTypesConvertsToNullWhenNullJsonCorrectly()
{
var json = @"
{
""string"": null,
""int"": null,
""short"": null,
""float"": null,
""bool"": null,
""datetime"": null
}
";

var record = JsonSerializer.Deserialize<SupportedTypesTestRecord>(json, _defaultSerializerOptions);
record!.SomeString.Should().BeNull();
record!.SomeInt.Should().BeNull();
record!.SomeShort.Should().BeNull();
record!.SomeFloat.Should().BeNull();
record!.SomeBool.Should().BeNull();
record!.SomeDateTime.Should().BeNull();
}

[Fact]
public void TestConvertAllNonNullShouldThrowExcptionIfThereAreADatatypeError()
{
var json = @"
{
""string"": 1
}
";
var result = JsonSerializer.Deserialize<SupportedTypesNonNullTestRecord>(json, _defaultSerializerOptions);
// The string can be null even if not nullable so it will not throw.
result!.SomeString.Should().BeNull();
json = @"
{
""int"": ""10000""
}
";
FluentActions.Invoking(() => JsonSerializer.Deserialize<SupportedTypesNonNullTestRecord>(json, _defaultSerializerOptions))
.Should().Throw<JsonException>();
json = @"
{
""short"": ""2000""
}
";
FluentActions.Invoking(() => JsonSerializer.Deserialize<SupportedTypesNonNullTestRecord>(json, _defaultSerializerOptions))
.Should().Throw<JsonException>();
json = @"
{
""float"": {""property"": ""100""}
}
";
FluentActions.Invoking(() => JsonSerializer.Deserialize<SupportedTypesNonNullTestRecord>(json, _defaultSerializerOptions))
.Should().Throw<JsonException>();
json = @"
{
""bool"": ""hello""
}
";
FluentActions.Invoking(() => JsonSerializer.Deserialize<SupportedTypesNonNullTestRecord>(json, _defaultSerializerOptions))
.Should().Throw<JsonException>();
json = @"
{
""datetime"": ""test""
}
";
FluentActions.Invoking(() => JsonSerializer.Deserialize<SupportedTypesNonNullTestRecord>(json, _defaultSerializerOptions))
.Should().Throw<JsonException>();
}
}

public record SupportedTypesTestRecord
{
[JsonPropertyName("string")] public string? SomeString { get; init; }
[JsonPropertyName("int")] public int? SomeInt { get; init; }
[JsonPropertyName("short")] public short? SomeShort { get; init; }
[JsonPropertyName("float")] public float? SomeFloat { get; init; }
[JsonPropertyName("bool")] public bool? SomeBool { get; init; }
[JsonPropertyName("datetime")] public DateTime? SomeDateTime { get; init; }
}

public record SupportedTypesNonNullTestRecord
{
[JsonPropertyName("string")] public string SomeString { get; init; } = string.Empty;
[JsonPropertyName("int")] public int SomeInt { get; init; }
[JsonPropertyName("short")] public short SomeShort { get; init; }
[JsonPropertyName("float")] public float SomeFloat { get; init; }
[JsonPropertyName("bool")] public bool SomeBool { get; init; }
[JsonPropertyName("datetime")] public DateTime SomeDateTime { get; init; }
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@

using NetDaemon.Client.Internal.Json;

namespace NetDaemon.HassClient.Tests.Json;

public class EnsureStringConverterTests
{
/// <summary>
/// Default Json serialization options, Hass expects intended
/// </summary>
private readonly JsonSerializerOptions _defaultSerializerOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly JsonSerializerOptions _defaultSerializerOptions = DefaultSerializerOptions.DeserializationOptions;

[Fact]
public void TestConvertAValidString()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace NetDaemon.Client.HomeAssistant.Extensions;
/// </summary>
public static class HassEventExtensions
{
private static readonly JsonSerializerOptions _jsonSerializerOptions = DefaultSerializerOptions.DeserializationOptions;
/// <summary>
/// Convert a HassEvent to a StateChangedEvent
/// </summary>
Expand All @@ -14,7 +15,7 @@ public static class HassEventExtensions
{
var jsonElement = hassEvent.DataElement ??
throw new NullReferenceException("DataElement cannot be empty");
return jsonElement.Deserialize<HassStateChangedEventData>();
return jsonElement.Deserialize<HassStateChangedEventData>(_jsonSerializerOptions);
}

/// <summary>
Expand All @@ -26,6 +27,6 @@ public static class HassEventExtensions
{
var jsonElement = hassEvent.DataElement ??
throw new NullReferenceException("DataElement cannot be empty");
return jsonElement.Deserialize<HassServiceEventData>();
return jsonElement.Deserialize<HassServiceEventData>(_jsonSerializerOptions);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,7 @@ public record HassDevice
#pragma warning disable CA1056 // It's ok for this URL to be a string
[JsonPropertyName("configuration_url")] public string? ConfigurationUrl { get; init; }
#pragma warning restore CA1056
[JsonConverter(typeof(EnsureStringConverter))]
[JsonPropertyName("hw_version")] public string? HardwareVersion { get; init; }
[JsonConverter(typeof(EnsureStringConverter))]
[JsonPropertyName("sw_version")] public string? SoftwareVersion { get; init; }
[JsonConverter(typeof(EnsureStringConverter))]
[JsonPropertyName("serial_number")] public string? SerialNumber { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace NetDaemon.Client.Internal.Json;

//<summary>
// Default options for serialization when serializing and deserializing json
// </summary>
internal static class DefaultSerializerOptions
{
public static JsonSerializerOptions DeserializationOptions => new()
{
Converters =
{
new EnsureStringConverter(),
new EnsureIntConverter(),
new EnsureShortConverter(),
new EnsureBooleanConverter(),
new EnsureFloatConverter(),
new EnsureDateTimeConverter()
}
};

public static JsonSerializerOptions SerializationOptions => new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
namespace NetDaemon.Client.Internal.Json;

/// <summary>
/// Base class for converters that ensures the expected suported datatyps
/// </summary>
/// <remarks>
/// This is a workaround to make the serializer to not throw exceptions when there are unexpected datatypes returning from Home Assistant json
/// This converter will only be used when deserializing json
///
/// Note: Tried to make a even smarter generic class but could not get it to avoid recursion
/// </remarks>
internal abstract class EnsureExcpectedDatatypeConverterBase<T> : JsonConverter<T?>
{
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
throw new NotImplementedException();

Check warning on line 15 in src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs#L15

Added line #L15 was not covered by tests

public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, typeof(T), options);
}

Check warning on line 20 in src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs#L19-L20

Added lines #L19 - L20 were not covered by tests

protected static object? ReadTokenSuccessfullyOrNull(ref Utf8JsonReader reader, JsonTokenType[] tokenType)
{
if (!tokenType.Contains(reader.TokenType))
{
// Skip the children of current token if it is not the expected one
reader.Skip();
return null;
}

var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);

try
{
return Type.GetTypeCode(type) switch
{
TypeCode.String => reader.GetString(),
TypeCode.Int32 => reader.GetInt32(),
TypeCode.Int16 => reader.GetInt16(),
TypeCode.Boolean => reader.GetBoolean(),
TypeCode.Single => reader.GetSingle(),
TypeCode.DateTime => reader.GetDateTime(),
_ => throw new NotImplementedException($"Type {typeof(T)} with timecode {Type.GetTypeCode(type)} is not implemented")

Check warning on line 43 in src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs#L43

Added line #L43 was not covered by tests
};
}
catch (JsonException)

Check warning on line 46 in src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs#L46

Added line #L46 was not covered by tests
{
// Skip the children of current token
reader.Skip();
return null;

Check warning on line 50 in src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs#L49-L50

Added lines #L49 - L50 were not covered by tests
}
catch (FormatException)
{
// We are getting this exception when for example there are a format error of dates etc
// I am reluctant if this error really should just return null, codereview should discuss
// Maybe trace log the error?
reader.Skip();
return null;
}
}
}

/// <summary>
/// Converts a Json element that can be a string or returns null if it is not a string
/// </summary>
internal class EnsureStringConverter : EnsureExcpectedDatatypeConverterBase<string?>
{
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.String, JsonTokenType.Null]) as string;
}

/// <summary>
/// Converts a Json element that can be a int or returns null if it is not a int
/// </summary>
internal class EnsureIntConverter : EnsureExcpectedDatatypeConverterBase<int?>
{
public override int? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
(int?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.Number, JsonTokenType.Null]);
}

/// <summary>
/// Converts a Json element that can be a short or returns null if it is not a short
/// </summary>
internal class EnsureShortConverter : EnsureExcpectedDatatypeConverterBase<short?>
{
public override short? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
(short?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.Number, JsonTokenType.Null]);
}

/// <summary>
/// Converts a Json element that can be a float or returns null if it is not afloat
/// </summary>
internal class EnsureFloatConverter : EnsureExcpectedDatatypeConverterBase<float?>
{
public override float? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
(float?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.Number, JsonTokenType.Null]);
}

/// <summary>
/// Converts a Json element that can be a boolean or returns null if it is not a boolean
/// </summary>
internal class EnsureBooleanConverter : EnsureExcpectedDatatypeConverterBase<bool?>
{
public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
(bool?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.True, JsonTokenType.False, JsonTokenType.Null]);
}

/// <summary>
/// Converts a Json element that can be a string or returns null if it is not a string
/// </summary>
internal class EnsureDateTimeConverter : EnsureExcpectedDatatypeConverterBase<DateTime?>
{
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
(DateTime?) ReadTokenSuccessfullyOrNull(ref reader, [JsonTokenType.String, JsonTokenType.Null]);
}

/// <summary>
/// Return all the converters that should be used when deserializing
/// </summary>
internal static class EnsureExpectedDatatypeConverter
{
public static IList<JsonConverter> Converters() =>
[
new EnsureStringConverter(),
new EnsureIntConverter(),
new EnsureShortConverter(),
new EnsureFloatConverter(),
new EnsureBooleanConverter(),
new EnsureDateTimeConverter()
];

Check warning on line 130 in src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs

View check run for this annotation

Codecov / codecov/patch

src/Client/NetDaemon.HassClient/Internal/Json/EnsureExcpectedDatatypeConverter.cs#L123-L130

Added lines #L123 - L130 were not covered by tests
}

This file was deleted.

Loading

0 comments on commit cbd28fa

Please sign in to comment.