Skip to content

Commit f9f5414

Browse files
authored
JSON Serialization Support (#13)
* Adding Option.ParseEnum * JSON serialization unit tests * OptionJsonConverter * ResultJsonConverter * Accurately reflecting that a default instance of Result may have a null Err value. * Updating README * NumericOptionJsonConverter * Updating version number
1 parent 12229d8 commit f9f5414

20 files changed

+1277
-673
lines changed

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,44 @@ Option and Result types for C#, inspired by Rust
44
[![CI](https://github.com/jtmueller/RustyOptions/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/jtmueller/RustyOptions/actions/workflows/build-and-test.yml)
55
[![CodeQL](https://github.com/jtmueller/RustyOptions/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/jtmueller/RustyOptions/actions/workflows/codeql-analysis.yml)
66
[![codecov](https://codecov.io/gh/jtmueller/RustyOptions/branch/main/graph/badge.svg?token=M81EJH4ZEI)](https://codecov.io/gh/jtmueller/RustyOptions)
7+
8+
9+
## Avoid Null-Reference Errors
10+
11+
The C# [nullable reference types](https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references) feature is useful,
12+
but it's entirely optional, and the warnings it produces are easily ignored.
13+
14+
RustyOptions uses the type system to:
15+
- Make it impossible to access a possibly-missing value without first checking if the value is present.
16+
- Clearly express your intent in the API you build. If a method might not return a value, or might return an error message instead of a value, you can express this in the return type where it can't be missed.
17+
18+
19+
## Safely Chain Together Fallible Methods
20+
21+
```csharp
22+
var output = Option.Parse<int>(userInput)
23+
.AndThen(ValidateRange)
24+
.OrElse(() => defaultsByGroup.GetValueOrNone(user.GroupId))
25+
.MapOr(PillWidget.Render, string.Empty);
26+
```
27+
28+
The example above does the following:
29+
1. Attempts to parse the user input into an integer.
30+
2. If the parsing succeeds, passes the resulting number to the `ValidateRange` method, which returns `Some(parsedInput)`
31+
if the parsed input is within the valid range, or `None` if it falls outside the valid range.
32+
3. If either steps 1 or 2 fail, we attempt to do a dictionary lookup to get a default value using the current user's group ID.
33+
4. If at the end we have a value, we render it to a string. Otherwise, we set `output` to an empty string.
34+
35+
36+
## Uses Modern .NET Features
37+
38+
For performance and convenience:
39+
- Supports parsing any type that implements `IParsable<T>` or `ISpanParsable<T>` (.NET 7 and above only)
40+
- The `NumericOption<T>` type supports generic math for any contained type that implements `INumber<T>` (.NET 7 and above only)
41+
- Supports `async` and `IAsyncEnumerable<T>`.
42+
- Supports nullable type annotations.
43+
- Supports serialization and deserialization with `System.Text.Json`.
44+
- `IEquatable<T>` and `IComparable<T>` allow `Option` and `Result` types to be easily compared and sorted.
45+
- `IFormattable` and `ISpanFormattable` allow `Option` and `Result` to efficiently format their content.
46+
- `Option` and `Result` can be efficiently converted to `ReadOnlySpan<T>` or `IEnumerable<T>` for easier interop with existing code.
47+
- Convenient extension methods for working with dictionaries (`GetValueOrNone`), collections (`FirstOrNone`), enums (`Option.ParseEnum`) and more.
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
using System.Globalization;
2+
using System.Text.Json;
3+
4+
namespace RustyOptions.Tests;
5+
6+
public class JsonSerializationTests
7+
{
8+
private const string DtoString = "2019-09-07T15:50:00-04:00";
9+
private static readonly DateTimeOffset DtoParsed = DateTimeOffset.Parse("2019-09-07T15:50:00-04:00", CultureInfo.InvariantCulture);
10+
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
11+
12+
[Fact]
13+
public void CanParseOptionsAllSome()
14+
{
15+
var parsed = JsonSerializer.Deserialize<ClassWithOptions>(OptionsAllSome, JsonOpts);
16+
17+
Assert.NotNull(parsed);
18+
Assert.Equal(42, parsed.Foo);
19+
Assert.Equal(Option.Some(17), parsed.Bar);
20+
Assert.Equal(Option.Some("Frank"), parsed.Name);
21+
Assert.Equal(Option.Some(DtoParsed), parsed.LastUpdated);
22+
}
23+
24+
[Fact]
25+
public void CanParseOptionsAllMissing()
26+
{
27+
var parsed = JsonSerializer.Deserialize<ClassWithOptions>(OptionsAllMissing, JsonOpts);
28+
29+
Assert.NotNull(parsed);
30+
Assert.Equal(42, parsed.Foo);
31+
Assert.Equal(Option.None<int>(), parsed.Bar);
32+
Assert.Equal(Option.None<string>(), parsed.Name);
33+
Assert.Equal(Option.None<DateTimeOffset>(), parsed.LastUpdated);
34+
}
35+
36+
[Fact]
37+
public void CanParseOptionsAllNull()
38+
{
39+
var parsed = JsonSerializer.Deserialize<ClassWithOptions>(OptionsAllNull, JsonOpts);
40+
41+
Assert.NotNull(parsed);
42+
Assert.Equal(42, parsed.Foo);
43+
Assert.Equal(Option.None<int>(), parsed.Bar);
44+
Assert.Equal(Option.None<string>(), parsed.Name);
45+
Assert.Equal(Option.None<DateTimeOffset>(), parsed.LastUpdated);
46+
}
47+
48+
[Fact]
49+
public void CanSerializeOptionsAllSome()
50+
{
51+
var sut = new ClassWithOptions
52+
{
53+
Foo = 42,
54+
Bar = Option.Some(17),
55+
Name = Option.Some("Frank"),
56+
LastUpdated = Option.Some(DtoParsed)
57+
};
58+
59+
var serialized = JsonSerializer.Serialize(sut, JsonOpts);
60+
61+
Assert.Equal(OptionsAllSome, serialized);
62+
}
63+
64+
[Fact]
65+
public void CanSerializeOptionsAllNone()
66+
{
67+
var sut = new ClassWithOptions
68+
{
69+
Foo = 42,
70+
Bar = Option.None<int>(),
71+
Name = Option.None<string>(),
72+
LastUpdated = Option.None<DateTimeOffset>()
73+
};
74+
75+
var serialized = JsonSerializer.Serialize(sut, JsonOpts);
76+
77+
Assert.Equal(OptionsAllNull, serialized);
78+
}
79+
80+
#if NET7_0_OR_GREATER
81+
82+
[Fact]
83+
public void CanParseNumericOptionsAllSome()
84+
{
85+
var parsed = JsonSerializer.Deserialize<ClassWithNumbers>(NumericOptionsAllSome, JsonOpts);
86+
87+
Assert.NotNull(parsed);
88+
Assert.Equal(42, parsed.Foo);
89+
Assert.Equal(NumericOption.Some(17), parsed.Bar);
90+
Assert.Equal(NumericOption.Some(3.14), parsed.Baz);
91+
Assert.Equal(NumericOption.Some((byte)255), parsed.Quux);
92+
}
93+
94+
[Fact]
95+
public void CanParseNumericOptionsAllMissing()
96+
{
97+
var parsed = JsonSerializer.Deserialize<ClassWithNumbers>(NumericOptionsAllMissing, JsonOpts);
98+
99+
Assert.NotNull(parsed);
100+
Assert.Equal(42, parsed.Foo);
101+
Assert.Equal(NumericOption.None<int>(), parsed.Bar);
102+
Assert.Equal(NumericOption.None<double>(), parsed.Baz);
103+
Assert.Equal(NumericOption.None<byte>(), parsed.Quux);
104+
}
105+
106+
[Fact]
107+
public void CanParseNumericOptionsAllNull()
108+
{
109+
var parsed = JsonSerializer.Deserialize<ClassWithNumbers>(NumericOptionsAllNull, JsonOpts);
110+
111+
Assert.NotNull(parsed);
112+
Assert.Equal(42, parsed.Foo);
113+
Assert.Equal(NumericOption.None<int>(), parsed.Bar);
114+
Assert.Equal(NumericOption.None<double>(), parsed.Baz);
115+
Assert.Equal(NumericOption.None<byte>(), parsed.Quux);
116+
}
117+
118+
[Fact]
119+
public void CanSerializeNumericOptionsAllSome()
120+
{
121+
var sut = new ClassWithNumbers
122+
{
123+
Foo = 42,
124+
Bar = NumericOption.Some(17),
125+
Baz = NumericOption.Some(3.14),
126+
Quux = NumericOption.Some((byte)255)
127+
};
128+
129+
var serialized = JsonSerializer.Serialize(sut, JsonOpts);
130+
131+
Assert.Equal(NumericOptionsAllSome, serialized);
132+
}
133+
134+
[Fact]
135+
public void CanSerializeNumericOptionsAllNone()
136+
{
137+
var sut = new ClassWithNumbers
138+
{
139+
Foo = 42,
140+
Bar = NumericOption.None<int>(),
141+
Baz = NumericOption.None<double>(),
142+
Quux = NumericOption.None<byte>()
143+
};
144+
145+
var serialized = JsonSerializer.Serialize(sut, JsonOpts);
146+
147+
Assert.Equal(NumericOptionsAllNull, serialized);
148+
}
149+
150+
#endif
151+
152+
[Fact]
153+
public void CanParseResultOk()
154+
{
155+
var parsed = JsonSerializer.Deserialize<ClassWithResult>(ResultOk, JsonOpts);
156+
157+
Assert.NotNull(parsed);
158+
Assert.Equal(42, parsed.Foo);
159+
Assert.Equal(Result.Ok(75), parsed.CurrentCount);
160+
}
161+
162+
[Fact]
163+
public void CanParseResultErr()
164+
{
165+
var parsed = JsonSerializer.Deserialize<ClassWithResult>(ResultErr, JsonOpts);
166+
167+
Assert.NotNull(parsed);
168+
Assert.Equal(42, parsed.Foo);
169+
Assert.Equal(Result.Err<int>("not found!"), parsed.CurrentCount);
170+
}
171+
172+
[Fact]
173+
public void CanParseResultMissing()
174+
{
175+
var parsed = JsonSerializer.Deserialize<ClassWithResult>(ResultMissing, JsonOpts);
176+
177+
Assert.NotNull(parsed);
178+
Assert.Equal(42, parsed.Foo);
179+
180+
// A default instance of the Result struct will be in the Err state, but the err value will be
181+
// the default value for TErr, and therefore possibly null.
182+
Assert.True(parsed.CurrentCount.IsErr(out var err) && err is null);
183+
}
184+
185+
[Fact]
186+
public void CanParseResultNull()
187+
{
188+
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ClassWithResult>(ResultNull, JsonOpts));
189+
}
190+
191+
[Fact]
192+
public void CanSerializeResultOk()
193+
{
194+
var sut = new ClassWithResult
195+
{
196+
Foo = 42,
197+
CurrentCount = Result.Ok(75)
198+
};
199+
200+
var serialized = JsonSerializer.Serialize(sut, JsonOpts);
201+
202+
Assert.Equal(ResultOk, serialized);
203+
}
204+
205+
[Fact]
206+
public void CanSerializeResultErr()
207+
{
208+
var sut = new ClassWithResult
209+
{
210+
Foo = 42,
211+
CurrentCount = Result.Err<int>("not found!")
212+
};
213+
214+
var serialized = JsonSerializer.Serialize(sut, JsonOpts);
215+
216+
Assert.Equal(ResultErr, serialized);
217+
}
218+
219+
private const string OptionsAllSome = $$"""
220+
{"foo":42,"bar":17,"name":"Frank","lastUpdated":"{{DtoString}}"}
221+
""";
222+
223+
private const string OptionsAllMissing = """
224+
{ "foo": 42 }
225+
""";
226+
227+
private const string OptionsAllNull = """
228+
{"foo":42,"bar":null,"name":null,"lastUpdated":null}
229+
""";
230+
231+
private const string NumericOptionsAllSome = """
232+
{"foo":42,"bar":17,"baz":3.14,"quux":255}
233+
""";
234+
235+
private const string NumericOptionsAllMissing = """
236+
{ "foo": 42 }
237+
""";
238+
239+
private const string NumericOptionsAllNull = """
240+
{"foo":42,"bar":null,"baz":null,"quux":null}
241+
""";
242+
243+
private const string ResultOk = """
244+
{"foo":42,"currentCount":{"ok":75}}
245+
""";
246+
247+
private const string ResultErr = """
248+
{"foo":42,"currentCount":{"err":"not found!"}}
249+
""";
250+
251+
private const string ResultMissing = """
252+
{ "foo": 42 }
253+
""";
254+
255+
private const string ResultNull = """
256+
{ "foo": 42, "currentCount": null }
257+
""";
258+
259+
private sealed record ClassWithOptions
260+
{
261+
public int Foo { get; set; }
262+
public Option<int> Bar { get; set; }
263+
public Option<string> Name { get; set; }
264+
public Option<DateTimeOffset> LastUpdated { get; set; }
265+
}
266+
267+
private sealed record ClassWithResult
268+
{
269+
public int Foo { get; set; }
270+
public Result<int, string> CurrentCount { get; set; }
271+
}
272+
273+
#if NET7_0_OR_GREATER
274+
private sealed record ClassWithNumbers
275+
{
276+
public int Foo { get; set; }
277+
public NumericOption<int> Bar { get; set; }
278+
public NumericOption<double> Baz { get; set; }
279+
public NumericOption<byte> Quux { get; set; }
280+
}
281+
#endif
282+
}
283+

src/RustyOptions.Tests/OptionTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,4 +442,19 @@ private sealed class NotFormattable
442442
public override string ToString() => Value.ToString();
443443
#pragma warning restore CA1305 // Specify IFormatProvider
444444
}
445+
446+
[Theory]
447+
[InlineData("Blue", false, ConsoleColor.Blue)]
448+
[InlineData("red", true, ConsoleColor.Red)]
449+
[InlineData("darkYellow", true, ConsoleColor.DarkYellow)]
450+
[InlineData("Darkred", false, null)]
451+
[InlineData("foo", true, null)]
452+
[InlineData("9", false, ConsoleColor.Blue)]
453+
[InlineData("797", false, (ConsoleColor)797)]
454+
[InlineData(null, true, null)]
455+
public void CanParseEnums(string name, bool ignoreCase, ConsoleColor? expected)
456+
{
457+
Assert.Equal(Option.Create(expected), Option.ParseEnum<ConsoleColor>(name, ignoreCase));
458+
Assert.Equal(Option.Create(expected), Option.ParseEnum<ConsoleColor>(name.AsSpan(), ignoreCase));
459+
}
445460
}

src/RustyOptions.Tests/ResultAsyncTests.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -229,28 +229,28 @@ public async Task CanOrElseAsync(object source, object mapper, Result<int, strin
229229
{
230230
switch ((source, mapper))
231231
{
232-
case (Result<int, string> src, Func<string, ValueTask<Result<int, string>>> mpr):
232+
case (Result<int, string> src, Func<string?, ValueTask<Result<int, string>>> mpr):
233233
Assert.Equal(expected, await src.OrElseAsync(mpr));
234234
break;
235-
case (Result<int, string> src, Func<string, Task<Result<int, string>>> mpr):
235+
case (Result<int, string> src, Func<string?, Task<Result<int, string>>> mpr):
236236
Assert.Equal(expected, await src.OrElseAsync(mpr));
237237
break;
238-
case (ValueTask<Result<int, string>> src, Func<string, Result<int, string>> mpr):
238+
case (ValueTask<Result<int, string>> src, Func<string?, Result<int, string>> mpr):
239239
Assert.Equal(expected, await src.OrElseAsync(mpr));
240240
break;
241-
case (Task<Result<int, string>> src, Func<string, Result<int, string>> mpr):
241+
case (Task<Result<int, string>> src, Func<string?, Result<int, string>> mpr):
242242
Assert.Equal(expected, await src.OrElseAsync(mpr));
243243
break;
244-
case (ValueTask<Result<int, string>> src, Func<string, ValueTask<Result<int, string>>> mpr):
244+
case (ValueTask<Result<int, string>> src, Func<string?, ValueTask<Result<int, string>>> mpr):
245245
Assert.Equal(expected, await src.OrElseAsync(mpr));
246246
break;
247-
case (Task<Result<int, string>> src, Func<string, ValueTask<Result<int, string>>> mpr):
247+
case (Task<Result<int, string>> src, Func<string?, ValueTask<Result<int, string>>> mpr):
248248
Assert.Equal(expected, await src.OrElseAsync(mpr));
249249
break;
250-
case (ValueTask<Result<int, string>> src, Func<string, Task<Result<int, string>>> mpr):
250+
case (ValueTask<Result<int, string>> src, Func<string?, Task<Result<int, string>>> mpr):
251251
Assert.Equal(expected, await src.OrElseAsync(mpr));
252252
break;
253-
case (Task<Result<int, string>> src, Func<string, Task<Result<int, string>>> mpr):
253+
case (Task<Result<int, string>> src, Func<string?, Task<Result<int, string>>> mpr):
254254
Assert.Equal(expected, await src.OrElseAsync(mpr));
255255
break;
256256

0 commit comments

Comments
 (0)