Umbraco Content Delivery API – Compose layout from multiple sources

One of the generic requirements for a website is to able easily create new pages without adding the common components (such as footer, header, navigation, search bar, etc.) to all newly created pages manually. My idea is to compose the final page layout response from multiple Umbraco items using Block Grid Editor. In my simple example I would like to add the header and footer components from a shared node to all pages. First, let’s build up the following item structure in Umbraco.

Header item
Footer item
Home item

The challenge is how to merge these partial pages together. For this I implemented a type of Strategy Pattern. Which means, the concrete implementation of the different parts (header, content, footer) of the page are independent.

Partial layout implementation (header, footer)

The basic idea here is that one component can define the Order and resolve the partial content item. The ResolveContent method is really simple in my case, but additional logic could be added, e.g. based on the page type, resolve different header and footer. The Order property defines the order of the resolver – if it’s lower than zero then it’s on top of the page, if it’s bigger than zero, then it’s after the dynamic default content resolver.

public class FooterResolver : IComponentResolver
{
public int Order => 100;
private readonly IApiPublishedContentCache _apiPublishedContentCache;
public FooterResolver(IApiPublishedContentCache apiPublishedContentCache)
{
_apiPublishedContentCache = apiPublishedContentCache;
}
public IPublishedContent ResolveContent(IPublishedContent content)
{
return _apiPublishedContentCache.GetByRoute("/site-data/page-frames/footer/");
}
}
public class HeaderResolver : IComponentResolver
{
public int Order => -100;
private readonly IApiPublishedContentCache _apiPublishedContentCache;
public HeaderResolver(IApiPublishedContentCache apiPublishedContentCache)
{
_apiPublishedContentCache = apiPublishedContentCache;
}
public IPublishedContent ResolveContent(IPublishedContent content)
{
return _apiPublishedContentCache.GetByRoute("/site-data/page-frames/header/");
}
}
public interface IComponentResolver
{
int Order { get; }
IPublishedContent ResolveContent(IPublishedContent content);
}

Layout builder – the composer

The next step is to build the merged response. For that, I need to overwrite the default ApiContentBuilderBase and add my implementation to resolve the partial layouts from other spaces (the header and footer).

Important note: the field which holds the layout definition for the page should be called layout as it is hardcoded in the implementation currently.

public abstract class LayoutBuilderBase<T> : ApiContentBuilderBase<T> where T : IApiContent
{
private const string LayoutPropertyKey = "layout";
protected readonly IEnumerable<IComponentResolver> ComponentResolvers;
protected readonly IApiPublishedContentCache ApiPublishedContentCache;
protected LayoutBuilderBase(
IApiContentNameProvider apiContentNameProvider,
IApiContentRouteBuilder apiContentRouteBuilder,
IOutputExpansionStrategyAccessor outputExpansionStrategyAccessor,
IEnumerable<IComponentResolver> componentResolvers,
IApiPublishedContentCache apiPublishedContentCache)
: base(apiContentNameProvider, apiContentRouteBuilder, outputExpansionStrategyAccessor)
{
ComponentResolvers = componentResolvers;
ApiPublishedContentCache = apiPublishedContentCache;
}
public override T? Build(IPublishedContent content)
{
var currentItem = base.Build(content);
if (currentItem == null)
{
return currentItem;
}
if (LayoutPropertyKey == null || currentItem.Properties[LayoutPropertyKey] == null)
{
return currentItem;
}
var currentLayout = currentItem.Properties[LayoutPropertyKey] as ApiBlockGridModel;
if (currentLayout?.Items == null || !currentLayout.Items.Any())
{
return currentItem;
}
var currentAreas = new List<ApiBlockGridArea>(currentLayout.Items.First().Areas ?? Array.Empty<ApiBlockGridArea>());
ResolvePreComponents(content, LayoutPropertyKey, ref currentAreas);
ResolvePostComponents(content, LayoutPropertyKey, ref currentAreas);
var currentLayoutItems = new List<ApiBlockGridItem>(currentLayout.Items);
currentLayoutItems[0] = new ApiBlockGridItem(
currentLayoutItems[0].Content,
currentLayoutItems[0].Settings,
currentLayoutItems[0].RowSpan,
currentLayoutItems[0].ColumnSpan,
currentLayoutItems[0].AreaGridColumns,
currentAreas);
var composedLayout = new ApiBlockGridModel(currentLayout.GridColumns, currentLayoutItems);
currentItem.Properties[LayoutPropertyKey] = composedLayout;
return currentItem;
}
private void ResolvePreComponents(IPublishedContent content, string layoutPropertyKey, ref List<ApiBlockGridArea> currentAreas)
{
foreach (var componentResolver in ComponentResolvers.Where(x => x.Order < 0).OrderByDescending(x => x.Order))
{
var partialPageAreas = GetPartialPageAreas(componentResolver, content, layoutPropertyKey);
if (partialPageAreas == null)
{
continue;
}
foreach (var area in partialPageAreas)
{
var foundArea = currentAreas.FirstOrDefault(a => a.Alias == area.Alias);
if (foundArea != null)
{
var index = currentAreas.IndexOf(foundArea);
var tempItems = foundArea.Items.ToList();
tempItems.InsertRange(0, area.Items);
currentAreas[index] = new ApiBlockGridArea(foundArea.Alias, foundArea.RowSpan, foundArea.ColumnSpan, tempItems);
}
else
{
currentAreas.Insert(0, area);
}
}
}
}
private void ResolvePostComponents(IPublishedContent content, string layoutPropertyKey, ref List<ApiBlockGridArea> currentAreas)
{
foreach (var componentResolver in ComponentResolvers.Where(x => x.Order >= 0).OrderBy(x => x.Order))
{
var partialPageAreas = GetPartialPageAreas(componentResolver, content, layoutPropertyKey);
if (partialPageAreas == null)
{
continue;
}
foreach (var area in partialPageAreas)
{
var foundArea = currentAreas.FirstOrDefault(a => a.Alias == area.Alias);
if (foundArea != null)
{
var index = currentAreas.IndexOf(foundArea);
var tempItems = foundArea.Items.ToList();
tempItems.AddRange(area.Items);
currentAreas[index] = new ApiBlockGridArea(foundArea.Alias, foundArea.RowSpan, foundArea.ColumnSpan, tempItems);
}
else
{
currentAreas.Add(area);
}
}
}
}
private IEnumerable<ApiBlockGridArea>? GetPartialPageAreas(IComponentResolver componentResolver, IPublishedContent content, string layoutPropertyKey)
{
var partialPage = base.Build(componentResolver.ResolveContent(content));
if (partialPage == null)
{
return null;
}
var partialPageLayout = partialPage.Properties[layoutPropertyKey] as ApiBlockGridModel;
var partialPageAreas = partialPageLayout?.Items?.First().Areas;
if (partialPageAreas == null)
{
return null;
}
return partialPageAreas;
}
}

I also had to reimplement and replace the default ApiContentResponseBuilder to use my customized ApiContentBuilderBase class. Therefore, I created the ExtendedApiContentResponseBuilder which is almost a full copy of the original class, but with the difference that it’s inherited from the LayoutBuilderBase. This is needed unfortunately to use my new Build method, which resolves partial layouts.

public class ExtendedApiContentResponseBuilder : LayoutBuilderBase<IApiContentResponse>, IApiContentResponseBuilder
{
private readonly IApiContentRouteBuilder _apiContentRouteBuilder;
public ExtendedApiContentResponseBuilder(
IApiContentNameProvider apiContentNameProvider,
IApiContentRouteBuilder apiContentRouteBuilder,
IOutputExpansionStrategyAccessor outputExpansionStrategyAccessor,
IEnumerable<IComponentResolver> componentResolvers,
IApiPublishedContentCache apiPublishedContentCache)
: base(apiContentNameProvider, apiContentRouteBuilder, outputExpansionStrategyAccessor, componentResolvers, apiPublishedContentCache)
{
_apiContentRouteBuilder = apiContentRouteBuilder;
}
protected override IApiContentResponse Create(IPublishedContent content, string name, IApiContentRoute route, IDictionary<string, object?> properties)
{
var cultures = GetCultures(content);
return new ApiContentResponse(
content.Key,
name,
content.ContentType.Alias,
content.CreateDate,
content.UpdateDate,
route,
properties,
cultures);
}
/// <summary>
/// Copied from the original ApiContentResponseBuilder
/// </summary>
private Dictionary<string, IApiContentRoute> GetCultures(IPublishedContent content)
{
var routesByCulture = new Dictionary<string, IApiContentRoute>();
foreach (var publishedCultureInfo in content.Cultures.Values)
{
if (publishedCultureInfo.Culture.IsNullOrWhiteSpace())
{
continue;
}
var cultureRoute = _apiContentRouteBuilder.Build(content, publishedCultureInfo.Culture);
if (cultureRoute == null)
{
continue;
}
routesByCulture[publishedCultureInfo.Culture] = cultureRoute;
}
return routesByCulture;

Last but not least, let’s have a look at my modified Startup class, focusing on only the changes needed for the partial layout resolving.

public class Startup
{
...
public void ConfigureServices(IServiceCollection services)
{
...
// Overwrite the default ApiContentResponseBuilder to resolve partial layouts
services.AddSingleton<IApiContentResponseBuilder, ExtendedApiContentResponseBuilder>();
services.AddSingleton<IComponentResolver, HeaderResolver>();
services.AddSingleton<IComponentResolver, FooterResolver>();
}
}
view raw Startup.cs hosted with ❤ by GitHub

I hope this gives you an idea how this basic functionality can be achieved by a touching few parts of the original Umbraco implementation and without being too hacky. I would wish that Umbraco makes it a bit more easier in the future to able extend and overwrite, but it is on a good path.

Leave a comment