Created
August 30, 2023 15:01
-
-
Save jbreuer/2c99bc8e691c135354cb145e4915f734 to your computer and use it in GitHub Desktop.
Umbraco Content Delivery API changes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; } | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public void ConfigureServices(IServiceCollection services) | |
{ | |
services.AddUmbraco(_env, _config) | |
.AddBackOffice() | |
.AddWebsite() | |
.AddDeliveryApi() | |
.AddComposers() | |
.AddCustomApiMediaUrlProvider() | |
.Build(); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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