In RESTful APIs it's common to provide a PATCH endpoint for clients to partially update a resource. If the resource doesn't have any nullable properties, that can be solved by using null as a default value to indicate that a property was omitted.
public class PatchTodoDto
{
public string? Title { get; init; }
public bool? Done { get; init; }
}
ASP.NET Core will then use null by default if the property was not provided. And in your application logic, you would have something like:
Todo updatedTodo = originalTodo with
{
dto.Title ?? originalTodo.Title,
dto.Done ?? originalTodo.Done
};
For resources WITH nullable properties though, you need a way to separate between null and "undefined" (to make a javascript reference).
Introducing the Option type
By the means of some JSON converter magic and implicit casting, we can have a type Option represent all the three possible states of a patched property: value, null and undefined.
public readonly record struct Option<T>(T Value)
{
public bool HasValue { get; } = true;
public static implicit operator Option<T>(T value) => new(value);
}
With this type, we can now modify our DTO type to handle nullable properties.
public class PatchTodoDto
{
public Option<string> Title { get; init; }
public Option<bool> Done { get; init; }
public Option<DateTimeOffset?> DueDate { get; init; }
}
And the application logic now becomes:
Todo updatedTodo = originalTodo with
{
dto.Title.HasValue ? dto.Title.Value : originalTodo.Title,
dto.Done.HasValue ? dto.Done.Value : originalTodo.Done,
dto.DueDate.HasValue ? dto.DueDate.Value : originalTodo.DueDate
};
With a tiny extension method, it becomes even nicer:
Todo updatedTodo = originalTodo with
{
dto.Title.GetValueOrDefault(originalTodo.Title),
dto.Done.GetValueOrDefault(originalTodo.Done),
dto.DueDate.GetValueOrDefault(originalTodo.DueDate)
};
public static class OptionExtensions
{
[return: NotNullIfNotNull(nameof(@default))]
public static T? GetValueOrDefault<T>(this Option<T> option, T? @default = default)
=> option.HasValue ? option.Value : @default;
}
This way null means null and you have a more explicit contract.
Infrastructure
To be able to use the Option type in ASP.NET Core, you must configure a custom JSON converter when setting up the services:
services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new OptionJsonConverterFactory());
});
And the implementation of the converter is as follows:
public class OptionJsonConverterFactory : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert)
=> typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(Option<>);
public override JsonConverter CreateConverter(
Type type,
JsonSerializerOptions options)
{
Type typeArgument = type.GetGenericArguments()[0];
var converter = (JsonConverter)Activator.CreateInstance(
typeof(OptionJsonConverterInner<>).MakeGenericType(typeArgument),
BindingFlags.Instance | BindingFlags.Public,
binder: null,
args: [options],
culture: null)!;
return converter;
}
private class OptionJsonConverterInner<T>(JsonSerializerOptions options) : JsonConverter<Option<T>>
{
private readonly JsonConverter<T> _converter = (JsonConverter<T>)options.GetConverter(typeof(T));
public override Option<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> new(_converter.Read(ref reader, typeof(T), options)!);
public override void Write(Utf8JsonWriter writer, Option<T> value, JsonSerializerOptions options)
=> throw new NotImplementedException();
}
}
I hope this can be of help to others when implementing PATCH methods in their RESTful APIs.
Happy hacking!
Top comments (0)