Skip to content

Commit

Permalink
Enhance JSON Serialization and Deserialization
Browse files Browse the repository at this point in the history
Updated serialization for `Money` type to support advanced formats, including reversed currency/amount strings. Improved error handling with concise messages for invalid formats. Expanded test coverage for varied JSON serialization scenarios, ensuring robustness with both valid and invalid test cases.
  • Loading branch information
RemyDuijkeren committed Dec 5, 2024
1 parent 578fb76 commit e794e23
Show file tree
Hide file tree
Showing 8 changed files with 211 additions and 168 deletions.
2 changes: 1 addition & 1 deletion src/NodaMoney/ExchangeRate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ public override int GetHashCode()
{
return Value.GetHashCode() + (397 * BaseCurrency.GetHashCode()) + (397 * QuoteCurrency.GetHashCode());
}
}
}

/// <summary>Indicates whether this instance and a specified <see cref="ExchangeRate"/> are equal.</summary>
/// <param name="other">Another object to compare to.</param>
Expand Down
5 changes: 3 additions & 2 deletions src/NodaMoney/Money.Serializable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ namespace NodaMoney;

/// <summary>Represents Money, an amount defined in a specific Currency.</summary>
[Serializable]
//[TypeConverter(typeof(MoneyTypeConverter))]
[JsonConverter(typeof(MoneyJsonConverter))]
[TypeConverter(typeof(MoneyTypeConverter))] // Used by Newtonsoft.Json JSON String to do the serialization.
[JsonConverter(typeof(MoneyJsonConverter))] // Used by System.Text.Json to do the serialization.
//[JsonConverter(typeof(NullableMoneyJsonConverter))]
// IXmlSerializable used for XML serialization (ReadXml, WriteXml, GetSchema),
// ISerializable for binary serialization (GetObjectData, ctor(SerializationInfo, StreamingContext))
public partial struct Money : IXmlSerializable, ISerializable
Expand Down
53 changes: 38 additions & 15 deletions src/NodaMoney/MoneyJsonConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,26 @@ namespace NodaMoney;
/// <inheritdoc />
public class MoneyJsonConverter : JsonConverter<Money>
{
//public override bool HandleNull => false;

/// <inheritdoc />
public override Money Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
//return (Money)null;
throw new JsonException("Unexpected null value for Money.");
return default;

Check warning on line 18 in src/NodaMoney/MoneyJsonConverter.cs

View workflow job for this annotation

GitHub Actions / build

Unreachable code detected
//return null;
}

// new serialization format (v2): "EUR 234.25"
// new serialization format (v2): "EUR 234.25" (or "234.25 EUR")
// TODO: serialize non-ISO-4217 currencies with same code as ISO-4217 currencies, like "XXX;NON-ISO 234.25" or something else
if (reader.TokenType == JsonTokenType.String)
{
string value = reader.GetString();
string? value = reader.GetString();
if (string.IsNullOrWhiteSpace(value))
{
throw new JsonException("Invalid format for Money. Expected format is 'Currency Amount', like 'EUR 234.25'.");
//return (Money)null;
}
else
Expand All @@ -29,16 +35,35 @@ public override Money Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSe
int spaceIndex = valueAsSpan.IndexOf(' ');
if (spaceIndex == -1)
{
throw new JsonException("Invalid format for Money. Expected format is 'Currency Amount'.");
throw new JsonException("Invalid format for Money. Expected format is 'Currency Amount', like 'EUR 234.25'.");
}

ReadOnlySpan<char> currencySpan = valueAsSpan.Slice(0, spaceIndex);
ReadOnlySpan<char> amountSpan = valueAsSpan.Slice(spaceIndex + 1);

Currency currency1 = new Currency(currencySpan.ToString());
decimal amount1 = decimal.Parse(amountSpan.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture);
try
{
Currency currency1 = new Currency(currencySpan.ToString());
decimal amount1 = decimal.Parse(amountSpan.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture);

return new Money(amount1, currency1);
}
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
try
{
// try reverse 234.25 EUR
Currency currency1 = new Currency(amountSpan.ToString());
decimal amount1 = decimal.Parse(currencySpan.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture);

return new Money(amount1, currency1);
return new Money(amount1, currency1);
}
catch (Exception reverseException) when (reverseException is FormatException or ArgumentException)
{
// throw with original exception!
throw new JsonException("Invalid format for Money. Expected format is 'Currency Amount', like 'EUR 234.25'.", ex);
}
}
}
}

Expand Down Expand Up @@ -109,14 +134,12 @@ public override Money Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSe
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, Money money, JsonSerializerOptions options)
{
if (money == null)
{
writer.WriteNullValue();
return;
}

writer.WriteStringValue($"{money.Currency.Code.ToString(CultureInfo.InvariantCulture)} {money.Amount.ToString(CultureInfo.InvariantCulture)}");
// if (money == null)
// {
// writer.WriteNullValue();
// }
// else
// {
// writer.WriteStringValue($"{money.Currency.Code.ToString(CultureInfo.InvariantCulture)} {money.Amount.ToString(CultureInfo.InvariantCulture)}");
// }
}
}
62 changes: 42 additions & 20 deletions src/NodaMoney/MoneyTypeConverter.cs
Original file line number Diff line number Diff line change
@@ -1,46 +1,68 @@
using System.ComponentModel;
using System.Globalization;
using System.Runtime.Serialization;
using System.Text;

namespace NodaMoney;

/// <summary>Provides a way of converting the type <see cref="string"/> to and from the type <see cref="Money"/>.</summary>
/// <remarks>Used by <see cref="Newtonsoft.Json."/> to do the serialization.</remarks>
/// <remarks>Used by <see cref="Newtonsoft.Json"/> for JSON Strings to do the serialization.</remarks>
public class MoneyTypeConverter : TypeConverter
{
/// <inheritdoc/>
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) =>
sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
sourceType == typeof(string) || sourceType == typeof(Money) || base.CanConvertFrom(context, sourceType);

/// <inheritdoc/>
public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) =>
destinationType == typeof(Money) || base.CanConvertTo(context, destinationType);
destinationType == typeof(Money) || destinationType == typeof(string) || base.CanConvertTo(context, destinationType);

/// <inheritdoc/>
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object? value)
{
if (value is string valueAsString)
// Newtonsoft.Json will call this method when it is a JSON String, like "EUR 234.25",
// but if it is a JSON Object it tries to check if it can convert JObject (in Newtonsoft.Json).

if (value is not string jsonString)
return base.ConvertFrom(context, culture, value);

var valueAsSpan = jsonString.AsSpan();
var spaceIndex = valueAsSpan.IndexOf(' ');
if (spaceIndex == -1)
{
// Use Span<T> to slice the string without creating a new array
var valueAsSpan = valueAsString.AsSpan();
var separatorIndex = valueAsSpan.IndexOf(' ');
if (separatorIndex == -1)
{
throw new FormatException("Invalid format. Expected format is 'amount currency' but didn't find a space.");
}
throw new FormatException("Invalid format for Money. Expected format is 'Currency Amount', like 'EUR 234.25', but didn't find a space.");
}

var amountPart = valueAsSpan.Slice(0, separatorIndex);
var currencyPart = valueAsSpan.Slice(separatorIndex + 1);
ReadOnlySpan<char> currencySpan = valueAsSpan.Slice(0, spaceIndex);
ReadOnlySpan<char> amountSpan = valueAsSpan.Slice(spaceIndex + 1);

var amount = decimal.Parse(amountPart.ToString(), culture);
var currency = new Currency(currencyPart.ToString());
try
{
Currency currency1 = new Currency(currencySpan.ToString());
decimal amount1 = decimal.Parse(amountSpan.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture);

return new Money(amount, currency);
return new Money(amount1, currency1);
}
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
try
{
// try reverse: 234.25 EUR
Currency currency1 = new Currency(amountSpan.ToString());
decimal amount1 = decimal.Parse(currencySpan.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture);

// string[] v = valueAsString.Split([' ']);
// return new Money(decimal.Parse(v[0], culture), v[1]);
return new Money(amount1, currency1);
}
catch (Exception reverseException) when (reverseException is FormatException or ArgumentException)
{
// throw with original exception!
throw new SerializationException("Invalid format for Money. Expected format is 'Currency Amount', like 'EUR 234.25'.", ex);
}
}

// old serialization format (v1): { "Amount": 234.25, "Currency": "EUR" }
// use the build in converter for this.

return base.ConvertFrom(context, culture, value);

Check warning on line 66 in src/NodaMoney/MoneyTypeConverter.cs

View workflow job for this annotation

GitHub Actions / build

Unreachable code detected
}

Expand All @@ -50,9 +72,9 @@ public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo c
if (destinationType == typeof(string) && value is Money money)
{
var result = new StringBuilder();
result.Append(money.Amount.ToString(culture));
result.Append(' ');
result.Append(money.Currency.Code.ToString(culture));
result.Append(' ');
result.Append(money.Amount.ToString(culture));

return result.ToString();
}
Expand Down
41 changes: 35 additions & 6 deletions tests/NodaMoney.Tests/Serialization/NewtonsoftJsonSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ public void WhenOrder_ThenThisShouldSucceed(Money money, string expectedCurrency
{
// Arrange
var order = new Order { Id = 123, Name = "Foo", Total = money };
string expected = $$"""{"Id":123,"Name":"Foo","Total":"{{expectedMoney}}"}""";
string expected = $$"""
{"Id":123,"Total":"{{expectedMoney}}","Name":"Foo"}
""";

// Act
string json = JsonConvert.SerializeObject(order);
Expand All @@ -77,7 +79,7 @@ public void WhenNullableOrder_ThenThisShouldSucceed(Money money, string expected
{
// Arrange
var order = new NullableOrder() { Id = 123, Name = "Foo", Total = money };
string expected = $$"""{"Id":123,"Name":"Foo","Price":"{{expectedMoney}}"}""";
string expected = $$"""{"Id":123,"Total":"{{expectedMoney}}","Name":"Foo"}""";

// Act
string json = JsonConvert.SerializeObject(order);
Expand All @@ -94,7 +96,7 @@ public class GivenIWantToDeserializeMoney
{
[Theory]
[ClassData(typeof(ValidJsonV1TestData))]
public void WhenDeserializing_ThenThisShouldSucceed(string json, Money expected)
public void WhenDeserializingV1_ThenThisShouldSucceed(string json, Money expected)
{
var clone = JsonConvert.DeserializeObject<Money>(json);

Expand All @@ -103,16 +105,43 @@ public void WhenDeserializing_ThenThisShouldSucceed(string json, Money expected)

[Theory]
[ClassData(typeof(InvalidJsonV1TestData))]
public void WhenDeserializingWithInvalidJSON_ThenThisShouldFail(string json)
public void WhenDeserializingWithInvalidJSONV1_ThenThisShouldFail(string json)
{
Action action = () => JsonConvert.DeserializeObject<Money>(json);

action.Should().Throw<SerializationException>().WithMessage("Member '*");
action.Should().Throw<SerializationException>();
}

[Theory]
[ClassData(typeof(NestedJsonV1TestData))]
public void WhenDeserializingWithNested_ThenThisShouldSucceed(string json, Order expected)
public void WhenDeserializingWithNestedV1_ThenThisShouldSucceed(string json, Order expected)
{
var clone = JsonConvert.DeserializeObject<Order>(json);

clone.Should().BeEquivalentTo(expected);
}

[Theory]
[ClassData(typeof(ValidJsonV2TestData))]
public void WhenDeserializingV2_ThenThisShouldSucceed(string json, Money expected)
{
var clone = JsonConvert.DeserializeObject<Money>(json);

clone.Should().Be(expected);
}

[Theory]
[ClassData(typeof(InvalidJsonV2TestData))]
public void WhenDeserializingWithInvalidJSONV2_ThenThisShouldFail(string json)
{
Action action = () => JsonConvert.DeserializeObject<Money>(json);

action.Should().Throw<JsonException>();
}

[Theory]
[ClassData(typeof(NestedJsonV2TestData))]
public void WhenDeserializingWithNestedV2_ThenThisShouldSucceed(string json, Order expected)
{
var clone = JsonConvert.DeserializeObject<Order>(json);

Expand Down
29 changes: 6 additions & 23 deletions tests/NodaMoney.Tests/Serialization/RavenDbSerializationSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,13 @@ namespace NodaMoney.Tests.Serialization.RavenDbSerializationSpec;

public class GivenIWantToStoreInRavenDb : RavenTestDriver
{
[Fact]
public void WhenMoneyAsRoot_ThenThisMustWork()
static GivenIWantToStoreInRavenDb()
{
Money euros = new Money(123.56, "EUR");

using var store = GetDocumentStore();

// Store in RavenDb
using (var session = store.OpenSession())
// ConfigureServer() must be set before calling GetDocumentStore() and can only be set once per test run.
ConfigureServer(new TestServerOptions
{
session.Store(euros);
session.SaveChanges();
}

WaitForIndexing(store);
//WaitForUserToContinueTheTest(store); // Sometimes we want to debug the test itself, this redirect us to the studio

// Read from RavenDb
using (var session = store.OpenSession())
{
var result = session.Query<Money>().FirstOrDefault();

result.Should().Be(euros);
}
Licensing = { ThrowOnInvalidOrMissingLicense = false }
});
}

[Fact]
Expand All @@ -48,7 +31,7 @@ public void WhenObjectWithMoneyAttribute_ThenThisMustWork()
}

WaitForIndexing(store);
WaitForUserToContinueTheTest(store); // Sometimes we want to debug the test itself, this redirect us to the studio
//WaitForUserToContinueTheTest(store); // Sometimes we want to debug the test itself, this redirect us to the studio

// Read from RavenDb
using (var session = store.OpenSession())
Expand Down
Loading

0 comments on commit e794e23

Please sign in to comment.