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 and Deserialization #22

Merged
merged 1 commit into from
Apr 17, 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
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
Loading