Skip to content

Commit

Permalink
Merge pull request #69 from dgg/feature/json-serialization
Browse files Browse the repository at this point in the history
JSON serialization (plus EFCore)
  • Loading branch information
dgg committed Jan 28, 2024
2 parents 6b51e2d + d9dd583 commit 6444777
Show file tree
Hide file tree
Showing 43 changed files with 4,427 additions and 324 deletions.
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

0 comments on commit 6444777

Please sign in to comment.