Customizing fallback for Umbraco

Customizing fallback for Umbraco

Fallback is one of the lesser-known extension points in Umbraco 🧐

In short, fallback allows for deciding what to output for a property that has no value. This is a quite powerful feature, particularly when rendering for language and/or segment variance.

In this post I’ll elaborate a little on how the fallback behaviour can be customized.

I have created a GitHub repo with a working sample of the code below. If you want to try it out, the Umbraco admin login is (per usual):

  • Username: admin@localhost
  • Password: SuperSecret123

Built-in fallback support

Umbraco comes with a bunch of built-in fallback options, all of which work fine in their own right. There are a few caveats, though.

First and foremost, they require explicit opt-in at render time. This is great if fallback is a rarity - not so great if fallback is wanted throughout all renderings.

For reference, here’s how to render a property value with fallback to a configured fallback language:

Model.Value<string>("title", fallback: Fallback.ToLanguage)

Secondly, fallback does not work when using Models Builder models for rendering, due to the need for explicit opt-in 🙁

Thirdly, the built-in fallback has no options for segmented content. It would be nice to have the ability to perform value fallback to the default segment.

And lastly: The Delivery API does allow for specifying fallback options at query time.

A custom fallback solution

To customize the fallback behaviour, one must provide a custom implementation of the IPublishedValueFallback interface.

The interface looks a little scary at first glance with lots of different methods and overloads… but at the end of the day, everything boils down to getting a value from an instance of IPublishedProperty.

For my custom implementation, I want a fallback logic that automatically performs the following language fallback:

  1. Look for property values in the explicitly configured language fallbacks.
  2. Default to the property value for the default language.

Note that fallbacks can be “chained”, so depending on the configuration, it might be necessary to traverse languages in order to find the appropriate fallback value.

I’ll start by introducing a handler to do the heavy lifting 🏋️️

using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Services;

namespace Site.Services;

public class PublishedPropertyFallbackHandler : IPublishedPropertyFallbackHandler
{
    private readonly ILanguageService _languageService;
    private readonly IVariationContextAccessor _variationContextAccessor;

    public PublishedPropertyFallbackHandler(ILanguageService languageService, IVariationContextAccessor variationContextAccessor)
    {
        _languageService = languageService;
        _variationContextAccessor = variationContextAccessor;
    }

    public async Task<string?> GetFallbackCultureAsync(IPublishedProperty property)
    {
        // sanity check the property before proceeding
        if (property.PropertyType.VariesByCulture() is false)
        {
            return null;
        }

        string? fallbackCulture = null;

        // get the requested (contextual) culture
        var requestedCulture = _variationContextAccessor.VariationContext?.Culture;

        if (requestedCulture.IsNullOrWhiteSpace() is false)
        {
            // traverse explicit language fallback configurations (if applicable)
            var cultureToAttempt = requestedCulture;
            while (fallbackCulture is null)
            {
                // get the language configuration for the culture
                var language = await _languageService.GetAsync(cultureToAttempt);

                // break if the language itself does not have a configured fallback language
                if (language?.FallbackIsoCode is null)
                {
                    break;
                }

                // if the property has a value for the fallback language, use the fallback language culture
                if (property.HasValue(culture: language.FallbackIsoCode))
                {
                    fallbackCulture = language.FallbackIsoCode;
                }

                cultureToAttempt = language.FallbackIsoCode;
            }
        }

        if (fallbackCulture is null)
        {
            // no explicit fallback language configured, try the default culture
            var defaultCulture = await _languageService.GetDefaultIsoCodeAsync();

            // if the default culture was not requested, and the property has a value for the default
            // culture, use that for fallback
            if (defaultCulture.InvariantEquals(requestedCulture) is false && property.HasValue(culture: defaultCulture))
            {
                fallbackCulture = defaultCulture;
            }
        }

        return fallbackCulture;
    }
}

Now I can implement IPublishedValueFallback using this handler.

using Umbraco.Cms.Core.Models.PublishedContent;

namespace Site.Services;

public class CustomPublishedValueFallback : IPublishedValueFallback
{
    private readonly IPublishedPropertyFallbackHandler _fallbackHandler;
    private readonly IPublishedValueFallback _noopFallback = new NoopPublishedValueFallback();

    public bool TryGetValue<T>(IPublishedProperty property, string? culture, string? segment, Fallback fallback, T? defaultValue, out T? value)
        => TryGetValueWithCultureFallback(property, culture, out value);

    // NOTE: the other IPublishedValueFallback methods have been removed for abbreviation
    
    private bool TryGetValueWithCultureFallback<T>(IPublishedProperty property, string? culture, out T? value)
    {
        // if a specific culture was requested, don't attempt a fallback to another culture
        if (culture.IsNullOrWhiteSpace() is false)
        {
            value = default;
            return false;
        }

        // get the fallback culture for the property (if any)
        var fallbackCulture = _fallbackHandler.GetFallbackCultureAsync(property).GetAwaiter().GetResult();
        if (fallbackCulture is null)
        {
            // no fallback culture or no property value for the fallback culture
            value = default;
            return false;
        }

        // get the property value for the fallback culture - prevent further fallback handling by using:
        // - the no-op published value fallback implementation (from core)
        // - explicit Fallback.None as fallback option
        value = property.Value<T>(_noopFallback, fallbackCulture, null, Fallback.To(Fallback.None));
        return true;
    }
}

Now all that remains is to register the handler and the fallback implementation:

using Site.Services;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Models.PublishedContent;

namespace Site.Composition;

public class SiteComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        // replace core services
        builder.Services.AddUnique<IPublishedValueFallback, CustomPublishedValueFallback>();
 
        // register own services
        builder.Services.AddSingleton<IPublishedPropertyFallbackHandler, PublishedPropertyFallbackHandler>();
    }
}

Fallback for the Delivery API

As mentioned, there’s no way to specify fallback explicitly when querying the Delivery API.

Fortunately, there’s a quite simple solution to achieve the same custom fallback functionality as above: Implementing a custom IApiPropertyRenderer 🤓

The IApiPropertyRenderer is responsible for rendering all properties in the Delivery API output. And now that I have a handler to figure out the fallback culture for a given property, the implementation becomes quite simple:

using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;

namespace Site.Services;

public class CustomApiPropertyRenderer : IApiPropertyRenderer
{
    private readonly IPublishedPropertyFallbackHandler _fallbackHandler;

    public CustomApiPropertyRenderer(IPublishedPropertyFallbackHandler fallbackHandler)
        => _fallbackHandler = fallbackHandler;

    public object? GetPropertyValue(IPublishedProperty property, bool expanding)
    {
        // first try the default handling
        if (property.HasValue())
        {
            return property.GetDeliveryApiValue(expanding);
        }

        var fallbackCulture = _fallbackHandler
            .GetFallbackCultureAsync(property)
            .GetAwaiter()
            .GetResult();

        // attempt to fetch the property value for the fallback culture
        return fallbackCulture is not null
            ? property.GetDeliveryApiValue(expanding, culture: fallbackCulture)
            : null;
    }
}

Remember to register the custom implementation in place of the default one, by amending the composer like this:

// ...
// 👇 add this using 👇
using Umbraco.Cms.Core.DeliveryApi;

namespace Site.Composition;

public class SiteComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        // ...
        // 👇 add this line 👇
        builder.Services.AddUnique<IPublishedValueFallback, CustomPublishedValueFallback>();
    }
}

What about segments?

You can apply the same patters for segments as I have outlined for cultures here.

The only real difference is that Umbraco offers no fallback segment configuration, because segments aren’t managed entities.

…so, in conclusion…

I think the ability to handle property value fallback programmatically is pretty neat. It’s an awesome extension point 🤘

A word of caution, though: The property value fallback is potentially executed for each and every property being rendered. Thus, performance is key; make sure your custom implementation is optimized and fine-tuned for maximum throughput.

Happy falling back 💜