Skip to content

Commit

Permalink
Json Serialization and Deserialization (#22)
Browse files Browse the repository at this point in the history
* added support for serializing `Result` and `Result<T>` to JSON
* added support for deserializing`Result` and `Result<T>` from JSON
* added unit test to cover JSON serializing and deserializing
  • Loading branch information
skrasekmichael committed Apr 17, 2024
1 parent 76941d2 commit dadfa6f
Show file tree
Hide file tree
Showing 18 changed files with 586 additions and 18 deletions.
31 changes: 31 additions & 0 deletions src/RailwayResult/Helpers/TypeDefinition.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Diagnostics.CodeAnalysis;

namespace RailwayResult.Helpers;

internal record TypeDefinition(string AssemblyName, string TypeName) : ISpanParsable<TypeDefinition>
{
public TypeDefinition(Type type) : this(type.Assembly.GetName().Name!, type.FullName!) { }

public static TypeDefinition Parse(ReadOnlySpan<char> s, IFormatProvider? provider)
{
Span<Range> range = stackalloc Range[2];
s.Split(range, '/');

var assemblyName = s[range[0]];
var typeName = s[range[1]];

return new TypeDefinition(assemblyName.ToString(), typeName.ToString());
}

public static TypeDefinition Parse(string s, IFormatProvider? provider) => Parse(s.AsSpan(), provider);

public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider? provider, [MaybeNullWhen(false)] out TypeDefinition result)
{
result = Parse(s, provider);
return true;
}

public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TypeDefinition result) => TryParse(s.AsSpan(), provider, out result);

public override string ToString() => $"{AssemblyName}/{TypeName}";
}
80 changes: 80 additions & 0 deletions src/RailwayResult/Helpers/Utf8JsonReaderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace RailwayResult.Helpers;

internal static class Utf8JsonReaderExtensions
{
public static void ReadOrThrow(ref this Utf8JsonReader reader, JsonTokenType type, string? errorMessage = null)
{
reader.Read();
if (reader.TokenType != type)
{
throw new JsonException(errorMessage);
}
}

public static string? ReadPropertyName(ref this Utf8JsonReader reader, params string[] expectedParams)
{
reader.Read();
var name = reader.GetString();
if (reader.TokenType != JsonTokenType.PropertyName || !expectedParams.Contains(name))
{
throw new JsonException($"Expected one of '{string.Join("', '", expectedParams)}'.");
}

return name;
}

public static void ReadPropertyName(ref this Utf8JsonReader reader, string expectedPropertyName)
{
reader.Read();
var name = reader.GetString();
if (reader.TokenType != JsonTokenType.PropertyName || name != expectedPropertyName)
{
throw new JsonException($"Expected property '{expectedPropertyName}'.");
}
}

public static string ReadStringValue(ref this Utf8JsonReader reader, string? errorMessage = null)
{
reader.Read();
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException(errorMessage);
}

return reader.GetString()!;
}

public static Type ReadErrorType(ref this Utf8JsonReader reader, string? errorMessage = null)
{
reader.Read();
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException(errorMessage);
}

var typeDefinitionString = reader.GetString()!;
var typeDefinition = TypeDefinition.Parse(typeDefinitionString, default);

try
{
var assembly = Assembly.Load(typeDefinition.AssemblyName);
return assembly.GetType(typeDefinition.TypeName)!;
}
catch
{
throw new JsonException($"Failed to load error type [{typeDefinitionString}].");
}
}

public static Error ReadError(ref this Utf8JsonReader reader, Type valueType, JsonSerializerOptions options)
{
reader.Read();
var converter = Unsafe.As<JsonConverter<Error>>(options.GetConverter(valueType));
return converter.Read(ref reader, valueType, options)!;
}
}
14 changes: 14 additions & 0 deletions src/RailwayResult/Helpers/Utf8JsonWriterExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace RailwayResult.Helpers;

internal static class Utf8JsonWriterExtensions
{
public static void WriteError(this Utf8JsonWriter writer, Error error, Type valueType, JsonSerializerOptions options)
{
var converter = Unsafe.As<JsonConverter<Error>>(options.GetConverter(valueType));
converter.Write(writer, error, options);
}
}
72 changes: 72 additions & 0 deletions src/RailwayResult/JsonConverters/GenericResultJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Text.Json;
using System.Text.Json.Serialization;

using RailwayResult.Helpers;

namespace RailwayResult.JsonConverters;

internal sealed class GenericResultJsonConverter<T> : JsonConverter<Result<T>>
{
internal const string JSON_VALUE = "Value";
internal const string JSON_ERROR_TYPE = "ErrorType";
internal const string JSON_ERROR = "Error";

private static readonly Type ValueType = typeof(T);

private readonly JsonConverter<T> _valueConverter;

public GenericResultJsonConverter(JsonSerializerOptions options)
{
_valueConverter = (JsonConverter<T>)options.GetConverter(typeof(T));
}

public override Result<T>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException("Expected '{'.");
}

var name = reader.ReadPropertyName(JSON_ERROR_TYPE, JSON_VALUE);
Result<T>? result;

if (name == JSON_ERROR_TYPE)
{
var errorType = reader.ReadErrorType("Expected error type definition.");

reader.ReadPropertyName(JSON_ERROR);
var error = reader.ReadError(errorType, options);

result = new(error);
}
else
{
reader.Read();
result = _valueConverter.Read(ref reader, ValueType, options);
}

reader.ReadOrThrow(JsonTokenType.EndObject, "Expected '}'.");
return result;
}

public override void Write(Utf8JsonWriter writer, Result<T> value, JsonSerializerOptions options)
{
writer.WriteStartObject();

if (value!.IsSuccess)
{
writer.WritePropertyName(JSON_VALUE);
_valueConverter.Write(writer, value.Value, options);
}
else
{
var errorType = value.Error.GetType();
writer.WriteString(JSON_ERROR_TYPE, new TypeDefinition(errorType).ToString());

writer.WritePropertyName(JSON_ERROR);
writer.WriteError(value.Error, errorType, options);
}

writer.WriteEndObject();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace RailwayResult.JsonConverters;

internal sealed class GenericResultJsonConverterFactory : JsonConverterFactory
{
private static readonly Type GenericResultType = typeof(Result<>);
private static readonly Type GenericResultConverterType = typeof(GenericResultJsonConverter<>);

public override bool CanConvert(Type typeToConvert)
{
return typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == GenericResultType;
}

public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
var nestedType = typeToConvert.GetGenericArguments()[0];
var converterType = GenericResultConverterType.MakeGenericType(nestedType);
var flags = BindingFlags.Instance | BindingFlags.Public;
return Activator.CreateInstance(converterType, flags, null, [options], null) as JsonConverter;
}
}
62 changes: 62 additions & 0 deletions src/RailwayResult/JsonConverters/ResultJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Text.Json;
using System.Text.Json.Serialization;

using RailwayResult.Helpers;

namespace RailwayResult.JsonConverters;

internal sealed class ResultJsonConverter : JsonConverter<Result>
{
internal const string JSON_ERROR_TYPE = "Type";
internal const string JSON_ERROR = "Error";

public override Result? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException("Expected '{'.");
}

var name = reader.ReadPropertyName(JSON_ERROR_TYPE, JSON_ERROR);
Result? result;

if (name == JSON_ERROR_TYPE)
{
var errorType = reader.ReadErrorType("Expected error type definition.");

reader.ReadPropertyName(JSON_ERROR);
var error = reader.ReadError(errorType, options);

result = new(error);
}
else
{
reader.ReadOrThrow(JsonTokenType.Null, "Expected null, failure Result has to have specified error type.");
result = Result.Success;
}

reader.ReadOrThrow(JsonTokenType.EndObject, "Expected '}'.");
return result;
}

public override void Write(Utf8JsonWriter writer, Result value, JsonSerializerOptions options)
{
writer.WriteStartObject();

if (value.IsSuccess)
{
writer.WritePropertyName(JSON_ERROR);
writer.WriteNullValue();
}
else
{
var errorType = value.Error.GetType();
writer.WriteString(JSON_ERROR_TYPE, new TypeDefinition(errorType).ToString());

writer.WritePropertyName(JSON_ERROR);
writer.WriteError(value.Error, errorType, options);
}

writer.WriteEndObject();
}
}
6 changes: 5 additions & 1 deletion src/RailwayResult/Result.Generic.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
using RailwayResult.Exceptions;
using System.Text.Json.Serialization;

using RailwayResult.Exceptions;
using RailwayResult.JsonConverters;

namespace RailwayResult;

[JsonConverter(typeof(GenericResultJsonConverterFactory))]
public sealed class Result<TValue> : IResult<TValue>
{
public bool IsSuccess => _error is null;
Expand Down
8 changes: 6 additions & 2 deletions src/RailwayResult/Result.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
using RailwayResult.Exceptions;
using System.Text.Json.Serialization;

using RailwayResult.Exceptions;
using RailwayResult.JsonConverters;

namespace RailwayResult;

[JsonConverter(typeof(ResultJsonConverter))]
public sealed class Result : IResult
{
public bool IsSuccess { get; }
Expand All @@ -14,7 +18,7 @@ public sealed class Result : IResult

public Result(Error error)
{
IsSuccess = error is null;
IsSuccess = false;
_error = error;
}

Expand Down
31 changes: 31 additions & 0 deletions tests/RailwayResult.Tests/Extensions/ResultExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace RailwayResult.Tests.Extensions;

public static class ResultExtensions
{
public static void ShouldBe(this Result self, Result expected)
{
if (self!.IsSuccess)
{
expected.IsSuccess.Should().BeTrue();
}
else
{
expected.IsSuccess.Should().BeFalse();
self.Error.Should().BeEquivalentTo(expected.Error);
}
}

public static void ShouldBe<T>(this Result<T> self, Result<T> expected)
{
if (self.IsSuccess)
{
expected.IsSuccess.Should().BeTrue();
self.Value.Should().BeEquivalentTo(expected.Value);
}
else
{
expected.IsSuccess.Should().BeFalse();
self.Error.Should().BeEquivalentTo(expected.Error);
}
}
}
6 changes: 6 additions & 0 deletions tests/RailwayResult.Tests/Mocks/BasicError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace RailwayResults.Tests.Mocks;

public sealed record BasicError(string Key, string Message) : Error(Key, Message)
{
public static readonly BasicError ErrorA = new("Key", "Error A");
}
19 changes: 19 additions & 0 deletions tests/RailwayResult.Tests/Mocks/ComplexError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using static RailwayResults.Tests.Mocks.ComplexError;

namespace RailwayResults.Tests.Mocks;

public sealed record ComplexError(
string Key,
string Message,
string AdditionalString,
int AdditionalInt,
NestedRecord Record,
List<NestedRecord> Records) : Error(Key, Message)
{
public static readonly ComplexError One = new("key", "msg", "one", 1, new(2, "two"), [
new(3, "three"),
new(4, "four")
]);

public record NestedRecord(int A, string B);
}
Loading

0 comments on commit dadfa6f

Please sign in to comment.