Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JSON serialization #69

Merged
merged 4 commits into from
Jan 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions NMoneys.sln
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "archive", "archive", "{DB66
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NMoneys.Exchange", "archive\NMoneys.Exchange\NMoneys.Exchange.csproj", "{97C58515-92D8-4ECF-86B6-CFAABD66279D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NMoneys.Serialization", "src\NMoneys.Serialization\NMoneys.Serialization.csproj", "{F61AA4C5-EAF0-425F-BF23-8B04DB85A1FF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NMoneys.Serialization.Tests", "tests\NMoneys.Serialization.Tests\NMoneys.Serialization.Tests.csproj", "{A28BEBB8-EC70-4FC5-AFE7-9989F816347B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -36,10 +40,20 @@ Global
{97C58515-92D8-4ECF-86B6-CFAABD66279D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{97C58515-92D8-4ECF-86B6-CFAABD66279D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{97C58515-92D8-4ECF-86B6-CFAABD66279D}.Release|Any CPU.Build.0 = Release|Any CPU
{F61AA4C5-EAF0-425F-BF23-8B04DB85A1FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F61AA4C5-EAF0-425F-BF23-8B04DB85A1FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F61AA4C5-EAF0-425F-BF23-8B04DB85A1FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F61AA4C5-EAF0-425F-BF23-8B04DB85A1FF}.Release|Any CPU.Build.0 = Release|Any CPU
{A28BEBB8-EC70-4FC5-AFE7-9989F816347B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A28BEBB8-EC70-4FC5-AFE7-9989F816347B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A28BEBB8-EC70-4FC5-AFE7-9989F816347B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A28BEBB8-EC70-4FC5-AFE7-9989F816347B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{0484566C-1D24-433E-90F2-B8B44BEF2FB2} = {558BFE88-EB25-4BFA-91B8-4CFF8696CED4}
{891B60F6-888D-488A-89D0-6E25FE1A7883} = {6454722E-5710-44B0-8EDE-1DE92929E641}
{97C58515-92D8-4ECF-86B6-CFAABD66279D} = {DB66F4E1-075E-4E1C-931E-5DD8F956E484}
{F61AA4C5-EAF0-425F-BF23-8B04DB85A1FF} = {6454722E-5710-44B0-8EDE-1DE92929E641}
{A28BEBB8-EC70-4FC5-AFE7-9989F816347B} = {558BFE88-EB25-4BFA-91B8-4CFF8696CED4}
EndGlobalSection
EndGlobal
101 changes: 101 additions & 0 deletions src/NMoneys.Serialization/BSON/MoneySerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using MongoDB.Bson;
using MongoDB.Bson.IO;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;

namespace NMoneys.Serialization.BSON;

/// <summary>
/// Allows customizing the serialization of monetary quantities
/// </summary>
public class MoneySerializer : StructSerializerBase<Money>
{
// maps cannot be changed so the looked-up instance is cached
private readonly Lazy<BsonClassMap<Money>> _map = new(() =>
(BsonClassMap<Money>)BsonClassMap.LookupClassMap(typeof(Money)));

/// <inheritdoc />
public override void Serialize([NotNull]BsonSerializationContext context, BsonSerializationArgs args, Money value)
{
context.Writer.WriteStartDocument();

writeAmount(context.Writer, value);

writeCurrency(context, args, value);

context.Writer.WriteEndDocument();
}

/// <inheritdoc />
public override Money Deserialize([NotNull]BsonDeserializationContext context, BsonDeserializationArgs args)
{
context.Reader.ReadStartDocument();

decimal amount = readAmount(context.Reader);
CurrencyIsoCode currency = readCurrency(context, args);

context.Reader.ReadEndDocument();

var deserialized = new Money(amount, currency);
return deserialized;
}

#region write

private void writeAmount(IBsonWriter writer, Money value)
{
string amountName = _map.Value.GetMemberMap(m => m.Amount).ElementName;
writer.WriteName(amountName);
writer.WriteDecimal128(value.Amount);
}

private void writeCurrency(BsonSerializationContext context, BsonSerializationArgs args, Money value)
{
var currencyMap = _map.Value.GetMemberMap(c => c.CurrencyCode);
string currencyName = char.IsLower(currencyMap.ElementName, 0) ? "currency" : "Currency";
context.Writer.WriteName(currencyName);
currencyMap.GetSerializer().Serialize(context, args, value.CurrencyCode);
}

#endregion

#region read

private decimal readAmount(IBsonReader reader)
{
var amountMap = _map.Value.GetMemberMap(m => m.Amount);
reader.ReadName(amountMap.ElementName);
BsonType type = reader.GetCurrentBsonType();
decimal amount;
switch (type)
{
// does not support floating point conversion
case BsonType.String:
string str = reader.ReadString();
amount = decimal.Parse(str, CultureInfo.InvariantCulture);
break;
case BsonType.Decimal128:
Decimal128 dec = reader.ReadDecimal128();
amount = Decimal128.ToDecimal(dec);
break;
default:
throw new NotSupportedException($"Cannot extract a monetary amount out of '{type.ToString()}'.");
}

return amount;
}

private CurrencyIsoCode readCurrency(BsonDeserializationContext context, BsonDeserializationArgs args)
{
var currencyMap = _map.Value.GetMemberMap(m => m.CurrencyCode);
string currencyName = char.IsLower(currencyMap.ElementName, 0) ? "currency" : "Currency";
context.Reader.ReadName(currencyName);
var currency = (CurrencyIsoCode)currencyMap.GetSerializer()
.Deserialize(context, args);
return currency;
}

#endregion
}
23 changes: 23 additions & 0 deletions src/NMoneys.Serialization/BSON/QuantitySerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;

namespace NMoneys.Serialization.BSON;

public class QuantitySerializer : StructSerializerBase<Money>
{
/// <inheritdoc />
public override void Serialize([NotNull]BsonSerializationContext context, BsonSerializationArgs args, Money value)
{
context.Writer.WriteString(value.AsQuantity());
}

/// <inheritdoc />
public override Money Deserialize([NotNull]BsonDeserializationContext context, BsonDeserializationArgs args)
{
string quantity = context.Reader.ReadString();

Money deserialized = Money.Parse(quantity);
return deserialized;
}
}
17 changes: 17 additions & 0 deletions src/NMoneys.Serialization/EFCore/JsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using System.Text.Json;

namespace NMoneys.Serialization.EFCore;

/// <summary>
/// Defines conversions from an instance of <see cref="Money"/> in a model to a <see cref="string"/> in the store.
/// </summary>
public class JsonConverter : ValueConverter<Money, string>
{
/// <summary>
/// Initializes a new instance of the <see cref="JsonConverter"/> class.
/// </summary>
public JsonConverter(JsonSerializerOptions options) : base(
m => JsonSerializer.Serialize(m, options),
str => JsonSerializer.Deserialize<Money>(str, options)) { }
}
14 changes: 14 additions & 0 deletions src/NMoneys.Serialization/EFCore/MoneyComparer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore.ChangeTracking;

namespace NMoneys.Serialization.EFCore;

/// <summary>
/// Specifies custom value snapshotting and comparison for <see cref="Money"/>.
/// </summary>
public class MoneyComparer : ValueComparer<Money>
{
/// <summary>
/// Creates a new <see cref="MoneyComparer"/>. A shallow copy will be used for the snapshot.
/// </summary>
public MoneyComparer() : base((x, y) => x.Equals(y), m => m.GetHashCode()) { }
}
16 changes: 16 additions & 0 deletions src/NMoneys.Serialization/EFCore/QuantityConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;

namespace NMoneys.Serialization.EFCore;

/// <summary>
/// Defines conversions from an instance of <see cref="Money"/> in a model to a <see cref="string"/> in the store.
/// </summary>
public class QuantityConverter : ValueConverter<Money, string>
{
/// <summary>
/// Initializes a new instance of the <see cref="QuantityConverter"/> class.
/// </summary>
public QuantityConverter() : base(
m => m.AsQuantity(),
str => Money.Parse(str)) { }
}
190 changes: 190 additions & 0 deletions src/NMoneys.Serialization/Json_NET/DescriptiveMoneyConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;

namespace NMoneys.Serialization.Json_NET;

/// <summary>
/// Converts an monetary quantity <see cref="NMoneys.Money"/> to and from JSON.
/// </summary>
/// <remarks>
/// <para>The serialized quantity would look something like: <c>{"Amount": 0, "Currency": "XXX"}</c>.</para>
/// <para>Provides better JSON pointers for deserialization errors: better suited when not in control of serialization.</para>
/// </remarks>
public class DescriptiveMoneyConverter : JsonConverter<Money>
{
private readonly bool _forceStringEnum;

/// <param name="forceStringEnum">Ignore enum value configuration and force string representation of the
/// <see cref="Money.CurrencyCode"/> when serializing.</param>
public DescriptiveMoneyConverter(bool forceStringEnum = false)
{
_forceStringEnum = forceStringEnum;
}

#region write

/// <inheritdoc />
public override void WriteJson([NotNull] JsonWriter writer, Money value, [NotNull] JsonSerializer serializer)
{
DefaultContractResolver? resolver = serializer.ContractResolver as DefaultContractResolver;

writer.WriteStartObject();

writeAmount(value.Amount, writer, resolver);
writeCurrency(value.CurrencyCode, writer, serializer, resolver);

writer.WriteEndObject();
}

private static void writeAmount(decimal amount, JsonWriter writer, DefaultContractResolver? resolver)
{
// non-pascal if "weird" resolver
string amountName = resolver?.GetResolvedPropertyName("Amount") ?? "amount";
writer.WritePropertyName(amountName);
writer.WriteValue(amount);
}

private void writeCurrency(CurrencyIsoCode currency,
JsonWriter writer, JsonSerializer serializer, DefaultContractResolver? resolver)
{
// non-pascal if "weird" resolver
string currencyName = resolver?.GetResolvedPropertyName("Currency") ?? "currency";
writer.WritePropertyName(currencyName);
if (_forceStringEnum)
{
// ignore configured enum value convention, string it is
writer.WriteValue(currency.ToString());
}
else
{
// follow configured enum value convention
serializer.Serialize(writer, currency, typeof(CurrencyIsoCode));
}
}

#endregion

#region read

/// <inheritdoc />
public override Money ReadJson([NotNull] JsonReader reader,
Type objectType, Money existingValue,
bool hasExistingValue, [NotNull] JsonSerializer serializer)
{
DefaultContractResolver? resolver = serializer.ContractResolver as DefaultContractResolver;

// no need to read StartObject token (already read)

decimal amount = readAmount(reader, resolver);
CurrencyIsoCode currency = readCurrency(reader, serializer, resolver);

// but EndObject needs to be read
readEndObject(reader);

return new Money(amount, currency);
}

private static decimal readAmount(JsonReader reader, DefaultContractResolver? resolver)
{
readProperty(reader);
ensurePropertyName("Amount", reader, resolver);
decimal amount = readAmountValue(reader);
return amount;
}

private static CurrencyIsoCode readCurrency(JsonReader reader, JsonSerializer serializer, DefaultContractResolver? resolver)
{
readProperty(reader);
ensurePropertyName("Currency", reader, resolver);
CurrencyIsoCode currency = readCurrencyValue(reader, serializer);
return currency;
}

private static void readEndObject(JsonReader reader)
{
bool read = reader.Read();
if (!read || reader.TokenType != JsonToken.EndObject)
{
throw buildException($"Expected token type '{JsonToken.EndObject}', but got '{reader.TokenType}'.", reader);
}
}

private static JsonSerializationException buildException(string message, JsonReader reader, Exception? inner = null)
{
IJsonLineInfo? info = reader as IJsonLineInfo;
JsonSerializationException exception = (info == null || info.HasLineInfo()) ?
new JsonSerializationException(message) :
new JsonSerializationException(message, reader.Path, info.LineNumber, info.LinePosition, inner);
return exception;
}


private static void readProperty(JsonReader reader)
{
bool isRead = reader.Read();
if (!isRead || reader.TokenType != JsonToken.PropertyName)
{
throw buildException($"Expected token type '{JsonToken.PropertyName}', but got '{reader.TokenType}'.", reader);
}
}

private static void ensurePropertyName(string pascalSingleName, JsonReader reader, DefaultContractResolver? resolver)
{
#pragma warning disable CA1308
string propName = resolver?.GetResolvedPropertyName(pascalSingleName) ?? pascalSingleName.ToLowerInvariant();
#pragma warning restore CA1308
bool matchAmount = StringComparer.Ordinal.Equals(reader.Value, propName);
if (!matchAmount)
{
throw buildException($"Expected property '{propName}', but got '{reader.Value}'.", reader);
}
}

private static decimal readAmountValue(JsonReader reader)
{
try
{
var amount = reader.ReadAsDecimal();
if (!amount.HasValue)
{
throw buildException("Amount should not be nullable.", reader);
}

return amount.Value;
}
catch (Exception ex)
{
throw buildException("Could not read amount value.", reader, ex);
}
}

private static CurrencyIsoCode readCurrencyValue(JsonReader reader, JsonSerializer serializer)
{
bool read = reader.Read();
if (!read)
{
throw buildException("Expected value token type.", reader);
}

CurrencyIsoCode currency = serializer.Deserialize<CurrencyIsoCode>(reader);
ensureDefined(currency, reader);

return currency;
}

private static void ensureDefined(CurrencyIsoCode maybeCurrency, JsonReader reader)
{
try
{
maybeCurrency.AssertDefined();
}
catch (Exception ex)
{
throw buildException($"Currency '{maybeCurrency}' not defined.", reader, ex);
}
}


#endregion
}
Loading
Loading