json-api-dotnet / JsonApiDotNetCore

A framework for building JSON:API compliant REST APIs using ASP.NET and Entity Framework Core.
https://www.jsonapi.net
MIT License
662 stars 160 forks source link

Explore usage of INotifyPropertyChanged with NSwag clients #1577

Open bkoelman opened 1 week ago

bkoelman commented 1 week ago

If possible, this smoothens the experience of partial post/patch for atomic operations. Otherwise, the existing extension method must take the operation index as a parameter.

bkoelman commented 18 hours ago

This would be great to use for partial patch, where sending "firstname": null means something different than omitting it.

Unfortunately, this isn't currently possible, because the client code generated by NSwag performs a comparison before raising the INotifyPropertyChanged.PropertyChanged event. For example:

[Newtonsoft.Json.JsonProperty("firstName", Required = Newtonsoft.Json.Required.Default,
    NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
public string? FirstName
{
    get { return _firstName; }
    set
    {
        if (_firstName != value) // <----- this check must be removed, so we can detect null/default assignments
        {
            _firstName = value;
            RaisePropertyChanged();
        }
    }
}

It would require shipping a custom template for NJsonSchema, based on the default template. If we did, the following would work:

var updatePersonRequest = new UpdatePersonRequestDocument
{
    Data = new DataInUpdatePersonRequest
    {
        Id = "1",
        Attributes = new TrackChangesFor<AttributesInUpdatePersonRequest>(_apiClient)
        {
            Initializer =
            {
                FirstName = null,
                LastName = "last"
            }
        }.Initializer
    }
};

Using the following utilities in the JsonApiDotNetCore.OpenApi.Client.NSwag package:

public sealed class TrackChangesFor<T>
    where T : INotifyPropertyChanged, new()
{
    public T Initializer { get; }

    public TrackChangesFor(JsonApiClient apiClient)
    {
        ArgumentNullException.ThrowIfNull(apiClient);

        Initializer = new T();
        apiClient.Track(Initializer);
    }
}

public abstract class JsonApiClient : IJsonApiClient
{
    private readonly Dictionary<INotifyPropertyChanged, ISet<string>> _propertyStore = [];

    internal void Track<T>(T container)
        where T : INotifyPropertyChanged, new()
    {
        container.PropertyChanged += ContainerOnPropertyChanged;
    }

    private void ContainerOnPropertyChanged(object? sender, PropertyChangedEventArgs args)
    {
        if (sender is INotifyPropertyChanged container && args.PropertyName != null)
        {
            if (!_propertyStore.TryGetValue(container, out ISet<string>? properties))
            {
                properties = new HashSet<string>();
                _propertyStore[container] = properties;
            }

            properties.Add(args.PropertyName!);
        }
    }

    public void Reset()
    {
        foreach (INotifyPropertyChanged container in _propertyStore.Keys)
        {
            container.PropertyChanged -= ContainerOnPropertyChanged;
        }

        _propertyStore.Clear();
    }

    protected void SetSerializerSettingsForJsonApi(JsonSerializerSettings serializerSettings)
    {
        ArgumentNullException.ThrowIfNull(serializerSettings);

        serializerSettings.Converters.Add(new PropertyTrackingConverter(this));
    }

    private sealed class PropertyTrackingConverter : JsonConverter
    {
        private readonly JsonApiClient _apiClient;
        private bool _isSerializing;

        public PropertyTrackingConverter(JsonApiClient apiClient)
        {
            ArgumentNullException.ThrowIfNull(apiClient);

            _apiClient = apiClient;
        }

        public override bool CanConvert(Type objectType)
        {
            return !_isSerializing &&
                _apiClient._propertyStore.Keys.Any(container => container.GetType() == objectType);
        }

        public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue,
            JsonSerializer serializer)
        {
            _isSerializing = true;
            var result = serializer.Deserialize(reader, objectType);
            _isSerializing = false;
            return result;
        }

        public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
        {
            if (value is INotifyPropertyChanged container &&
                _apiClient._propertyStore.TryGetValue(container, out ISet<string>? properties))
            {
                _isSerializing = true;
                writer.WriteStartObject();

                foreach (string propertyName in properties)
                {
                    PropertyInfo property = container.GetType().GetProperty(propertyName)!;
                    object? propertyValue = property.GetValue(container);

                    writer.WritePropertyName(propertyName);
                    serializer.Serialize(writer, propertyValue);
                }

                writer.WriteEndObject();
                _isSerializing = false;
            }
            else
            {
                serializer.Serialize(writer, value);
            }
        }
    }
}