Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,14 @@ private static List<KeyValuePair<string, object>> FlattenParamsOptions(
// reference types), so skip those to avoid encoding them in the request.
if (value == null)
{
// If this is an emptyable property that was explicitly set to null,
// encode it as an empty string to clear the field on the server.
if (options is IHasSetTracking tracked && tracked.IsPropertySet(property.Name))
{
string newPrefixForNull = NewPrefix(key, keyPrefix);
flatParams.Add(new KeyValuePair<string, object>(newPrefixForNull, string.Empty));
}

continue;
}

Expand Down
17 changes: 17 additions & 0 deletions src/Stripe.net/Infrastructure/IHasSetTracking.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Stripe.Infrastructure
{
/// <summary>
/// Implemented by options classes that support set-tracking for emptyable fields.
/// Allows encoders to check whether a null property was explicitly set (clear the field)
/// vs never set (omit from the request).
/// </summary>
internal interface IHasSetTracking
{
/// <summary>
/// Returns whether the given property was explicitly set by the caller.
/// </summary>
/// <param name="propertyName">The C# property name to check.</param>
/// <returns>True if the property was explicitly set.</returns>
bool IsPropertySet(string propertyName);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,17 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions
switch (valueToSerialize)
{
case null:
// If this is an emptyable property that was explicitly set to null,
// write null even if the global ignore condition would skip it.
bool forceWriteNull = value is IHasSetTracking tracked
&& tracked.IsPropertySet(property.PropertyInfo.Name);

// Use property-level ignore condition if set, otherwise use global setting
var effectiveIgnoreCondition = property.IgnoreCondition ?? options.DefaultIgnoreCondition;

if (effectiveIgnoreCondition != JsonIgnoreCondition.WhenWritingNull &&
effectiveIgnoreCondition != JsonIgnoreCondition.Always)
if (forceWriteNull ||
(effectiveIgnoreCondition != JsonIgnoreCondition.WhenWritingNull &&
effectiveIgnoreCondition != JsonIgnoreCondition.Always))
{
writer.WritePropertyName(property.JsonPropertyName);
writer.WriteNullValue();
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
namespace Stripe.Infrastructure
{
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

/// <summary>
/// Serializes dictionary entries preserving null values. The global
/// <see cref="JsonIgnoreCondition.WhenWritingNull"/> setting in
/// <c>JsonEncodedContent</c> would normally skip null dictionary entries, but for
/// metadata fields on update operations, <c>{"key": null}</c> means "delete this key."
/// Apply this converter per-property via attribute.
/// </summary>
internal class STJNullPreservingDictionaryConverter : JsonConverterFactory
{
/// <inheritdoc/>
public override bool CanConvert(Type typeToConvert)
{
if (!typeToConvert.IsGenericType)
{
return false;
}

return typeToConvert.GetGenericTypeDefinition() == typeof(Dictionary<,>);
}

/// <inheritdoc/>
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
var keyType = typeToConvert.GenericTypeArguments[0];
var valueType = typeToConvert.GenericTypeArguments[1];

var converterType = typeof(InnerConverter<,>).MakeGenericType(keyType, valueType);
return (JsonConverter)Activator.CreateInstance(converterType);
}

private class InnerConverter<TKey, TValue> : JsonConverter<Dictionary<TKey, TValue>>
{
public override Dictionary<TKey, TValue> Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
// Standard deserialization handles null values fine.
// No recursion risk because this converter is per-property (via attribute),
// not registered globally in the options.
return JsonSerializer.Deserialize<Dictionary<TKey, TValue>>(ref reader, options);
}

public override void Write(
Utf8JsonWriter writer,
Dictionary<TKey, TValue> value,
JsonSerializerOptions options)
{
writer.WriteStartObject();
foreach (var kvp in value)
{
var key = kvp.Key?.ToString();
if (key == null)
{
continue;
}

writer.WritePropertyName(key);
if (kvp.Value == null)
{
writer.WriteNullValue();
}
else
{
JsonSerializer.Serialize(writer, kvp.Value, options);
}
}

writer.WriteEndObject();
}
}
}
}
35 changes: 35 additions & 0 deletions src/Stripe.net/Infrastructure/SetTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace Stripe.Infrastructure
{
using System.Collections.Generic;
using System.Runtime.CompilerServices;

/// <summary>
/// Tracks which properties have been explicitly set by the caller.
/// Used by emptyable fields so that setting a property to null is
/// distinguishable from never setting it.
/// </summary>
internal class SetTracker
{
private HashSet<string> properties;

/// <summary>
/// Records that a property was explicitly set.
/// </summary>
/// <param name="propertyName">The name of the property (auto-populated by CallerMemberName).</param>
public void Track([CallerMemberName] string propertyName = null)
{
this.properties ??= new HashSet<string>();
this.properties.Add(propertyName);
}

/// <summary>
/// Returns whether the given property was explicitly set.
/// </summary>
/// <param name="propertyName">The C# property name to check.</param>
/// <returns>True if the property was explicitly set.</returns>
public bool IsSet(string propertyName)
{
return this.properties != null && this.properties.Contains(propertyName);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ namespace Stripe
[STJS.JsonConverter(typeof(STJStripeOptionsConverter))]
public class AccountExternalAccountUpdateOptions : BaseOptions, IHasMetadata
{
private string accountHolderType;
private Dictionary<string, string> metadata;

/// <summary>
/// The name of the person or business that owns the bank account.
/// </summary>
Expand All @@ -23,7 +26,15 @@ public class AccountExternalAccountUpdateOptions : BaseOptions, IHasMetadata
/// </summary>
[JsonProperty("account_holder_type")]
[STJS.JsonPropertyName("account_holder_type")]
public string AccountHolderType { get; set; }
public string AccountHolderType
{
get => this.accountHolderType;
set
{
this.accountHolderType = value;
this.SetTracker.Track();
}
}

/// <summary>
/// The bank account type. This can only be <c>checking</c> or <c>savings</c> in most
Expand Down Expand Up @@ -112,7 +123,15 @@ public class AccountExternalAccountUpdateOptions : BaseOptions, IHasMetadata
/// </summary>
[JsonProperty("metadata")]
[STJS.JsonPropertyName("metadata")]
public Dictionary<string, string> Metadata { get; set; }
public Dictionary<string, string> Metadata
{
get => this.metadata;
set
{
this.metadata = value;
this.SetTracker.Track();
}
}

/// <summary>
/// Cardholder name.
Expand Down
Loading
Loading