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
50 changes: 50 additions & 0 deletions src/Streamlabs.SocketClient/Converters/FlexibleObjectConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Streamlabs.SocketClient.InternalExtensions;

namespace Streamlabs.SocketClient.Converters;

/// <summary>
/// Provides a flexible JSON converter for <typeparamref name="T"/> that handles mixed input types.
/// This converter can deserialize <typeparamref name="T"/> from a standard JSON object,
/// an escaped JSON string (double-encoded), or gracefully handle plain non-JSON strings by returning null.
/// </summary>
/// <typeparam name="T">The reference type to deserialize into.</typeparam>
public class FlexibleObjectConverter<T> : JsonConverter<T>
where T : class
{
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
reader.TokenType switch
{
JsonTokenType.StartObject => JsonSerializer.Deserialize<T>(ref reader, options),
JsonTokenType.StartArray => JsonSerializer.Deserialize<T>(ref reader, options),
JsonTokenType.String => DeserializeString(ref reader, options),
_ => null,
};
Comment on lines +16 to +23
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent exception handling between StartObject/StartArray and DeserializeString.

DeserializeString silently returns null on any JsonException, but the StartObject and StartArray branches propagate exceptions to the caller. Two realistic failure modes:

  1. The API sends an array for a property typed as FlexibleObjectConverter<TopDonator> (not a collection). JsonSerializer.Deserialize<TopDonator>(ref reader, options) on an array token would throw a JsonException that crashes the whole message deserialization.
  2. The API sends a structurally valid object/array token but with missing required fields on the target type.

For the same resilience as DeserializeString, wrap the object/array branches consistently:

🛡️ Proposed fix — consistent exception handling
 public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
     reader.TokenType switch
     {
-        JsonTokenType.StartObject => JsonSerializer.Deserialize<T>(ref reader, options),
-        JsonTokenType.StartArray => JsonSerializer.Deserialize<T>(ref reader, options),
+        JsonTokenType.StartObject => TryDeserializeToken(ref reader, options),
+        JsonTokenType.StartArray => TryDeserializeToken(ref reader, options),
         JsonTokenType.String => DeserializeString(ref reader, options),
         _ => null,
     };

+private static T? TryDeserializeToken(ref Utf8JsonReader reader, JsonSerializerOptions options)
+{
+    try
+    {
+        return JsonSerializer.Deserialize<T>(ref reader, options);
+    }
+    catch (JsonException)
+    {
+        return null;
+    }
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Streamlabs.SocketClient/Converters/FlexibleObjectConverter.cs` around
lines 16 - 23, The Read method in FlexibleObjectConverter<T> handles
JsonException differently between branches: DeserializeString swallows
JsonException and returns null, but the StartObject/StartArray branches call
JsonSerializer.Deserialize<T>(ref reader, options) and let exceptions propagate;
make them consistent by wrapping the StartObject and StartArray deserialize
calls in a try/catch(JsonException) that returns null on error (same behavior as
DeserializeString) so malformed/structurally-mismatched payloads for
FlexibleObjectConverter<TopDonator> don’t crash message deserialization.


public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) =>
JsonSerializer.Serialize(writer, value, options);

private static T? DeserializeString(ref Utf8JsonReader reader, JsonSerializerOptions options)
{
string? value = reader.GetString();
if (value is null)
{
return null;
}

if (!value.IsJsonObjectOrArray())
{
return null;
}

try
{
return JsonSerializer.Deserialize<T>(value.Trim(), options);
}
catch (JsonException)
{
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,23 @@ internal static class SerializationExtensions
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
};

private static readonly IReadOnlyCollection<IStreamlabsEvent> Empty = Array.Empty<IStreamlabsEvent>();
private static readonly IReadOnlyCollection<IStreamlabsEvent> Empty = [];

public static IReadOnlyCollection<IStreamlabsEvent> Deserialize(this string json)
{
string normalized = json.NormalizeTypeDiscriminators();
return JsonSerializer.Deserialize<IReadOnlyCollection<IStreamlabsEvent>>(normalized, Options) ?? Empty;
}

public static bool IsJsonObjectOrArray(this string value)
{
string trimmed = value.Trim();
return trimmed switch
{
"" => false,
['{', .., '}'] => true,
['[', .., ']'] => true,
_ => false,
};
}
}
14 changes: 14 additions & 0 deletions src/Streamlabs.SocketClient/Messages/DataTypes/DonationGoal.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;

namespace Streamlabs.SocketClient.Messages.DataTypes;

public sealed record DonationGoal {
[JsonPropertyName("title")]
public required string Title { get; init; }

[JsonPropertyName("currentAmount")]
public required string CurrentAmount { get; init; }

[JsonPropertyName("goalAmount")]
public required string GoalAmount { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ public sealed record StreamlabelsMessageData : IHasMessageId, IHasPriority
public required string SessionMostRecentMonthlyDonator { get; init; }

[JsonPropertyName("cloudbot_counter_deaths")]
public required string CloudbotCounterDeaths { get; init; }
public string? CloudbotCounterDeaths { get; init; }

[JsonPropertyName("monthly_subscriber_count")]
[JsonConverter(typeof(IntStringConverter))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ namespace Streamlabs.SocketClient.Messages.DataTypes;
public sealed record StreamlabelsUnderlyingMessageData : IHasMessageId, IHasPriority
{
[JsonPropertyName("donation_goal")]
public required string DonationGoal { get; init; }
[JsonConverter(typeof(FlexibleObjectConverter<DonationGoal>))]
public DonationGoal? DonationGoal { get; init; }

[JsonPropertyName("most_recent_donator")]
public required Donator MostRecentDonator { get; init; }

[JsonPropertyName("session_most_recent_donator")]
public required string SessionMostRecentDonator { get; init; }
[JsonConverter(typeof(FlexibleObjectConverter<Donator>))]
public Donator? SessionMostRecentDonator { get; init; }

[JsonPropertyName("session_donators")]
public required string SessionDonators { get; init; }
[JsonConverter(typeof(FlexibleObjectConverter<IReadOnlyCollection<Donator>>))]
public IReadOnlyCollection<Donator>? SessionDonators { get; init; }

[JsonPropertyName("total_donation_amount")]
public required DonationAmount TotalDonationAmount { get; init; }
Expand All @@ -37,31 +40,40 @@ public sealed record StreamlabelsUnderlyingMessageData : IHasMessageId, IHasPrio
public required TopDonator AllTimeTopDonator { get; init; }

[JsonPropertyName("monthly_top_donator")]
public required string MonthlyTopDonator { get; init; }
[JsonConverter(typeof(FlexibleObjectConverter<TopDonator>))]
public TopDonator? MonthlyTopDonator { get; init; }

[JsonPropertyName("weekly_top_donator")]
public required string WeeklyTopDonator { get; init; }
[JsonConverter(typeof(FlexibleObjectConverter<TopDonator>))]
public TopDonator? WeeklyTopDonator { get; init; }

[JsonPropertyName("30day_top_donator")]
public required string ThirtyDayTopDonator { get; init; }
[JsonConverter(typeof(FlexibleObjectConverter<TopDonator>))]
public TopDonator? ThirtyDayTopDonator { get; init; }

[JsonPropertyName("session_top_donator")]
public required string SessionTopDonator { get; init; }
[JsonConverter(typeof(FlexibleObjectConverter<TopDonator>))]
public TopDonator? SessionTopDonator { get; init; }

[JsonPropertyName("all_time_top_donators")]
public required IReadOnlyCollection<TopDonator> AllTimeTopDonators { get; init; }
[JsonConverter(typeof(FlexibleObjectConverter<IReadOnlyCollection<TopDonator>>))]
public IReadOnlyCollection<TopDonator>? AllTimeTopDonators { get; init; }

[JsonPropertyName("monthly_top_donators")]
public required string MonthlyTopDonators { get; init; }
[JsonConverter(typeof(FlexibleObjectConverter<IReadOnlyCollection<TopDonator>>))]
public IReadOnlyCollection<TopDonator>? MonthlyTopDonators { get; init; }

[JsonPropertyName("weekly_top_donators")]
public required string WeeklyTopDonators { get; init; }
[JsonConverter(typeof(FlexibleObjectConverter<IReadOnlyCollection<TopDonator>>))]
public IReadOnlyCollection<TopDonator>? WeeklyTopDonators { get; init; }

[JsonPropertyName("30day_top_donators")]
public required string ThirtyDayTopDonators { get; init; }
[JsonConverter(typeof(FlexibleObjectConverter<IReadOnlyCollection<TopDonator>>))]
public IReadOnlyCollection<TopDonator>? ThirtyDayTopDonators { get; init; }

[JsonPropertyName("session_top_donators")]
public required string SessionTopDonators { get; init; }
[JsonConverter(typeof(FlexibleObjectConverter<IReadOnlyCollection<TopDonator>>))]
public IReadOnlyCollection<TopDonator>? SessionTopDonators { get; init; }

[JsonPropertyName("all_time_top_donations")]
public required IReadOnlyCollection<TopDonationAmount> AllTimeTopDonations { get; init; }
Expand Down Expand Up @@ -133,7 +145,7 @@ public sealed record StreamlabelsUnderlyingMessageData : IHasMessageId, IHasPrio
public required string SessionMostRecentMonthlyDonator { get; init; }

[JsonPropertyName("cloudbot_counter_deaths")]
public required Counter CloudbotCounterDeaths { get; init; }
public Counter? CloudbotCounterDeaths { get; init; }

[JsonPropertyName("monthly_subscriber_count")]
public required Count MonthlySubscriberCount { get; init; }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Streamlabs.SocketClient.Converters;

namespace Streamlabs.SocketClient.Tests.Converters;

[SuppressMessage("Style", "VSTHRD200:Use \"Async\" suffix for async methods")]
public class FlexibleObjectConverterTests
{
private sealed class SampleClass
{
[JsonConverter(typeof(FlexibleObjectConverter<SamplePayload>))]
public SamplePayload? Payload { get; set; }
}

private sealed class SamplePayload
{
public string? Name { get; set; }
public int Count { get; set; }
}

[Test]
public async Task Read_ObjectToken_Deserializes()
{
// Arrange
var json = """{"Payload":{"Name":"Alpha","Count":3}}""";

// Act
var result = JsonSerializer.Deserialize<SampleClass>(json);

// Assert
await Assert.That(result).IsNotNull();
await Assert.That(result!.Payload).IsNotNull();
await Assert.That(result.Payload!.Name).IsEqualTo("Alpha");
await Assert.That(result.Payload.Count).IsEqualTo(3);
}

[Test]
public async Task Read_EscapedJsonString_Deserializes()
{
// Arrange
var json = """{"Payload":"{\"Name\":\"Beta\",\"Count\":7}"}""";

// Act
var result = JsonSerializer.Deserialize<SampleClass>(json);

// Assert
await Assert.That(result).IsNotNull();
await Assert.That(result!.Payload).IsNotNull();
await Assert.That(result.Payload!.Name).IsEqualTo("Beta");
await Assert.That(result.Payload.Count).IsEqualTo(7);
}

[Test]
public async Task Read_PlainString_ReturnsNull()
{
// Arrange
var json = """{"Payload":"just a plain string"}""";

// Act
var result = JsonSerializer.Deserialize<SampleClass>(json);

// Assert
await Assert.That(result).IsNotNull();
await Assert.That(result!.Payload).IsNull();
}

[Test]
public async Task Read_MalformedJsonString_ReturnsNull()
{
// Arrange
var json = """{"Payload":"{not valid json}"}""";

// Act
var result = JsonSerializer.Deserialize<SampleClass>(json);

// Assert
await Assert.That(result).IsNotNull();
await Assert.That(result!.Payload).IsNull();
}

[Test]
public async Task Write_SerializesNormally()
{
// Arrange
SampleClass sample = new()
{
Payload = new SamplePayload { Name = "Gamma", Count = 42 },
};

// Act
var json = JsonSerializer.Serialize(sample);

// Assert
await Assert.That(json).Contains("\"Payload\"");
await Assert.That(json).Contains("\"Name\":\"Gamma\"");
await Assert.That(json).Contains("\"Count\":42");
}
}
Loading