diff --git a/.gitea/workflows/publish-nuget.yaml b/.gitea/workflows/publish-nuget.yaml index a81be22..5b96bda 100644 --- a/.gitea/workflows/publish-nuget.yaml +++ b/.gitea/workflows/publish-nuget.yaml @@ -22,12 +22,15 @@ jobs: run: dotnet restore Railway/Railway.csproj - name: Setup nuget source - run: dotnet nuget add source --name gitea_registry https://gitea.jstdev.ru/api/packages/just/nuget/index.json + run: dotnet nuget add source --name gitea_registry ${{ vars.OUTPUT_NUGET_REGISTRY }} - name: Create the package env: RELEASE_VERSION: ${{ gitea.ref_name }} - run: dotnet pack --no-restore --configuration Release --output nupkgs Railway/Railway.csproj `echo $RELEASE_VERSION | sed -E 's|^(v([0-9]+(\.[0-9]+){2}))(-([a-z0-9]+)){1}|/p:ReleaseVersion=\2 /p:VersionSuffix=\5|; s|^(v([0-9]+(\.[0-9]+){2}))$|/p:ReleaseVersion=\2|'` + run: > + dotnet pack --no-restore --configuration Release --output nupkgs Railway/Railway.csproj + /p:RepositoryUrl ${{ gitea.server_url }}/${{ gitea.repository }}/ + `echo $RELEASE_VERSION | sed -E 's|^(v([0-9]+(\.[0-9]+){2}))(-([a-z0-9]+)){1}|/p:ReleaseVersion=\2 /p:VersionSuffix=\5|; s|^(v([0-9]+(\.[0-9]+){2}))$|/p:ReleaseVersion=\2|'` - name: Publish the package to Gitea run: dotnet nuget push --source gitea_registry --api-key ${{ secrets.NUGET_PACKAGE_TOKEN }} nupkgs/*.nupkg diff --git a/Railway/Error.cs b/Railway/Error.cs index 9396981..c46a583 100644 --- a/Railway/Error.cs +++ b/Railway/Error.cs @@ -1,14 +1,10 @@ using System.Collections; using System.Collections.Immutable; -using System.Runtime.Serialization; using System.Text; namespace Just.Railway; -[JsonPolymorphic(TypeDiscriminatorPropertyName = "$$err")] -[JsonDerivedType(typeof(ExpectedError), typeDiscriminator: 0)] -[JsonDerivedType(typeof(ExceptionalError), typeDiscriminator: 1)] -[JsonDerivedType(typeof(ManyErrors))] +[JsonConverter(typeof(ErrorJsonConverter))] public abstract class Error : IEquatable, IComparable { protected internal Error(){} @@ -33,16 +29,22 @@ public abstract class Error : IEquatable, IComparable /// /// Error detail [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Error New(string message) => - new ExpectedError("error", message); + public static Error New(string message, IEnumerable>? extensionData = null) => + new ExpectedError("error", message) + { + ExtensionData = extensionData?.ToImmutableDictionary() ?? ImmutableDictionary.Empty + }; /// /// Create an /// /// Error code /// Error detail [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Error New(string type, string message) => - new ExpectedError(type, message); + public static Error New(string type, string message, IEnumerable>? extensionData = null) => + new ExpectedError(type, message) + { + ExtensionData = extensionData?.ToImmutableDictionary() ?? ImmutableDictionary.Empty + }; /// /// Create a /// @@ -50,7 +52,7 @@ public abstract class Error : IEquatable, IComparable [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)] public static Error Many(Error error1, Error error2) => (error1, error2) switch { - (null, null) => new ManyErrors(Enumerable.Empty()), + (null, null) => new ManyErrors([]), (Error err, null) => err, (Error err, { IsEmpty: true }) => err, (null, Error err) => err, @@ -76,13 +78,14 @@ public abstract class Error : IEquatable, IComparable [Pure] public abstract string Type { get; } [Pure] public abstract string Message { get; } - [Pure, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ImmutableDictionary? ExtensionData { get; init; } - [Pure] public string? this[string key] => ExtensionData?.TryGetValue(key, out var value) == true ? value : null; - [Pure, JsonIgnore] public abstract int Count { get; } - [Pure, JsonIgnore] public abstract bool IsEmpty { get; } - [Pure, JsonIgnore] public abstract bool IsExpected { get; } - [Pure, JsonIgnore] public abstract bool IsExeptional { get; } + [Pure] public ImmutableDictionary ExtensionData { get; internal init; } = ImmutableDictionary.Empty; + [Pure] public string? this[string key] => ExtensionData.TryGetValue(key, out var value) == true ? value : null; + + [Pure] public abstract int Count { get; } + [Pure] public abstract bool IsEmpty { get; } + [Pure] public abstract bool IsExpected { get; } + [Pure] public abstract bool IsExeptional { get; } [Pure] public Error Append(Error? next) { @@ -142,9 +145,9 @@ public abstract class Error : IEquatable, IComparable } } +[JsonConverter(typeof(ExpectedErrorJsonConverter))] public sealed class ExpectedError : Error { - [JsonConstructor] public ExpectedError(string type, string message) { Type = type; @@ -158,10 +161,10 @@ public sealed class ExpectedError : Error [Pure] public override string Type { get; } [Pure] public override string Message { get; } - [Pure, JsonIgnore] public override int Count => 1; - [Pure, JsonIgnore] public override bool IsEmpty => false; - [Pure, JsonIgnore] public override bool IsExpected => true; - [Pure, JsonIgnore] public override bool IsExeptional => false; + [Pure] public override int Count => 1; + [Pure] public override bool IsEmpty => false; + [Pure] public override bool IsExpected => true; + [Pure] public override bool IsExeptional => false; [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)] public override IEnumerable ToEnumerable() @@ -170,24 +173,24 @@ public sealed class ExpectedError : Error } } +[JsonConverter(typeof(ExceptionalErrorJsonConverter))] public sealed class ExceptionalError : Error { internal readonly Exception? Exception; internal ExceptionalError(Exception exception) - : this(exception.GetType().Name, exception.Message) + : this(exception.GetType().FullName ?? exception.GetType().Name, exception.Message) { Exception = exception; ExtensionData = ExtractExtensionData(exception); } internal ExceptionalError(string message, Exception exception) - : this(exception.GetType().Name, message) + : this(exception.GetType().FullName ?? exception.GetType().Name, message) { Exception = exception; ExtensionData = ExtractExtensionData(exception); } - [JsonConstructor] public ExceptionalError(string type, string message) { Type = type; @@ -197,10 +200,10 @@ public sealed class ExceptionalError : Error [Pure] public override string Type { get; } [Pure] public override string Message { get; } - [Pure, JsonIgnore] public override int Count => 1; - [Pure, JsonIgnore] public override bool IsEmpty => false; - [Pure, JsonIgnore] public override bool IsExpected => false; - [Pure, JsonIgnore] public override bool IsExeptional => true; + [Pure] public override int Count => 1; + [Pure] public override bool IsEmpty => false; + [Pure] public override bool IsExpected => false; + [Pure] public override bool IsExeptional => true; [Pure] public override Exception ToException() => Exception ?? base.ToException(); @@ -210,10 +213,10 @@ public sealed class ExceptionalError : Error yield return this; } - private static ImmutableDictionary? ExtractExtensionData(Exception exception) + private static ImmutableDictionary ExtractExtensionData(Exception exception) { if (!(exception.Data?.Count > 0)) - return null; + return ImmutableDictionary.Empty; List>? values = null; @@ -231,16 +234,17 @@ public sealed class ExceptionalError : Error values ??= []; values.Add(new(keyString, valueString)); } - return values?.ToImmutableDictionary(); + return values?.ToImmutableDictionary() ?? ImmutableDictionary.Empty; } } -[DataContract] -public sealed class ManyErrors : Error, IEnumerable +[JsonConverter(typeof(ManyErrorsJsonConverter))] +public sealed class ManyErrors : Error, IEnumerable, IReadOnlyList { private readonly List _errors; - [Pure, DataMember] public IEnumerable Errors { get => _errors; } + [Pure] public IEnumerable Errors { get => _errors; } + internal ManyErrors(List errors) => _errors = errors; internal ManyErrors(Error head, Error tail) { _errors = new List(head.Count + tail.Count); @@ -283,9 +287,11 @@ public sealed class ManyErrors : Error, IEnumerable } [Pure] public override int Count => _errors.Count; - [Pure, JsonIgnore] public override bool IsEmpty => _errors.Count == 0; - [Pure, JsonIgnore] public override bool IsExpected => _errors.All(static x => x.IsExpected); - [Pure, JsonIgnore] public override bool IsExeptional => _errors.Any(static x => x.IsExeptional); + [Pure] public override bool IsEmpty => _errors.Count == 0; + [Pure] public override bool IsExpected => _errors.All(static x => x.IsExpected); + [Pure] public override bool IsExeptional => _errors.Any(static x => x.IsExeptional); + + [Pure] public Error this[int index] => _errors[index]; [Pure] public override Exception ToException() => new AggregateException(_errors.Select(static x => x.ToException())); [Pure] public override IEnumerable ToEnumerable() => _errors; diff --git a/Railway/ErrorJsonConverter.cs b/Railway/ErrorJsonConverter.cs new file mode 100644 index 0000000..8842c2d --- /dev/null +++ b/Railway/ErrorJsonConverter.cs @@ -0,0 +1,182 @@ +using System.Collections.Immutable; + +namespace Just.Railway; + +public sealed class ErrorJsonConverter : JsonConverter +{ + public override Error? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.StartObject => ToExpectedError(ReadOne(ref reader)), + JsonTokenType.StartArray => ReadMany(ref reader), + JsonTokenType.None => null, + JsonTokenType.Null => null, + _ => throw new JsonException("Unexpected JSON token.") + }; + } + + public override void Write(Utf8JsonWriter writer, Error value, JsonSerializerOptions options) + { + if (value is ManyErrors manyErrors) + { + writer.WriteStartArray(); + foreach (var err in manyErrors) + { + WriteOne(writer, err); + } + writer.WriteEndArray(); + } + else + { + WriteOne(writer, value); + } + } + + internal static ManyErrors ReadMany(ref Utf8JsonReader reader) + { + List errors = []; + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.StartObject) + { + errors.Add(ToExpectedError(ReadOne(ref reader))); + } + } + return new ManyErrors(errors); + } + internal static ExpectedError ToExpectedError(in (string Type, string Message, ImmutableDictionary ExtensionData) errorInfo) + => new(errorInfo.Type, errorInfo.Message) { ExtensionData = errorInfo.ExtensionData }; + internal static (string Type, string Message, ImmutableDictionary ExtensionData) ReadOne(ref Utf8JsonReader reader) + { + List>? extensionData = null; + string type = "error"; + string message = ""; + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.PropertyName: + { + var propname = reader.GetString(); + reader.Read(); + + if (reader.TokenType == JsonTokenType.Null) + break; + + while (reader.TokenType == JsonTokenType.Comment) reader.Read(); + + if (!(reader.TokenType == JsonTokenType.String)) + throw new JsonException("Unable to deserialize Error type."); + + var propvalue = reader.GetString(); + if (string.IsNullOrEmpty(propvalue)) + break; + + if (propname == "type" || string.Equals(propname, "type", StringComparison.InvariantCultureIgnoreCase)) + { + type = propvalue; + } + else if (propname == "msg" || string.Equals(propname, "msg", StringComparison.InvariantCultureIgnoreCase)) + { + message = propvalue; + } + else if (!string.IsNullOrEmpty(propname)) + { + extensionData ??= []; + extensionData.Add(new(propname, propvalue)); + } + + break; + } + case JsonTokenType.Comment: break; + case JsonTokenType.EndObject: goto endLoop; + default: throw new JsonException("Unable to deserialize Error type."); + } + } + endLoop: + return (type, message, extensionData?.ToImmutableDictionary() ?? ImmutableDictionary.Empty); + } + internal static void WriteOne(Utf8JsonWriter writer, Error value) + { + writer.WriteStartObject(); + + writer.WriteString("type", value.Type); + writer.WriteString("msg", value.Message); + + if (value.ExtensionData?.Count > 0) + { + foreach (var (key, val) in value.ExtensionData) + { + writer.WriteString(key, val); + } + } + + writer.WriteEndObject(); + } +} + +public sealed class ExpectedErrorJsonConverter : JsonConverter +{ + public override ExpectedError? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.StartObject => ErrorJsonConverter.ToExpectedError(ErrorJsonConverter.ReadOne(ref reader)), + JsonTokenType.None => null, + JsonTokenType.Null => null, + _ => throw new JsonException("Unexpected JSON token.") + }; + } + + public override void Write(Utf8JsonWriter writer, ExpectedError value, JsonSerializerOptions options) + { + ErrorJsonConverter.WriteOne(writer, value); + } +} + +public sealed class ExceptionalErrorJsonConverter : JsonConverter +{ + public override ExceptionalError? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.StartObject => ToExceptionalError(ErrorJsonConverter.ReadOne(ref reader)), + JsonTokenType.None => null, + JsonTokenType.Null => null, + _ => throw new JsonException("Unexpected JSON token.") + }; + } + + public override void Write(Utf8JsonWriter writer, ExceptionalError value, JsonSerializerOptions options) + { + ErrorJsonConverter.WriteOne(writer, value); + } + + private static ExceptionalError ToExceptionalError(in (string Type, string Message, ImmutableDictionary ExtensionData) errorInfo) + => new(errorInfo.Type, errorInfo.Message) { ExtensionData = errorInfo.ExtensionData }; +} + +public sealed class ManyErrorsJsonConverter : JsonConverter +{ + public override ManyErrors? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.StartArray => ErrorJsonConverter.ReadMany(ref reader), + JsonTokenType.None => null, + JsonTokenType.Null => null, + _ => throw new JsonException("Unexpected JSON token.") + }; + } + + public override void Write(Utf8JsonWriter writer, ManyErrors value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var err in value) + { + ErrorJsonConverter.WriteOne(writer, err); + } + writer.WriteEndArray(); + } +} diff --git a/Railway/Railway.csproj b/Railway/Railway.csproj index 6f45fc8..929e9ec 100644 --- a/Railway/Railway.csproj +++ b/Railway/Railway.csproj @@ -7,10 +7,11 @@ Just.Railway Just.Railway + Base for railway-oriented programming in .NET. Package includes Result object, Error class and most of the common extensions. + railway-oriented;functional;result-pattern;result-object;error-handling JustFixMe Copyright (c) 2023 JustFixMe MIT - https://gitea.jstdev.ru/just/Just.Railway/ true 1.0.0 diff --git a/Raliway.Tests/Errors/Serialization.cs b/Raliway.Tests/Errors/Serialization.cs index 1d8e13f..30dda86 100644 --- a/Raliway.Tests/Errors/Serialization.cs +++ b/Raliway.Tests/Errors/Serialization.cs @@ -1,5 +1,3 @@ -using System.Collections.Immutable; - namespace Railway.Tests.Errors; public class Serialization @@ -8,35 +6,64 @@ public class Serialization public void WhenSerializingManyErrors() { // Given - Error many_errors = new ManyErrors(new Error[]{ - new ExpectedError("err1", "msg1"){ - ExtensionData = ImmutableDictionary.Empty - .Add("ext", "ext_value"), - }, - new ExceptionalError(new Exception("msg2")), - }); + Error many_errors = new ManyErrors( + [ + Error.New("err1", "msg1", new KeyValuePair[] + { + new("ext", "ext_value"), + }), + Error.New(new Exception("msg2")), + ]); // When var result = JsonSerializer.Serialize(many_errors); // Then Assert.Equal( - expected: "[{\"$$err\":0,\"Type\":\"err1\",\"Message\":\"msg1\",\"ExtensionData\":{\"ext\":\"ext_value\"}},{\"$$err\":1,\"Type\":\"Exception\",\"Message\":\"msg2\"}]", + expected: "[{\"type\":\"err1\",\"msg\":\"msg1\",\"ext\":\"ext_value\"},{\"type\":\"System.Exception\",\"msg\":\"msg2\"}]", result); } [Fact] - public void WhenDeserializingManyErrors() + public void WhenDeserializingManyErrorsAsError() { // Given - var json = "[{\"$$err\":0,\"Type\":\"err1\",\"Message\":\"msg1\",\"ExtensionData\":{\"ext1\":\"ext_value1\",\"ext2\":\"ext_value2\"}},{\"$$err\":1,\"Type\":\"Exception\",\"Message\":\"msg2\"}]"; + var json = "[{\"type\":\"err1\",\"msg\":\"msg1\",\"ext1\":\"ext_value1\",\"ext2\":\"ext_value2\"},{\"type\":\"System.Exception\",\"msg\":\"msg2\"}]"; // When - var result = JsonSerializer.Deserialize(json); + var result = JsonSerializer.Deserialize(json); // Then - Assert.True(result?.Length == 2); + Assert.IsType(result); + ManyErrors manyErrors = (ManyErrors)result; + + Assert.True(manyErrors.Count == 2); Assert.Equal( - expected: new ManyErrors(new Error[]{ - new ExpectedError("err1", "msg1"), - new ExceptionalError(new Exception("msg2")), - }), + expected: Error.Many( + Error.New("err1", "msg1"), + Error.New(new Exception("msg2")) + ).ToEnumerable(), + manyErrors + ); + Assert.Equal( + expected: "ext_value1", + manyErrors[0]["ext1"]); + Assert.Equal( + expected: "ext_value2", + manyErrors[0]["ext2"]); + } + + [Fact] + public void WhenDeserializingManyErrorsAsManyErrors() + { + // Given + var json = "[{\"type\":\"err1\",\"msg\":\"msg1\",\"ext1\":\"ext_value1\",\"ext2\":\"ext_value2\"},{\"type\":\"System.Exception\",\"msg\":\"msg2\"}]"; + // When + var result = JsonSerializer.Deserialize(json); + // Then + Assert.NotNull(result); + Assert.True(result.Count == 2); + Assert.Equal( + expected: Error.Many( + Error.New("err1", "msg1"), + Error.New(new Exception("msg2")) + ).ToEnumerable(), result ); Assert.Equal(