Files
Umbraco-CMS/src/Umbraco.Infrastructure/Serialization/JsonBlockValueConverter.cs
Karl-Johan Sjögren ec6a38e052 Fix for invalid state in JsonBlockValueConverter when an unused layout has a nested array (#19363)
* Fix for invalid state in JsonBlockValueConverter when an unused layout has a nested array

* Improved comments as suggested by copilot review, also fixed code style miss

* Added check for malformed JSON with more closing array tokens then opening tokens
2025-05-20 10:07:50 +01:00

229 lines
10 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Serialization;
/// <summary>
/// JSON converter for block values, because block value layouts are strongly typed but different from implementation to implementation.
/// </summary>
public class JsonBlockValueConverter : JsonConverter<BlockValue>
{
public override bool CanConvert(Type typeToConvert) => typeToConvert.IsAssignableTo(typeof(BlockValue));
/// <inheritdoc />
public override BlockValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException("Expected start object");
}
BlockValue? blockValue;
try
{
blockValue = (BlockValue?)Activator.CreateInstance(typeToConvert);
}
catch (Exception ex)
{
throw new JsonException($"Unable to create an instance of {nameof(BlockValue)} from type: {typeToConvert.FullName}. Please make sure the type has an default (parameterless) constructor. See the inner exception for more details.", ex);
}
if (blockValue is null)
{
throw new JsonException($"Could not create an instance of {nameof(BlockValue)} from type: {typeToConvert.FullName}.");
}
while (reader.Read())
{
if (reader.TokenType is JsonTokenType.EndObject)
{
break;
}
if (reader.TokenType is JsonTokenType.PropertyName)
{
var propertyName = reader.GetString();
if (propertyName is null)
{
continue;
}
switch (propertyName.ToFirstUpperInvariant())
{
case nameof(BlockValue.ContentData):
blockValue.ContentData = DeserializeBlockItemData(ref reader, options, typeToConvert, nameof(BlockValue.ContentData));
break;
case nameof(BlockValue.SettingsData):
blockValue.SettingsData = DeserializeBlockItemData(ref reader, options, typeToConvert, nameof(BlockValue.SettingsData));
break;
case nameof(BlockValue.Layout):
DeserializeAndSetLayout(ref reader, options, typeToConvert, blockValue);
break;
case nameof(BlockValue.Expose):
blockValue.Expose = DeserializeBlockVariation(ref reader, options, typeToConvert, nameof(BlockValue.Expose));
break;
}
}
}
return blockValue;
}
public override void Write(Utf8JsonWriter writer, BlockValue value, JsonSerializerOptions options)
{
value.Layout.TryGetValue(value.PropertyEditorAlias, out IEnumerable<IBlockLayoutItem>? blockLayoutItems);
blockLayoutItems ??= Enumerable.Empty<IBlockLayoutItem>();
writer.WriteStartObject();
writer.WritePropertyName(nameof(BlockValue.ContentData).ToFirstLowerInvariant());
JsonSerializer.Serialize(writer, value.ContentData, options);
if (value.SettingsData is not null)
{
writer.WritePropertyName(nameof(BlockValue.SettingsData).ToFirstLowerInvariant());
JsonSerializer.Serialize(writer, value.SettingsData, options);
}
writer.WritePropertyName(nameof(BlockValue.Expose).ToFirstLowerInvariant());
JsonSerializer.Serialize(writer, value.Expose, options);
Type layoutItemType = GetLayoutItemType(value.GetType());
writer.WriteStartObject(nameof(BlockValue.Layout));
if (blockLayoutItems.Any())
{
writer.WriteStartArray(value.PropertyEditorAlias);
foreach (IBlockLayoutItem blockLayoutItem in blockLayoutItems)
{
JsonSerializer.Serialize(writer, blockLayoutItem, layoutItemType, options);
}
writer.WriteEndArray();
}
writer.WriteEndObject();
writer.WriteEndObject();
}
private static Type GetLayoutItemType(Type blockValueType)
{
Type? layoutItemType = blockValueType.BaseType?.GenericTypeArguments.FirstOrDefault();
if (layoutItemType is null || layoutItemType.Implements<IBlockLayoutItem>() is false)
{
throw new JsonException($"The {nameof(BlockValue)} implementation should have an {nameof(IBlockLayoutItem)} type as its first generic type argument - found: {layoutItemType?.FullName ?? "none"}.");
}
return layoutItemType;
}
private List<BlockItemData> DeserializeBlockItemData(ref Utf8JsonReader reader, JsonSerializerOptions options, Type typeToConvert, string propertyName)
=> DeserializeListOf<BlockItemData>(ref reader, options, typeToConvert, propertyName);
private List<BlockItemVariation> DeserializeBlockVariation(ref Utf8JsonReader reader, JsonSerializerOptions options, Type typeToConvert, string propertyName)
=> DeserializeListOf<BlockItemVariation>(ref reader, options, typeToConvert, propertyName);
private List<T> DeserializeListOf<T>(ref Utf8JsonReader reader, JsonSerializerOptions options, Type typeToConvert, string propertyName)
=> JsonSerializer.Deserialize<List<T>>(ref reader, options)
?? throw new JsonException($"Unable to deserialize {propertyName} from type: {typeToConvert.FullName}.");
private void DeserializeAndSetLayout(ref Utf8JsonReader reader, JsonSerializerOptions options, Type typeToConvert, BlockValue blockValue)
{
// the block editor layouts collection can contain layouts from any number of block editors.
// we only want to deserialize the one identified by the concrete block value.
// here's an example of how the layouts collection JSON might look:
// "layout": {
// "Umbraco.BlockGrid": [{
// "contentUdi": "umb://element/1304E1DDAC87439684FE8A399231CB3D",
// "rowSpan": 1,
// "columnSpan": 12,
// "areas": []
// }
// ],
// "Umbraco.BlockList": [{
// "contentUdi": "umb://element/1304E1DDAC87439684FE8A399231CB3D"
// }
// ],
// "Some.Custom.BlockEditor": [{
// "contentUdi": "umb://element/1304E1DDAC87439684FE8A399231CB3D"
// }
// ]
// }
// the concrete block editor layout items type
Type layoutItemType = GetLayoutItemType(typeToConvert);
// the type describing a list of concrete block editor layout items
Type layoutItemsType = typeof(List<>).MakeGenericType(layoutItemType);
while (reader.Read())
{
if (reader.TokenType is JsonTokenType.EndObject)
{
break;
}
if (reader.TokenType is JsonTokenType.PropertyName)
{
// grab the block editor alias (e.g. "Umbraco.BlockGrid")
var blockEditorAlias = reader.GetString()
?? throw new JsonException($"Could bot get the block editor alias from the layout while attempting to deserialize type: {typeToConvert.FullName}.");
// forward the reader to the next JSON token, which *should* be the array of corresponding layout items
reader.Read();
if (reader.TokenType is not JsonTokenType.StartArray)
{
throw new JsonException($"Expected to find the beginning of an array of layout items for block editor alias: {blockEditorAlias}, got: {reader.TokenType}. This happened while attempting to deserialize type: {typeToConvert.FullName}.");
}
// did we encounter the concrete block value?
if (blockValue.SupportsBlockLayoutAlias(blockEditorAlias))
{
// yes, deserialize the block layout items as their concrete type (list of layoutItemType)
var layoutItems = JsonSerializer.Deserialize(ref reader, layoutItemsType, options);
blockValue.Layout[blockValue.PropertyEditorAlias] = layoutItems as IEnumerable<IBlockLayoutItem>
?? throw new JsonException($"Could not deserialize block editor layout items as type: {layoutItemType.FullName} while attempting to deserialize layout items for block editor alias: {blockEditorAlias} for type: {typeToConvert.FullName}.");
}
else
{
// ignore this layout - forward the reader to the end of the array and look for the next one
// Read past the current StartArray token before we start counting
_ = reader.Read();
// Keep track of the number of open arrays to ensure we find the correct EndArray token
var openCount = 0;
while (true)
{
if (reader.TokenType is JsonTokenType.EndArray && openCount == 0)
{
break;
}
if (reader.TokenType is JsonTokenType.StartArray)
{
openCount++;
}
else if (reader.TokenType is JsonTokenType.EndArray)
{
openCount--;
if (openCount < 0)
{
throw new JsonException($"Malformed JSON: Encountered more closing array tokens than opening ones while processing block editor alias: {blockEditorAlias}.");
}
}
if (!reader.Read())
{
throw new JsonException($"Unexpected end of JSON while looking for the end of the layout items array for block editor alias: {blockEditorAlias}.");
}
}
}
}
}
}
}