Skip to content

Instantly share code, notes, and snippets.

@jbreuer
Created August 30, 2023 15:01
Show Gist options
  • Save jbreuer/2c99bc8e691c135354cb145e4915f734 to your computer and use it in GitHub Desktop.
Save jbreuer/2c99bc8e691c135354cb145e4915f734 to your computer and use it in GitHub Desktop.
Umbraco Content Delivery API changes
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Routing;
namespace UmbracoProject;
public class CustomApiMediaUrlProvider : IApiMediaUrlProvider
{
private readonly IPublishedUrlProvider _publishedUrlProvider;
public CustomApiMediaUrlProvider(IPublishedUrlProvider publishedUrlProvider)
=> _publishedUrlProvider = publishedUrlProvider;
public string GetUrl(IPublishedContent media)
{
if (media.ItemType != PublishedItemType.Media)
{
throw new ArgumentException("Media URLs can only be generated from Media items.", nameof(media));
}
return _publishedUrlProvider.GetMediaUrl(media, UrlMode.Absolute);
}
}
using System.Runtime.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.Models.Editors;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.PropertyEditors.DeliveryApi;
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
namespace UmbracoProject;
[DefaultPropertyValueConverter]
public class CustomMediaPickerWithCropsValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter
{
private readonly IJsonSerializer _jsonSerializer;
private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor;
private readonly IPublishedUrlProvider _publishedUrlProvider;
private readonly IPublishedValueFallback _publishedValueFallback;
private readonly IApiMediaBuilder _apiMediaBuilder;
[Obsolete("Use constructor that takes all parameters, scheduled for removal in V14")]
public CustomMediaPickerWithCropsValueConverter(
IPublishedSnapshotAccessor publishedSnapshotAccessor,
IPublishedUrlProvider publishedUrlProvider,
IPublishedValueFallback publishedValueFallback,
IJsonSerializer jsonSerializer)
: this(
publishedSnapshotAccessor,
publishedUrlProvider,
publishedValueFallback,
jsonSerializer,
StaticServiceProvider.Instance.GetRequiredService<IApiMediaBuilder>()
)
{
}
public CustomMediaPickerWithCropsValueConverter(
IPublishedSnapshotAccessor publishedSnapshotAccessor,
IPublishedUrlProvider publishedUrlProvider,
IPublishedValueFallback publishedValueFallback,
IJsonSerializer jsonSerializer,
IApiMediaBuilder apiMediaBuilder)
{
_publishedSnapshotAccessor = publishedSnapshotAccessor ??
throw new ArgumentNullException(nameof(publishedSnapshotAccessor));
_publishedUrlProvider = publishedUrlProvider;
_publishedValueFallback = publishedValueFallback;
_jsonSerializer = jsonSerializer;
_apiMediaBuilder = apiMediaBuilder;
}
public override bool IsConverter(IPublishedPropertyType propertyType) =>
propertyType.EditorAlias.Equals(Umbraco.Cms.Core.Constants.PropertyEditors.Aliases.MediaPicker3);
public override bool? IsValue(object? value, PropertyValueLevel level)
{
var isValue = base.IsValue(value, level);
if (isValue != false && level == PropertyValueLevel.Source)
{
// Empty JSON array is not a value
isValue = value?.ToString() != "[]";
}
return isValue;
}
public override Type GetPropertyValueType(IPublishedPropertyType propertyType)
=> IsMultipleDataType(propertyType.DataType)
? typeof(IEnumerable<MediaWithCrops>)
: typeof(MediaWithCrops);
public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) =>
PropertyCacheLevel.Snapshot;
public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview)
{
var isMultiple = IsMultipleDataType(propertyType.DataType);
if (string.IsNullOrEmpty(inter?.ToString()))
{
// Short-circuit on empty value
return isMultiple ? Enumerable.Empty<MediaWithCrops>() : null;
}
var mediaItems = new List<MediaWithCrops>();
IEnumerable<MediaPicker3PropertyValueEditor.MediaWithCropsDto> dtos =
MediaPicker3PropertyValueEditor.Deserialize(_jsonSerializer, inter);
MediaPicker3Configuration? configuration = propertyType.DataType.ConfigurationAs<MediaPicker3Configuration>();
IPublishedSnapshot publishedSnapshot = _publishedSnapshotAccessor.GetRequiredPublishedSnapshot();
foreach (MediaPicker3PropertyValueEditor.MediaWithCropsDto dto in dtos)
{
IPublishedContent? mediaItem = publishedSnapshot.Media?.GetById(preview, dto.MediaKey);
if (mediaItem != null)
{
var localCrops = new ImageCropperValue
{
Crops = dto.Crops,
FocalPoint = dto.FocalPoint,
Src = mediaItem.Url(_publishedUrlProvider),
};
localCrops.ApplyConfiguration(configuration);
// TODO: This should be optimized/cached, as calling Activator.CreateInstance is slow
Type mediaWithCropsType = typeof(MediaWithCrops<>).MakeGenericType(mediaItem.GetType());
var mediaWithCrops = (MediaWithCrops)Activator.CreateInstance(mediaWithCropsType, mediaItem, _publishedValueFallback, localCrops)!;
mediaItems.Add(mediaWithCrops);
if (!isMultiple)
{
// Short-circuit on single item
break;
}
}
}
return isMultiple ? mediaItems : mediaItems.FirstOrDefault();
}
public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Elements;
public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(IEnumerable<ApiMediaWithCrops>);
public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding)
{
var isMultiple = IsMultipleDataType(propertyType.DataType);
ApiMediaWithCrops ToApiMedia(MediaWithCrops media)
{
IApiMedia inner = _apiMediaBuilder.Build(media.Content);
// make sure we merge crops and focal point defined at media level with the locally defined ones (local ones take precedence in case of a conflict)
ImageCropperValue? mediaCrops = media.Content.Value<ImageCropperValue>(_publishedValueFallback, Umbraco.Cms.Core.Constants.Conventions.Media.File);
ImageCropperValue localCrops = media.LocalCrops;
if (mediaCrops != null)
{
localCrops = localCrops.Merge(mediaCrops);
}
return new ApiMediaWithCrops(inner, localCrops.FocalPoint, localCrops.Crops);
}
// NOTE: eventually we might implement this explicitly instead of piggybacking on the default object conversion. however, this only happens once per cache rebuild,
// and the performance gain from an explicit implementation is negligible, so... at least for the time being this will do just fine.
var converted = ConvertIntermediateToObject(owner, propertyType, referenceCacheLevel, inter, preview);
if (isMultiple && converted is IEnumerable<MediaWithCrops> mediasWithCrops)
{
return mediasWithCrops.Select(ToApiMedia).ToArray();
}
if (isMultiple == false && converted is MediaWithCrops mediaWithCrops)
{
return ToApiMedia(mediaWithCrops);
}
return Array.Empty<ApiMediaWithCrops>();
}
private bool IsMultipleDataType(PublishedDataType dataType) =>
dataType.ConfigurationAs<MediaPicker3Configuration>()?.Multiple ?? false;
}
internal class MediaPicker3PropertyValueEditor : DataValueEditor, IDataValueReference
{
private readonly IDataTypeService _dataTypeService;
private readonly IJsonSerializer _jsonSerializer;
private readonly ITemporaryMediaService _temporaryMediaService;
public MediaPicker3PropertyValueEditor(
ILocalizedTextService localizedTextService,
IShortStringHelper shortStringHelper,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
DataEditorAttribute attribute,
IDataTypeService dataTypeService,
ITemporaryMediaService temporaryMediaService)
: base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute)
{
_jsonSerializer = jsonSerializer;
_dataTypeService = dataTypeService;
_temporaryMediaService = temporaryMediaService;
}
/// <remarks>
/// Note: no FromEditor() and ToEditor() methods
/// We do not want to transform the way the data is stored in the DB and would like to keep a raw JSON string
/// </remarks>
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
{
foreach (MediaWithCropsDto dto in Deserialize(_jsonSerializer, value))
{
yield return new UmbracoEntityReference(Udi.Create(Constants.UdiEntityType.Media, dto.MediaKey));
}
}
public override object ToEditor(IProperty property, string? culture = null, string? segment = null)
{
var value = property.GetValue(culture, segment);
var dtos = Deserialize(_jsonSerializer, value).ToList();
IDataType? dataType = _dataTypeService.GetDataType(property.PropertyType.DataTypeId);
if (dataType?.Configuration != null)
{
MediaPicker3Configuration? configuration = dataType.ConfigurationAs<MediaPicker3Configuration>();
foreach (MediaWithCropsDto dto in dtos)
{
dto.ApplyConfiguration(configuration);
}
}
return dtos;
}
public override object? FromEditor(ContentPropertyData editorValue, object? currentValue)
{
if (editorValue.Value is JArray dtos)
{
if (editorValue.DataTypeConfiguration is MediaPicker3Configuration configuration)
{
dtos = PersistTempMedia(dtos, configuration);
}
// Clean up redundant/default data
foreach (JObject? dto in dtos.Values<JObject>())
{
MediaWithCropsDto.Prune(dto);
}
return dtos.ToString(Formatting.None);
}
return base.FromEditor(editorValue, currentValue);
}
internal static IEnumerable<MediaWithCropsDto> Deserialize(IJsonSerializer jsonSerializer, object? value)
{
var rawJson = value is string str ? str : value?.ToString();
if (string.IsNullOrWhiteSpace(rawJson))
{
yield break;
}
if (!rawJson.DetectIsJson())
{
// Old comma seperated UDI format
foreach (var udiStr in rawJson.Split(Constants.CharArrays.Comma))
{
if (UdiParser.TryParse(udiStr, out Udi? udi) && udi is GuidUdi guidUdi)
{
yield return new MediaWithCropsDto
{
Key = Guid.NewGuid(),
MediaKey = guidUdi.Guid,
Crops = Enumerable.Empty<ImageCropperValue.ImageCropperCrop>(),
FocalPoint = new ImageCropperValue.ImageCropperFocalPoint {Left = 0.5m, Top = 0.5m},
};
}
}
}
else
{
IEnumerable<MediaWithCropsDto>? dtos =
jsonSerializer.Deserialize<IEnumerable<MediaWithCropsDto>>(rawJson);
if (dtos is not null)
{
// New JSON format
foreach (MediaWithCropsDto dto in dtos)
{
yield return dto;
}
}
}
}
private JArray PersistTempMedia(JArray jArray, MediaPicker3Configuration mediaPicker3Configuration)
{
var result = new JArray();
foreach (JObject? dto in jArray.Values<JObject>())
{
if (dto is null)
{
continue;
}
if (!dto.TryGetValue("tmpLocation", out JToken? temporaryLocation))
{
// If it does not have a temporary path, it can be an already saved image or not-yet uploaded temp-image, check for media-key
if (dto.TryGetValue("mediaKey", out _))
{
result.Add(dto);
}
continue;
}
var temporaryLocationString = temporaryLocation.Value<string>();
if (temporaryLocationString is null)
{
continue;
}
GuidUdi? startNodeGuid = mediaPicker3Configuration.StartNodeId as GuidUdi ?? null;
JToken? mediaTypeAlias = dto.GetValue("mediaTypeAlias");
IMedia mediaFile = _temporaryMediaService.Save(temporaryLocationString, startNodeGuid?.Guid, mediaTypeAlias?.Value<string>());
MediaWithCropsDto? mediaDto = _jsonSerializer.Deserialize<MediaWithCropsDto>(dto.ToString());
if (mediaDto is null)
{
continue;
}
mediaDto.MediaKey = mediaFile.GetUdi().Guid;
result.Add(JObject.Parse(_jsonSerializer.Serialize(mediaDto)));
}
return result;
}
/// <summary>
/// Model/DTO that represents the JSON that the MediaPicker3 stores.
/// </summary>
[DataContract]
internal class MediaWithCropsDto
{
[DataMember(Name = "key")]
public Guid Key { get; set; }
[DataMember(Name = "mediaKey")]
public Guid MediaKey { get; set; }
[DataMember(Name = "crops")]
public IEnumerable<ImageCropperValue.ImageCropperCrop>? Crops { get; set; }
[DataMember(Name = "focalPoint")]
public ImageCropperValue.ImageCropperFocalPoint? FocalPoint { get; set; }
/// <summary>
/// Removes redundant crop data/default focal point.
/// </summary>
/// <param name="value">The media with crops DTO.</param>
/// <remarks>
/// Because the DTO uses the same JSON keys as the image cropper value for crops and focal point, we can re-use the
/// prune method.
/// </remarks>
public static void Prune(JObject? value) => ImageCropperValue.Prune(value);
/// <summary>
/// Applies the configuration to ensure only valid crops are kept and have the correct width/height.
/// </summary>
/// <param name="configuration">The configuration.</param>
public void ApplyConfiguration(MediaPicker3Configuration? configuration)
{
var crops = new List<ImageCropperValue.ImageCropperCrop>();
MediaPicker3Configuration.CropConfiguration[]? configuredCrops = configuration?.Crops;
if (configuredCrops != null)
{
foreach (MediaPicker3Configuration.CropConfiguration configuredCrop in configuredCrops)
{
ImageCropperValue.ImageCropperCrop? crop =
Crops?.FirstOrDefault(x => x.Alias == configuredCrop.Alias);
crops.Add(new ImageCropperValue.ImageCropperCrop
{
Alias = configuredCrop.Alias,
Width = configuredCrop.Width,
Height = configuredCrop.Height,
Coordinates = crop?.Coordinates,
});
}
}
Crops = crops;
if (configuration?.EnableLocalFocalPoint == false)
{
FocalPoint = null;
}
}
}
}
internal sealed class ApiMediaWithCrops : IApiMedia
{
private readonly IApiMedia _inner;
public ApiMediaWithCrops(
IApiMedia inner,
ImageCropperValue.ImageCropperFocalPoint? focalPoint,
IEnumerable<ImageCropperValue.ImageCropperCrop>? crops)
{
_inner = inner;
FocalPoint = focalPoint;
Crops = crops;
}
public Guid Id => _inner.Id;
public string Name => _inner.Name;
public string MediaType => _inner.MediaType;
public string Url => _inner.Url;
public string? Extension => _inner.Extension;
public int? Width => _inner.Width;
public int? Height => _inner.Height;
public int? Bytes => _inner.Bytes;
public IDictionary<string, object?> Properties => _inner.Properties;
public ImageCropperValue.ImageCropperFocalPoint? FocalPoint { get; }
public IEnumerable<ImageCropperValue.ImageCropperCrop>? Crops { get; }
}
public void ConfigureServices(IServiceCollection services)
{
services.AddUmbraco(_env, _config)
.AddBackOffice()
.AddWebsite()
.AddDeliveryApi()
.AddComposers()
.AddCustomApiMediaUrlProvider()
.Build();
}
using Umbraco.Cms.Core.DeliveryApi;
namespace UmbracoProject;
public static class UmbracoBuilderExtensions
{
public static IUmbracoBuilder AddCustomApiMediaUrlProvider(this IUmbracoBuilder builder)
{
builder.Services.AddSingleton<IApiMediaUrlProvider, CustomApiMediaUrlProvider>();
return builder;
}
}
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
namespace UmbracoProject;
public class ValueConverterComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
builder.PropertyValueConverters().Remove<MediaPickerWithCropsValueConverter>();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment