Alloy MVC with Optimizely Graph and 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. If you have your own Optimizely Graph account you can sync 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
First, create an Alloy site and configure it to use Optimizely Graph:
-
Install the following GraphQL extensions in Visual Studio Code. These extensions let you write GraphQL queries with IntelliSense against Optimizely Graph.
-
Add the Strawberry Shake CLI tools.
- Create a folder named 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 folder named ContentGraph:
-
Create Alloy MVC site.
- Create a folder for the site named 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
- Open your browser and go to the site (
https://localhost:5000
), add the admin user, and ensure the site works properly.
- Create a folder for the site named 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
- Open folder AlloyMvcGraphQL in Visual Studio Code, which contains the Alloy MVC site you created.
- Add Strawberry Shake Transport Http:
-
Update appsettings.json with the following after
"AllowedHosts": "\*"
:"Optimizely": { "ContentGraph": { "GatewayAddress": "https://cg.optimizely.com", "AppKey": "", "Secret": "", "SingleKey": "eBrGunULiC5TziTCtiOLEmov2LijBf30obh0KmhcBlyTktGZ", "AllowSendingLog": "true", "SynchronizationEnabled": false, } }
The default the datetime type in the GraphQL schema is
Date
. To optionally useDateTime
instead, setUseDateTimeGQLType
totrue
:"Optimizely": { "ContentGraph": { ... "UseDateTimeGQLType": true } }
Note
In this example, Optimizely uses the shared account with the
"SingleKey": "eBrGunULiC5TziTCtiOLEmov2LijBf30obh0KmhcBlyTktGZ"
. Content on an Alloy site has already been synchronized to this account.If you have you have an Optimizely Graph account, you can add your own
AppKey
,Secret
, andSingleKey
. -
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, visit: // https://docs.developers.optimizely.com/content-cloud/v1.5.0-content-delivery-api/docs/configuration services.AddContentGraph();
- Create a 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>
- In the
-
Build the project. Run:
dotnet build
Create an AI-driven search page
Use one of the following queries to create an AI-driven search page with a language model and machine learning behind the scenes.
- Query option 1 – Takes a generic
"where"
and"orderBy"
parameter. Makes it possible to define the filtering and ordering in C# code. - Query option 2 – Takes a simple query string as a parameter. Takes care of the filter and ordering logic in the GraphQL query.
-
Add Optimizely Graph queries.
- Create a folder named GraphQL:
mkdir GraphQL
- Query option 1 – Add a file named
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 } }
- Query option 2 – Add a file named
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 folder named GraphQL:
-
Build the project. Run:
dotnet build
-
Check to see that the generated file ContentGraphClient.Client.cs is in the obj/Debug/net6.0/berry/ folder.
-
Update Startup.cs to register the client.
-
Add the following in the
ConfigureServices
method:Note
If you have an Optimizely Graph account, you can use your own
singleKey
instead of the example OptimizelysingleKey
.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
-
-
In the Alloy search controller, update the search functionality using Optimizely Graph. The following code updates the
SearchPageController
in the Controller folder to make a request to Optimizely Graph, using one of the previously created queries.
The controller uses query option 2 with a query string parameter. The logic for query option 1 is commented out in this example. You can explore and test with each 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 does not support dashes (-) in enums. // All languages in the Locale enum has will have dashes (-) replaced with underscores (_). // For example, "en-Gb" is "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); } // Query option 2 private async Task<IOperationResult<ISearchContentByQueryParameterResult>> ExecuteQuery(Locales? locale, string query) { return await _contentGraphClient.SearchContentByQueryParameter.ExecuteAsync(locale, query); } // Query option 1 /* private async Task<IOperationResult<ISearchContentByGenericWhereClauseResult>> ExecuteQuery(Locales? locale, SitePageDataWhereInput where, SitePageDataOrderByInput orderBy) { return await _contentGraphClient.SearchContentByGenericWhereClause.ExecuteAsync(locale, where, orderBy); } */ }
-
Go to the search page and try it out.
- Start the site:
dotnet run
- Open your browser and go to:
https://localhost:5000/en/search
.
- Start the site:
Change implementation of the PageListBlockViewComponent
PageListBlockViewComponent
The Alloy site contains a listing of News. The implementation is done generically to enable listing any page type in the solution.
The implementation works well with a small amount of content. Another implementation is needed at a higher scale. We will change the implementation to support scalable listing.
-
Add Optimizely Graph queries:
Create a file namedPageListBlockRecursiveQuery.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 } }
Created a file named
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 } }
-
Build the project. Run:
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 the implementation of
PageListBlockViewComponent.cs
to use Optimizely Graph. There are two queries to filter content:"PageListBlockRecursiveQuery"
and"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> } }
-
Go to the search page and try it out.
- Start the site:
dotnet run
- Open your web browser and go to
https://localhost:5000/en/alloy-plan/
.
- Start the site:
Updated 9 months ago