HomeDev guideRecipesAPI ReferenceGraphQL
Dev guideUser GuideGitHubNuGetDev CommunitySubmit a ticketLog In
GitHubNuGetDev CommunitySubmit a ticket

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:

  1. Install the following GraphQL extensions in Visual Studio Code. These extensions let you write GraphQL queries with IntelliSense against Optimizely Graph.

  2. Add the Strawberry Shake CLI tools.

    1. Create a folder named ContentGraph:
      mkdir ContentGraph
      
    2. Go to the folder ContentGraph:
      cd ContentGraph
      
    3. Create a manifest file by running the dotnet new command:
      dotnet new tool-manifest
      
    4. Install Strawberry Shake tools locally:
      dotnet tool install StrawberryShake.Tools --local
      
  3. Create Alloy MVC site.

    1. Create a folder for the site named AlloyMvcGraphQL:
      mkdir AlloyMvcGraphQL
      
    2. Go to the folder AlloyMvcGraphQL:
      cd AlloyMvcGraphQL
      
    3. Install dotnet EPiServerTemplates:
      dotnet new --install "EPiServer.Templates"
      
    4. Create the Alloy MVC site:
      dotnet new epi-alloy-mvc
      
    5. Add the latest Optimizely Content Management System (CMS) package:
      dotnet add package EPiServer.Cms
      
    6. Start the site:
      dotnet run
      
    7. Open your browser and go to the site (https://localhost:5000), add the admin user, and ensure the site works properly.
  4. Install the required packages for Optimizely Graph and Strawberry Shake.

    1. Add Strawberry Shake Transport Http:
      dotnet add package StrawberryShake.Transport.Http
      
    2. Add Strawberry Shake Server, which generates the C# classes:
      dotnet add package StrawberryShake.Server
      
    3. Add Microsoft Extensions DependencyInjection:
      dotnet add package Microsoft.Extensions.DependencyInjection
      
    4. Add Microsoft Extensions Http:
      dotnet add package Microsoft.Extensions.Http
      
    5. Add Optimizely ContentGraph CMS:
      dotnet add package Optimizely.ContentGraph.Cms
      
    6. Open folder AlloyMvcGraphQL in Visual Studio Code, which contains the Alloy MVC site you created.
  5. Update appsettings.json with the following after "AllowedHosts": "\*":

    ,
      "Optimizely": {
        "ContentGraph": {
          "GatewayAddress": "https://cg.optimizely.com",
          "AppKey": "",
          "Secret": "",
          "SingleKey": "eBrGunULiC5TziTCtiOLEmov2LijBf30obh0KmhcBlyTktGZ",
          "AllowSendingLog": "true",
          "SynchronizationEnabled":  false
        }
      }
    

    πŸ“˜

    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, and SingleKey.

  6. Update Startup.cs to use Optimizely Graph by adding using EPiServer.DependencyInjection;:

    1. 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();
      
    2. 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>
      
  7. 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.
  1. Add Optimizely Graph queries.

    1. Create a folder named GraphQL:
      mkdir GraphQL
      
    2. 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
        }
      }
      
    3. 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
        }
      }
      
  2. Build the project. Run:

    dotnet build
    
  3. Check to see that the generated file ContentGraphClient.Client.cs is in the obj/Debug/net6.0/berry/ folder.

  4. Update Startup.cs to register the client.

    1. 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 Optimizely singleKey.

      services
        .AddContentGraphClient()
        .ConfigureHttpClient(client => client.BaseAddress = new Uri("https://cg.optimizely.com/content/v2?auth=eBrGunULiC5TziTCtiOLEmov2LijBf30obh0KmhcBlyTktGZ"));
      
    2. Build the project again to update the GraphQL client:

      dotnet build
      
  5. 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);
        }
        */
    }
    
    
    
  6. Go to the search page and try it out.

    1. Start the site:
      dotnet run
      
    2. Open your browser and go to: https://localhost:5000/en/search.

Change implementation of the 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.

  1. Add Optimizely Graph queries:
    Create a file named 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
      }
    }
    

    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
      }
    }
    
  2. Build the project. Run:

    dotnet build
    
  3. Modify PageListModel to handle IPageListBlockRecursiveQuery_SitePageData instead of IEnumerable<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; }
    }
    
  4. 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 };
                    }
            }
        }
    }
    
    
  5. Update the View for the PageListBlock located in the Defualt.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>
        }
    }
    
  6. Go to the search page and try it out.

    1. Start the site:
      dotnet run
      
    2. Open your web browser and go to https://localhost:5000/en/alloy-plan/.