Doxense / foundationdb-dotnet-client

C#/.NET Binding for FoundationDB Client API
BSD 3-Clause "New" or "Revised" License
148 stars 33 forks source link

Refactoring of JSON API #135

Closed KrzysFR closed 6 months ago

KrzysFR commented 6 months ago

This is a breaking change to the JSON API, that intends to fix the issues with nullabillity of optional arguments.

The main issue is that the API was designed ~10 years ago, and made use of the very flexible notation supported by Resharper for [ContractAnnotations], which allowed for APIs similar to Get<T>(string key, bool required = false) that would describe a method that could not return null if required was true. A lot of methods made use of this pattern (ie: by default optional, but becomes required if the second parameter is true).

Unfortunately, the native nullability syntax of modern C# does not support this particular pattern (where nullability is based on the value of a boolean argument), which creates a lot of false positives, and forces one to add ! at the end of the statement, like this:

// OLD API
string id = json.Get<string>("id", required: true); // produces a warning because the compiler thinks it can return null
string name = json.Get<string>("name", required: true)!; // the '!' works around the issue but could be a minefield for future refactorings
string? description = json.Get<string>("description"); // optional value can be nullable

The goal of this PR is to change the APIs to remove the "required" parameter, and use different conventions for optional vs required.

// NEW API
string id = json.Get<string>("id"); // required, throws is id is null or missing
string name = json.Get<string>("name"); // no '!' needed any more
string? description = json.Get<string?>("description", null); // needs to specify "<string?>" explicitly
string role = json.Get<string>("role", "user"); // with default value is null or missing
int role = json.Get<int>("level", 1); // no need to fiddle with 'int?'
SomeEnum mode = json.Get<SomeEnum>("mode", SomeEnum.Default); // allows specifying custom defaults for enums

The main problem is that a lot of code used the previous API pattern, and needed a strategy to refactor it to the new API with breaking the code. The strategy used was to introduced the new API with all methods prefixed with _ (ie: add new _Get<T>(...) methods, but keep the old Get<T>(....) ), mark old methods as [Obsolete], and then painstakingly update the legacy code to use the new _Get overloads. Once completed, remove the old methods, ensure that everything compiles/run. And then finally use a simple rename refactoring to remove the _ prefix of the new API. All this work was done in this branch.

During this work, a lot of "bad patterns" where identified, and the new API attempts to correct it as well.

A few minor changes:

A big chunk of bat pattern was also present in unit tests, whenever one had to check the content of a JSON Object (read from a database, from a remote API, etc..). Changes where made to allow a more "fluent" way to test the content, tailored to this use case (inside a unit test, which is a lot more forgiving than in production). Unfortunately, some native assertions in NUnit are not customizable enough (such as Is.Null, Is.True and Is.False) so another helper type IsJson had to be introduced.

Assert.That(obj["hello"], IsJson.EqualTo("world")); // test that `hello` is equal to the string "world"
Assert.That(obj["enabled"], IsJson.True); // test that `enabled` is equal to the boolean true
Assert.That(obj["not_found"], IsJson.Null); // test that `not_found` is null or missing (not present, or explicit null)
Assert.That(obj["foo"], IsJson.JsonObject); // test that `foo` is present and is an object
Assert.That(obj["bar"], IsJson.Not.Empty); // test that `bar` is either an object or array or string that is not empty
Assert.That(obj["bar"], IsJson.Array.And.Not.Empty); // test that `bar` is an array and is not empty
Assert.That(obj["foo"]["bar"][1]["baz"], IsJson.EqualTo(42)); // test that `foo.bar[1].baz` is present and is equal to 42