Alloy MVC with Optimizely Graph & Strawberry Shake
Create a standard Alloy MVC sample site and use Optimizely Graph for AI driven search and lists.
This tutorial will use an existing Optimizely Graph account with Alloy content in read-only mode. You can use your own Optimizely Graph account if you have one, and synchronize content in your Alloy site to your Optimizely Graph account.
Visual Studio Code is used in this tutorial with some extensions, but you could use Visual Studio or another IDE.
Create Alloy site
We will begin with creating an Alloy site and configure it to use Optimizely Graph.
- Add GraphQL extensions to Visual Studio Code by installing the following extensions in Visual Studio Code:
- GraphQL: Language Feature Support
- GraphQL
These extensions in Visual Studio Code let you write GraphQL queries with IntelliSense against Optimizely Graph.
- Add the Strawberry Shake CLI tools.
- Create a new folder called ContentGraph:
mkdir ContentGraph
- Go to the folder ContentGraph:
cd ContentGraph
- Create a manifest file by running the dotnet new command:
dotnet new tool-manifest
- Install Strawberry Shake tools locally:
dotnet tool install StrawberryShake.Tools --local
- Create a new folder called ContentGraph:
- Create Alloy MVC site.
- Create a new folder for the site called AlloyMvcGraphQL:
mkdir AlloyMvcGraphQL
- Go to the folder AlloyMvcGraphQL:
cd AlloyMvcGraphQL
- Install dotnet EPiServerTemplates:
dotnet new --install "EPiServer.Templates"
- Create the Alloy MVC site:
dotnet new epi-alloy-mvc
- Add the latest Optimizely Content Management System (CMS) package:
dotnet add package EPiServer.Cms
- Start the site:
dotnet run
- Browse to the site, add the admin user, and make sure the site is working properly:
https://localhost:5000
- Create a new folder for the site called AlloyMvcGraphQL:
- Install the required packages for Optimizely Graph and Strawberry Shake.
- Add Strawberry Shake Transport Http:
dotnet add package StrawberryShake.Transport.Http
- Add Strawberry Shake Server, which generates the C# classes:
dotnet add package StrawberryShake.Server
- Add Microsoft Extensions DependencyInjection:
dotnet add package Microsoft.Extensions.DependencyInjection
- Add Microsoft Extensions Http:
dotnet add package Microsoft.Extensions.Http
- Add Optimizely ContentGraph CMS:
dotnet add package Optimizely.ContentGraph.Cms
- Add Strawberry Shake Transport Http:
- Open folder AlloyMvcGraphQL in Visual Studio Code, which contains the Alloy MVC site you created.
- Update appsettings.json with the following after "AllowedHosts": "*". We are using the shared account with SingleKey "eBrGunULiC5TziTCtiOLEmov2LijBf30obh0KmhcBlyTktGZ" in this example. Content on an Alloy site has already been synchronized to this account. You can add your own AppKey, Secret, and SingleKey if you have your own Optimizely Graph account.
, "Optimizely": { "ContentGraph": { "GatewayAddress": "https://cg.optimizely.com", "AppKey": "", "Secret": "", "SingleKey": "eBrGunULiC5TziTCtiOLEmov2LijBf30obh0KmhcBlyTktGZ", "AllowSendingLog": "true", "SynchronizationEnabled": false } }
- Update Startup.cs to use Optimizely Graph by adding
using EPiServer.DependencyInjection;
- In the
ConfigureServices
method add:services.ConfigureContentApiOptions(o => { o.IncludeInternalContentRoots = true; o.IncludeSiteHosts = true; o.EnablePreviewFeatures = true; o.SetValidateTemplateForContentUrl(true); }); services.AddContentDeliveryApi(); // required, for further configurations, please visit: https://docs.developers.optimizely.com/content-cloud/v1.5.0-content-delivery-api/docs/configuration services.AddContentGraph();
- Create GraphQL client to the Alloy site using the CLI tools; add the following to AlloyMvcGraphQL.csproj.
<Target Name="Generate client" BeforeTargets="BeforeBuild"> <Exec Command="dotnet graphql init https://cg.optimizely.com/content/v2?auth=eBrGunULiC5TziTCtiOLEmov2LijBf30obh0KmhcBlyTktGZ -n ContentGraphClient" /> </Target>
- Build the project by running
dotnet build
Update site to support querying SitePageData
Optimizely Graph can query any of your content types. What we want to do in this tutorial is to query the "SitePageData" type, which all page types in Alloy inherits from. Two things needs to be done for this to work. This has already been done for the shared account. You don't have to do the following two changes in case you are using the shared account. But you need to follow the steps if you are using your own Optimizely Graph account:
- Change SitePageData to be a real content type, and hide it from the UI
//public abstract class SitePageData : PageData, ICustomCssInContentArea
[ContentType(AvailableInEditMode = false)]
public class SitePageData : PageData, ICustomCssInContentArea
{
- Add custom ContentType implementation, to support querying base types and get results from inherited types. Add a new class in Alloy with name
CustomContentTypeContentApiModelProperty.cs
:
using EPiServer.ContentApi.Core.Serialization.Models;
using EPiServer.ServiceLocation;
using Optimizely.ContentGraph.Cms.Core.ContentApiModelProperties.Internal;
namespace AlloyGraph
{
[ServiceConfiguration(typeof(IContentApiModelProperty), Lifecycle = ServiceInstanceScope.Singleton)]
public class CustomContentTypeContentApiModelProperty : IContentApiModelProperty
{
private readonly IContentTypeRepository _contentTypeRepository;
private readonly IContentLoader _contentLoader;
public CustomContentTypeContentApiModelProperty()
: this(ServiceLocator.Current.GetInstance<IContentTypeRepository>(),
ServiceLocator.Current.GetInstance<IContentLoader>())
{
}
public CustomContentTypeContentApiModelProperty(
IContentTypeRepository contentTypeRepository,
IContentLoader contentLoader)
{
_contentLoader = contentLoader;
_contentTypeRepository = contentTypeRepository;
}
public string Name => "ContentType";
public object GetValue(ContentApiModel contentApiModel)
{
var contentType = GetContentType(contentApiModel);
if (contentType == null)
{
return contentApiModel.ContentType;
}
var abstractTypes = new List<Type>();
AddBaseTypes(contentType.ModelType, ref abstractTypes);
contentApiModel.ContentType.AddRange(abstractTypes.Select(x => x.Name));
contentApiModel.ContentType.Add("Content");
return contentApiModel.ContentType.Distinct().ToList();
}
private void AddBaseTypes(Type type, ref List<Type> types)
{
if (type?.BaseType != null && type.BaseType != type && type.BaseType != typeof(IContent) && type.BaseType != typeof(Object))
{
types.Add(type.BaseType);
AddBaseTypes(type.BaseType, ref types);
}
}
private ContentType GetContentType(ContentApiModel contentApiModel)
{
if (contentApiModel?.ContentLink?.Id is null or 0)
{
return null;
}
var contentReference = new ContentReference(contentApiModel.ContentLink.Id.Value, contentApiModel.ContentLink?.WorkId.GetValueOrDefault() ?? 0, contentApiModel.ContentLink?.ProviderName);
if (_contentLoader.TryGet<IContent>(contentReference, out var content))
{
var contentType = _contentTypeRepository.Load(content.ContentTypeID);
if (contentType != null)
{
return contentType;
}
}
return null;
}
}
}
You will need to rebuild the site and start the site if you are using your own account. You will also have to start the scheduled job, to re-synchronize all content see the documentation on Scheduled synchronization.
Create AI driven search page
We will create an AI driven search page, which uses a language model and machine learning behind the scene. We are going to create two different queries, and you can choose which one you want to use.
The first query will take a generic "where" parameter and a generic "orderBy" parameter as input, while the other query will take a simple query string as parameter. The first query will make it possible for you to define your filtering and ordering in C# code, while the second query takes care of the logic in the GraphQL query.
-
Add Optimizely Graph queries.
- Create a new folder called GraphQL:
mkdir GraphQL
- Add a new file with name
SearchContentByGenericWhereClause.graphql
in the GraphQL folder, and add the following query in the file:query SearchContentByGenericWhereClause( $locale: Locales = ALL, $where: SitePageDataWhereInput={}, $orderBy: SitePageDataOrderByInput = { _ranking: SEMANTIC } ) { SitePageData( locale: [$locale] where: $where orderBy: $orderBy ) { items { Name RelativePath TeaserText } total } }
- Add a new file with name
SearchContentByQueryParameter.graphql
in the GraphQL folder, and add the following query in the file:query SearchContentByQueryParameter( $locale: Locales = ALL, $query: String ) { SitePageData( locale: [$locale] where: { _fulltext: { match: $query } } orderBy: { _ranking: SEMANTIC } ) { items { Name RelativePath TeaserText } total } }
- Create a new folder called GraphQL:
-
Build the project by running
dotnet build
. -
Check to see that the generated file ContentGraphClient.Client.cs is under the obj/Debug/net6.0/berry/ folder.
-
Update Startup.cs to register the client.
- Add the following in
ConfigureServices
method. We are using the shared singleKey here, but you can use your own singleKey instead, if you have your own Optimizely Graph account.services .AddContentGraphClient() .ConfigureHttpClient(client => client.BaseAddress = new Uri("https://cg.optimizely.com/content/v2?auth=eBrGunULiC5TziTCtiOLEmov2LijBf30obh0KmhcBlyTktGZ"));
- Build the project again to update the GraphQL client:
dotnet build
- Add the following in
-
Update search functionality in Alloy using Optimizely Graph in the search controller in Alloy. The following code updates
SearchPageController
in Controller folder to make a request to Optimizely Graph, using one of the queries we created earlier. The controller is currently using the query with a query string parameter, but we have also created logic for the other query in this example. You can test both to see which one you like the most.using AlloyGraph.Models.Pages; using AlloyGraph.Models.ViewModels; using Microsoft.AspNetCore.Mvc; using StrawberryShake; namespace AlloyGraph.Controllers; public class SearchPageController : PageControllerBase<SearchPage> { private readonly IContentGraphClient _contentGraphClient; private static Lazy<LocalesSerializer> _lazyLocaleSerializer = new Lazy<LocalesSerializer>(() => new LocalesSerializer()); public SearchPageController(IContentGraphClient contentGraphClient) { _contentGraphClient = contentGraphClient; } public ViewResult Index(SearchPage currentPage, string q) { var searchHits = new List<SearchContentModel.SearchHit>(); var total = 0; if (q != null) { // GraphQL don't support - in enums. All languages in the Locale enum has will have - replaced with _ for example en_Gb. var locale = _lazyLocaleSerializer.Value.Parse(currentPage.Language.TwoLetterISOLanguageName.Replace("-","_")); var result = ExecuteQuery(locale, q).GetAwaiter().GetResult(); /* var result = ExecuteQuery( locale, where: new SitePageDataWhereInput { _fulltext = new SearchableStringFilterInput { Match = q } }, orderBy: new SitePageDataOrderByInput { _ranking = Ranking.Relevance } ) .GetAwaiter().GetResult(); */ foreach (var item in result.Data.SitePageData.Items) { searchHits.Add(new SearchContentModel.SearchHit() { Title = item.Name, Url = item.RelativePath, Excerpt = item.TeaserText }); ; } total = result.Data.SitePageData.Total.GetValueOrDefault(); } var model = new SearchContentModel(currentPage) { Hits = searchHits, NumberOfHits = total, SearchServiceDisabled = false, SearchedQuery = q }; return View(model); } private async Task<IOperationResult<ISearchContentByQueryParameterResult>> ExecuteQuery(Locales? locale, string query) { return await _contentGraphClient.SearchContentByQueryParameter.ExecuteAsync(locale, query); } /* private async Task<IOperationResult<ISearchContentByGenericWhereClauseResult>> ExecuteQuery(Locales? locale, SitePageDataWhereInput where, SitePageDataOrderByInput orderBy) { return await _contentGraphClient.SearchContentByGenericWhereClause.ExecuteAsync(locale, where, orderBy); } */ }
-
Browse to the search page and try it out.
- Start the site:
dotnet run
. - Open your browser and navigate to
https://localhost:5000/en/search
.
- Start the site:
Change implementation of PageListBlockViewComponent
The Alloy site contains listing of News. The implementation has been done in a generic way, to support listing of any page type in the solution.
The implementation works good with small amount of content. Another implementation is needed at higher scale. We will change the implementation to support scalable listing.
- Add Optimizely Graph queries.
-
- Add a new file with name
PageListBlockRecursiveQuery.graphql
in the GraphQL folder, and add the following query in the file:query PageListBlockRecursiveQuery( $locale: Locales = ALL, $rootGuid: String, $pageTypes: [String], $categories: [Int], $count: Int = 3, $includePublishDate:Boolean = true, $includeIntroduction:Boolean = true, $orderBy: SitePageDataOrderByInput = { StartPublish: DESC } ) { SitePageData( locale: [$locale] where: { Ancestors: { eq: $rootGuid } ContentType: { in: $pageTypes } Category: { Id: { in: $categories } } } limit: $count orderBy: $orderBy ) { items { Name StartPublish @include(if: $includePublishDate) TeaserText @include(if: $includeIntroduction) Category { Name } RelativePath PageImage { Url } MetaDescription } total } }
- Add a new file with name
PageListBlockWithWhereQuery.graphql
in the GraphQL folder, and add the following query in the file:query PageListBlockWithWhereQuery( $locale: Locales = ALL, $where: SitePageDataWhereInput={}, $count: Int = 3, $includePublishDate:Boolean = true, $includeIntroduction:Boolean = true, $orderBy: SitePageDataOrderByInput = { StartPublish: DESC } ) { SitePageData( locale: [$locale] where: $where limit: $count orderBy: $orderBy ) { items { Name StartPublish @include(if: $includePublishDate) TeaserText @include(if: $includeIntroduction) Category { Name } RelativePath PageImage { Url } MetaDescription } total } }
- Add a new file with name
-
- Build the project by running
dotnet build
. - Modify
PageListModel
to handleIPageListBlockRecursiveQuery_SitePageData
instead ofIEnumerable<PageData>
.
using AlloyGraph.Models.Blocks;
namespace AlloyGraph.Models.ViewModels;
public class PageListModel
{
public PageListModel(PageListBlock block)
{
Heading = block.Heading;
ShowIntroduction = block.IncludeIntroduction;
ShowPublishDate = block.IncludePublishDate;
}
public string Heading { get; set; }
//public IEnumerable<PageData> Pages { get; set; }
public IPageListBlockRecursiveQuery_SitePageData ListResult { get; set; }
public bool ShowIntroduction { get; set; }
public bool ShowPublishDate { get; set; }
}
- Change implementation of
PageListBlockViewComponent.cs
to use Optimizely Graph. We have created two different ways of filtering content, one using the query "PageListBlockRecursiveQuery" and one using query "PageListBlockWithWhereQuery".
using AlloyGraph.Models.Blocks;
using AlloyGraph.Models.ViewModels;
using EPiServer.Filters;
using EPiServer.Web.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace AlloyGraph.Components;
public class PageListBlockViewComponent : BlockComponent<PageListBlock>
{
private readonly IContentLoader _contentLoader;
private readonly IContentGraphClient _contentGraphClient;
private static Lazy<LocalesSerializer> _lazyLocaleSerializer = new Lazy<LocalesSerializer>(() => new LocalesSerializer());
public PageListBlockViewComponent(IContentGraphClient contentGraphClient, IContentLoader contentLoader)
{
_contentGraphClient = contentGraphClient;
_contentLoader = contentLoader;
}
protected override IViewComponentResult InvokeComponent(PageListBlock currentContent)
{
var localizableContent = currentContent as ILocalizable;
var locale = localizableContent == null ? Locales.All : _lazyLocaleSerializer.Value.Parse(localizableContent.Language.TwoLetterISOLanguageName.Replace("-", "_"));
var rootGuid = _contentLoader.Get<IContent>(currentContent.Root).ContentGuid.ToString();
var pageTypes = new string[] { currentContent.PageTypeFilter.Name };
var categories = currentContent?.CategoryFilter?.Select(x => (int?)x).ToArray();
var sortOrder = GetSortOrder(currentContent.SortOrder);
var listResult = FilterPagesUsingMoreParameters(currentContent, locale, pageTypes, categories, sortOrder);
//var listResult = FilterPagesUsingWhere(currentContent, locale, pageTypes, categories, sortOrder);
var model = new PageListModel(currentContent) { ListResult = listResult };
ViewData.GetEditHints<PageListModel, PageListBlock>()
.AddConnection(x => x.Heading, x => x.Heading);
return View(model);
}
private IPageListBlockRecursiveQuery_SitePageData FilterPagesUsingMoreParameters(PageListBlock currentContent, Locales locale, string[] pageTypes, int?[] categories, SitePageDataOrderByInput sortOrder)
{
var rootGuid = GetContentGuid(currentContent);
var listResult = _contentGraphClient.PageListBlockRecursiveQuery.ExecuteAsync(
locale,
rootGuid,
pageTypes,
categories,
currentContent.Count,
currentContent.IncludePublishDate,
currentContent.IncludeIntroduction,
sortOrder)
.GetAwaiter().GetResult();
return listResult.Data.SitePageData;
}
/*
private IPageListBlockWithWhereQuery_SitePageData FilterPagesUsingWhere(PageListBlock currentContent, Locales locale, string[] pageTypes, int?[] categories, SitePageDataOrderByInput sortOrder)
{
var andFilter = new List<SitePageDataWhereInput>
{
new SitePageDataWhereInput { ContentType = new StringFilterInput { In = pageTypes } },
new SitePageDataWhereInput { Category = new CategoryModelWhereInput { Id = new IntFilterInput { In = categories } } }
};
if (currentContent.Recursive)
{
var rootGuid = GetContentGuid(currentContent);
andFilter.Add(new SitePageDataWhereInput { Ancestors = new StringFilterInput { Eq = rootGuid } });
}
else
{
andFilter.Add(new SitePageDataWhereInput { ParentLink = new ContentModelReferenceWhereInput { Id = new IntFilterInput { Eq = currentContent.Root.ID } } });
}
var listResult = _contentGraphClient.PageListBlockWithWhereQuery.ExecuteAsync(
locale,
where: new SitePageDataWhereInput { _and = andFilter },
currentContent.Count,
currentContent.IncludePublishDate,
currentContent.IncludeIntroduction,
sortOrder)
.GetAwaiter().GetResult();
return listResult.Data.SitePageData;
}
*/
private string GetContentGuid(PageListBlock currentContent)
{
return _contentLoader.Get<IContent>(currentContent.Root).ContentGuid.ToString();
}
private SitePageDataOrderByInput GetSortOrder(FilterSortOrder sortOrder)
{
switch (sortOrder)
{
case FilterSortOrder.ChangedDescending:
{
return new SitePageDataOrderByInput { Changed = OrderBy.Desc };
}
case FilterSortOrder.CreatedAscending:
{
return new SitePageDataOrderByInput { Created = OrderBy.Asc };
}
case FilterSortOrder.CreatedDescending:
{
return new SitePageDataOrderByInput { Created = OrderBy.Desc };
}
case FilterSortOrder.Index:
{
return new SitePageDataOrderByInput { _ranking = Ranking.Relevance };
}
case FilterSortOrder.PublishedAscending:
{
return new SitePageDataOrderByInput { StartPublish = OrderBy.Asc };
}
case FilterSortOrder.PublishedDescending:
{
return new SitePageDataOrderByInput { StartPublish = OrderBy.Desc };
}
default:
{
return new SitePageDataOrderByInput { Name = OrderBy.Asc };
}
}
}
}
- Update the
View
for thePageListBlock
Located in theDefualt.cshtml
file:
Views
->Shared
->PageListBlock
->Default.cshtml
@model PageListModel
@{
var isAside = ViewData["Aside"] != null && (bool)ViewData["Aside"];
}
@Html.FullRefreshPropertiesMetaData(new[] { "IncludePublishDate", "IncludeIntroduction", "Count", "SortOrder", "Root", "PageTypeFilter", "CategoryFilter", "Recursive" })
<h2 epi-property="@Model.Heading">@Model.Heading</h2>
@foreach (var item in Model.ListResult.Items)
{
if (isAside)
{
<div class="listResult @string.Join(" ", item.Category?.Select(x=>x.Name)?.GetThemeCssClassNames())">
<h3><a href="@item.RelativePath" alt="@item.Name">@item.Name</a></h3>
@if (Model.ShowPublishDate)
{
<p class="small date">@Html.DisplayFor(x => item.StartPublish)</p>
}
@if (Model.ShowIntroduction)
{
<p>@item.TeaserText</p>
}
</div>
}
else
{
<div class="archive-item @string.Join(" ", item.Category?.Select(x=>x.Name)?.GetThemeCssClassNames())">
<a href="@item.RelativePath">
<div class="row">
<div class="col-4">
@if (item.PageImage?.Url != null)
{
<img src="@Url.ContentUrl(item.PageImage.Url)" alt="@item.Name" />
}
else
{
<div class="placeholder"></div>
}
</div>
<div class="col-8 col-xl-6">
<h3>@(item.Name)</h3>
@if (Model.ShowPublishDate && item.StartPublish.HasValue)
{
<p class="small date">@Html.DisplayFor(x => item.StartPublish)</p>
}
@if (Model.ShowIntroduction)
{
<p>@(item.TeaserText ?? item.MetaDescription)</p>
}
</div>
</div>
</a>
</div>
}
}
- Browse to the search page and try it out.
- Start the site by running
dotnet run
. - Open your web browser and navigate to
https://localhost:5000/en/alloy-plan/
.
- Start the site by running
Updated about 1 month ago