diff --git a/Discord.Net.sln b/Discord.Net.sln index c52902b9b..ea814bbc9 100644 --- a/Discord.Net.sln +++ b/Discord.Net.sln @@ -11,6 +11,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sample", "sample", "{479564 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "idn", "sample\idn\idn.csproj", "{5BE5DE89-53B7-4243-AEA8-FD8A6420908A}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{68EE1EAC-F487-4BAC-917B-233370B3AEA1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Tests.Unit", "test\Discord.Tests.Unit\Discord.Tests.Unit.csproj", "{6AD4FF67-D45E-4E7E-8853-990390D35C9F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -48,9 +52,22 @@ Global {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Release|x64.Build.0 = Release|Any CPU {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Release|x86.ActiveCfg = Release|Any CPU {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Release|x86.Build.0 = Release|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Debug|x64.ActiveCfg = Debug|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Debug|x64.Build.0 = Debug|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Debug|x86.ActiveCfg = Debug|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Debug|x86.Build.0 = Debug|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Release|Any CPU.Build.0 = Release|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Release|x64.ActiveCfg = Release|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Release|x64.Build.0 = Release|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Release|x86.ActiveCfg = Release|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {3194F5DC-C0AF-4459-AAA3-91CB8FB8C370} = {5DAC796B-0B77-4F84-B790-83DB78C6DFFE} {5BE5DE89-53B7-4243-AEA8-FD8A6420908A} = {4795640A-030C-4A9A-A9B0-20C56AF4DA3F} + {6AD4FF67-D45E-4E7E-8853-990390D35C9F} = {68EE1EAC-F487-4BAC-917B-233370B3AEA1} EndGlobalSection EndGlobal diff --git a/src/Discord.Net/Rest/DiscordRestApi.cs b/src/Discord.Net/Rest/DiscordRestApi.cs index 94395fb29..3681a7014 100644 --- a/src/Discord.Net/Rest/DiscordRestApi.cs +++ b/src/Discord.Net/Rest/DiscordRestApi.cs @@ -1,10 +1,10 @@ using System.Text.Json; using System.Threading.Tasks; -using Refit; -using Discord.Models; using System.Net.Http.Headers; -using System; using System.Net.Http; +using Refit; +using Discord.Models; +using Discord.Serialization; // This is essentially a reimplementation of Wumpus.Net.Rest namespace Discord.Rest @@ -26,6 +26,7 @@ namespace Discord.Rest }; var jsonOptions = new JsonSerializerOptions(); + jsonOptions.Converters.Add(new OptionalConverter()); var refitSettings = new RefitSettings { ContentSerializer = new JsonContentSerializer(jsonOptions), diff --git a/src/Discord.Net/Serialization/OptionalConverter.cs b/src/Discord.Net/Serialization/OptionalConverter.cs index 55b988ea1..375dd60d5 100644 --- a/src/Discord.Net/Serialization/OptionalConverter.cs +++ b/src/Discord.Net/Serialization/OptionalConverter.cs @@ -1,22 +1,41 @@ using System; -using System.Collections.Generic; -using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace Discord.Serialization { - // 😅 - public class OptionalConverter : JsonConverter> + // TODO: This does not allow us to omit properties at runtime + // Need to evaluate which cases need us to omit properties and write a separate converter + // for those. At this time I can only think of the outgoing REST PATCH requests. Incoming + // omitted properties will be correctly treated as Optional.Unspecified (the default) + public class OptionalConverter : JsonConverterFactory { - public override Optional Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + private class OptionalTypeConverter : JsonConverter> { - throw new NotImplementedException(); + public override Optional Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return Optional.Unspecified; + else + return new Optional(JsonSerializer.Deserialize(ref reader, options)); + } + + public override void Write(Utf8JsonWriter writer, Optional value, JsonSerializerOptions options) + { + if (!value.IsSpecified) + writer.WriteNullValue(); + else + JsonSerializer.Serialize(writer, value.Value, options); + } } - public override void Write(Utf8JsonWriter writer, Optional value, JsonSerializerOptions options) + public override bool CanConvert(Type typeToConvert) + => typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - throw new NotImplementedException(); + var innerType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalTypeConverter<>).MakeGenericType(innerType); + return (JsonConverter)Activator.CreateInstance(converterType); } } } diff --git a/src/Discord.Net/Utilities/Optional.cs b/src/Discord.Net/Utilities/Optional.cs index a87cc2f7a..f61560764 100644 --- a/src/Discord.Net/Utilities/Optional.cs +++ b/src/Discord.Net/Utilities/Optional.cs @@ -6,7 +6,57 @@ namespace Discord { public struct Optional { - public bool IsSpecified { get; private set; } - public T Value { get; set; } + public static Optional Unspecified => default; + + public bool IsSpecified { get; } + private readonly T _innerValue; + + public T Value + { + get + { + if (!IsSpecified) + throw new UnspecifiedOptionalException(); + return _innerValue; + } + } + + public Optional(T value) + { + IsSpecified = true; + _innerValue = value; + } + + public override string ToString() + { + return $""; + } + + public override bool Equals(object obj) + { + if (obj is Optional opt) + { + if (IsSpecified && opt.IsSpecified) + return Value?.Equals(opt.Value) ?? opt.Value == null; + return IsSpecified == opt.IsSpecified; + } + return base.Equals(obj); + } + + public override int GetHashCode() + => IsSpecified ? Value?.GetHashCode() ?? 0 : 0; + + public static bool operator ==(Optional a, Optional b) + => a.Equals(b); + public static bool operator !=(Optional a, Optional b) + => !a.Equals(b); + + // todo: implement comparing, GetValueOrDefault, hash codes etc + } + + + public class UnspecifiedOptionalException : Exception + { + public UnspecifiedOptionalException() : base("An attempt was made to access an unspecified optional value") { } } } diff --git a/test/Discord.Tests.Unit/Discord.Tests.Unit.csproj b/test/Discord.Tests.Unit/Discord.Tests.Unit.csproj new file mode 100644 index 000000000..0a4bb8e5e --- /dev/null +++ b/test/Discord.Tests.Unit/Discord.Tests.Unit.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp3.1 + + false + + + + + + + + + + + + + + diff --git a/test/Discord.Tests.Unit/Serialization/OptionalConverterTests.cs b/test/Discord.Tests.Unit/Serialization/OptionalConverterTests.cs new file mode 100644 index 000000000..f606bc94b --- /dev/null +++ b/test/Discord.Tests.Unit/Serialization/OptionalConverterTests.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Xunit; +using Discord.Serialization; + +namespace Discord.Tests.Unit.Serialization +{ + public class OptionalConverterTests + { + private readonly JsonSerializerOptions _jsonOptions; + + public OptionalConverterTests() + { + _jsonOptions = new JsonSerializerOptions(); + _jsonOptions.Converters.Add(new OptionalConverter()); + } + + public class SampleOptionalClass + { + [JsonPropertyName("optional_number")] + public Optional OptionalNumber { get; set; } + [JsonPropertyName("required_number")] + public int RequiredNumber { get; set; } + + public override bool Equals(object obj) + => (obj is SampleOptionalClass other) && (other.OptionalNumber == OptionalNumber && other.RequiredNumber == RequiredNumber); + public override int GetHashCode() + => OptionalNumber.GetHashCode() ^ RequiredNumber.GetHashCode(); + } + + private string expectedOptionalUnset = "{\"optional_number\":null,\"required_number\":10}"; + private SampleOptionalClass withOptionalUnset = new SampleOptionalClass + { + OptionalNumber = Optional.Unspecified, + RequiredNumber = 10, + }; + private string expectedOptionalSet = "{\"optional_number\":11,\"required_number\":10}"; + private SampleOptionalClass withOptionalSet = new SampleOptionalClass + { + OptionalNumber = new Optional(11), + RequiredNumber = 10, + }; + + [Fact] + public void OptionalConverter_Can_Write() + { + // todo: is STJ deterministic in writing order? want to make sure this test doesn't fail because of cosmic rays + var unsetString = JsonSerializer.Serialize(withOptionalUnset, _jsonOptions); + Assert.Equal(expectedOptionalUnset, unsetString); + + var setString = JsonSerializer.Serialize(withOptionalSet, _jsonOptions); + Assert.Equal(expectedOptionalSet, setString); + } + + [Fact] + public void OptionalConverter_Can_Read() + { + var unset = JsonSerializer.Deserialize(expectedOptionalUnset, _jsonOptions); + Assert.Equal(withOptionalUnset, unset); + + var set = JsonSerializer.Deserialize(expectedOptionalSet, _jsonOptions); + Assert.Equal(withOptionalSet, set); + } + + public class NestedPoco + { + [JsonPropertyName("name")] + public string Name { get; set; } + [JsonPropertyName("age")] + public int Age { get; set; } + + public override bool Equals(object obj) + => (obj is NestedPoco other) && (Name == other.Name && Age == other.Age); + public override int GetHashCode() + => Name.GetHashCode() ^ Age.GetHashCode(); + + } + public class NestedSampleClass + { + [JsonPropertyName("nested")] + public Optional Nested { get; set; } + } + + private string expectedNestedWithUnset = "{\"nested\":null}"; + private NestedSampleClass nestedWithUnset = new NestedSampleClass + { + Nested = Optional.Unspecified + }; + private string expectedNestedWithSet = "{\"nested\":{\"name\":\"Ashley\",\"age\":23}}"; + private NestedSampleClass nestedWithSet = new NestedSampleClass + { + Nested = new Optional(new NestedPoco + { + Name = "Ashley", + Age = 23 + }), + }; + + [Fact] + public void OptionalConverter_Can_Write_Nested_Poco() + { + var unset = JsonSerializer.Serialize(nestedWithUnset, _jsonOptions); + Assert.Equal(expectedNestedWithUnset, unset); + + var set = JsonSerializer.Serialize(nestedWithSet, _jsonOptions); + Assert.Equal(expectedNestedWithSet, set); + } + [Fact] + public void OptionalConverter_Can_Read_Nested_Poco() + { + var unset = JsonSerializer.Deserialize(expectedNestedWithUnset, _jsonOptions); + Assert.Equal(nestedWithUnset.Nested, unset.Nested); + + var set = JsonSerializer.Deserialize(expectedNestedWithSet, _jsonOptions); + Assert.Equal(nestedWithSet.Nested, set.Nested); + } + } +} diff --git a/test/Discord.Tests.Unit/UnitTest1.cs b/test/Discord.Tests.Unit/UnitTest1.cs new file mode 100644 index 000000000..547a0b053 --- /dev/null +++ b/test/Discord.Tests.Unit/UnitTest1.cs @@ -0,0 +1,13 @@ +using Xunit; + +namespace Discord.Tests.Unit +{ + public class UnitTest1 + { + [Fact] + public void Test1() + { + Assert.True(true); + } + } +}