Using GraphQL in ASP.NET Core with Headless CMS – Sitecore 10
G?ran Halvarsson
Experienced Fullstack & Sitecore MVP| Contract Roles Preferred | Open to Full-Time | .NET, Azure, Kubernetes, Sitecore Specialist
Be excellent to each other and party on fellow developers!
Today’s post will be about how to use GraphQL in your ASP.NET Core(Rendering host) web app.
Before we start, I would like to give a shout out to Rob Earlam and his team for the very cool Sitecore MVP site – build against Sitecore 10 utilising the new .NET Core development experience at GitHub. Go ahead and fork it and start doing some serious contributions
In today’s example/post, we will use the very great Sitecore Helix Examples, you will find it at GitHub => https://github.com/Sitecore/Helix.Examples
Thanks to Sitecore JSS – Build Headless JavaScript applications with the power of Sitecore, we have the possibility to use GraphQL.
The Sitecore GraphQL API is a generic GraphQL service platform on top of Sitecore. It hosts your data and presents it through GraphQL queries. The API supports real-time data using GraphQL subscriptions.
The idea is that we will use GraphQL(instead of using Sitecore.LayoutService.ItemRendering.ContentsResolvers.RenderingContentsResolver) to fetch product data and render it. We will do this from the rendering host. We also want GraphQL to play well with the Experience editor, meaning we should be able to work with renderings(that are using GraphQL) in the Experience Editor. Lastly, we will use the localization(dictionaries in Sitecore) to render labels and such ??
Something like this ??
How about we divide the work into the following steps:
- Set up GraphQL endpoints in the Sitecore instances, CM and CD
- Making the Rendering Host consume the GraphQL endpoint/s
- Create GraphQL search query(containing facets and paging)
- Create a rendering(in the Sitecore instance) for presenting the GraphQL search result
- Create a search service(in the Rendering Host) to handle the GraphQL search
- Create view components(in the Rendering Host) for the search result(with facets and paging)
1. Set up GraphQL endpoints in the Sitecore instances, CM and CD
First up is to set up an endpoint for GraphQL in the Sitecore containers, “CM” and “CD”. We will create a Foundation Project containing a config patch for GraphQL. This can be a bit confusing, but keep in mind that we have two types of projects. One is for the Sitecore instance running on .Net Framework 4.8 with the suffix”.Platform”. The other one is for the rendering host, running on .Net Core 3.1 with the suffix “.Rendering”. The project we will create is for the Sitecore instances, meaning a .Net Framework 4.8 project – BasicCompany.Foundation.GraphQL.Platform
The config file – Foundation.GraphQL.config:
<?xml version="1.0"?> <configuration xmlns:patch="https://www.sitecore.net/xmlconfig/" xmlns:set="https://www.sitecore.net/xmlconfig/set/" xmlns:role="https://www.sitecore.net/xmlconfig/role/"> <sitecore> <api> <GraphQL> <endpoints> <jssGraphQLEndpoint url="/api/content" type="Sitecore.Services.GraphQL.Hosting.GraphQLEndpoint, Sitecore.Services.GraphQL.NetFxHost" resolve="true"> <url>$(url)</url> <enabled>true</enabled> <enableSubscriptions>true</enableSubscriptions> <!-- lock down the endpoint when deployed to content delivery --> <graphiql role:require="ContentDelivery">false</graphiql> <enableSchemaExport role:require="ContentDelivery">false</enableSchemaExport> <enableStats role:require="ContentDelivery">false</enableStats> <enableCacheStats role:require="ContentDelivery">false</enableCacheStats> <disableIntrospection role:require="ContentDelivery">true</disableIntrospection> <schema hint="list:AddSchemaProvider"> <content type="Sitecore.Services.GraphQL.Content.ContentSchemaProvider, Sitecore.Services.GraphQL.Content"> <templates type="Sitecore.Services.GraphQL.Content.TemplateGeneration.Filters.StandardTemplatePredicate, Sitecore.Services.GraphQL.Content"> <database>context</database> <paths hint="list:AddIncludedPath"> <templates>/sitecore/templates</templates> </paths> <fieldFilter type="Sitecore.Services.GraphQL.Content.TemplateGeneration.Filters.StandardFieldFilter, Sitecore.Services.GraphQL.Content"> <exclusions hint="raw:AddFilter"> <!-- Remove system fields from the API (e.g. __Layout) to keep the schema lean --> <exclude name="__*" /> </exclusions> </fieldFilter> </templates> <queries hint="raw:AddQuery"> <!-- enable querying on items via this API --> <query name="item" type="Sitecore.Services.GraphQL.Content.Queries.ItemQuery, Sitecore.Services.GraphQL.Content" /> <query name="search" type="Sitecore.Services.GraphQL.Content.Queries.SearchQuery, Sitecore.Services.GraphQL.Content" /> </queries> <fieldTypeMapping ref="/sitecore/api/GraphQL/defaults/content/fieldTypeMappings/standardTypeMapping" /> </content> </schema> <!-- Extenders allow modifying schema types after they are created by a schema provider but before they are added to the final schema. This is useful when you want to _extend_ a generated schema, for example to add external API data onto the item API, or to add in custom internal data (e.g. custom layout data to power an app) without having to directly modify a schema provider. Extenders must derive from SchemaExtender. --> <!-- Enables the 'jss' graph nodes that are preformatted to use with JSS rendering components, and the datasource resolving queries for JSS --> <extenders hint="list:AddExtender"> <layoutExtender type="Sitecore.JavaScriptServices.GraphQL.JssExtender, Sitecore.JavaScriptServices.GraphQL" resolve="true" /> </extenders> <!-- Determines the security of the service. Defaults are defined in Sitecore.Services.GraphQL.config --> <security ref="/sitecore/api/GraphQL/defaults/security/publicService" /> <!-- Determines how performance is logged for the service. Defaults are defined in Sitecore.Services.GraphQL.config --> <performance ref="/sitecore/api/GraphQL/defaults/performance/standard" /> <!-- Cache improves the query performance by caching parsed queries. It is also possible to implement query whitelisting by implementing an authoritative query cache; WhitelistingGraphQLQueryCache is an example of this, capturing queries to files in open mode and allowing only captured queries in whitelist mode. --> <cache type="Sitecore.Services.GraphQL.Hosting.QueryTransformation.Caching.GraphQLQueryCache, Sitecore.Services.GraphQL.NetFxHost"> <param desc="name">$(url)</param> <param desc="maxSize">10MB</param> </cache> </jssGraphQLEndpoint > </endpoints> </GraphQL> </api> </sitecore> </configuration>
To publish(and deploy) the newly created project we just have to build the BasicCompany.Environment.Platform project, thanks to the Helix Publishing Pipeline.
Time to test that the GraphQL endpoints (on CM and CD) are working. To do this you can easily use the GraphiQL GUI, which is accessed by adding /ui to the endpoint URL. But I prefer to use the GraphQL Playground (It’s very easy to set up). We will try with a simple GraphQL query to verify that we have working connections:
Success, both endpoints are working fine ??
https://cm.basic-company-aspnetcore.localhost/api/content/?sc_apikey=35537f26-6b0a-4a4f-8b76-02d823e4a4fe https://cd.basic-company-aspnetcore.localhost/api/content/?sc_apikey=35537f26-6b0a-4a4f-8b76-02d823e4a4fe
2. Making the Rendering Host consume the GraphQL endpoint/s
The next thing will be to make the Rendering Host connect to the GraphQL endpoint/s. We will use a third-party component for this – GraphQL.Client. The component will connect, retrieve, and serialize/deserialize the result. Very neat and quite easy to use.
A GraphQL Client for .NET Standard over HTTP.
We will start by creating a new Foundation(“.Rendering”) project, it will also be a library project. Meaning it will support .Net Core 3.1 but have the target framework .Netstandard 2.1.
Let’s add the NuGet packages GraphQL.Client and GraphQL.Client.Serializer.Newtonsoft. The version of the packages will be added to the Packages.props file.
The project file – BasicCompany.Foundation.GraphQL.Rendering
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard2.1</TargetFramework> <LangVersion>8</LangVersion> <RootNamespace>BasicCompany.Foundation.GraphQL.Rendering</RootNamespace> <AssemblyName>BasicCompany.Foundation.GraphQL.Rendering</AssemblyName> </PropertyGroup> <ItemGroup> <PackageReference Include="GraphQL.Client" /> <PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" /> <PackageReference Include="Sitecore.LayoutService.Client" /> </ItemGroup> </Project>
The updated Packages.props file:
<?xml version="1.0" encoding="utf-8"?> <Project xmlns="https://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup> <PlatformVersion>10.0.0</PlatformVersion> </PropertyGroup> <ItemGroup> <PackageReference Update="Sitecore.Nexus" Version="$(PlatformVersion)" /> <PackageReference Update="Sitecore.Kernel" Version="$(PlatformVersion)" /> <PackageReference Update="Sitecore.Mvc" Version="$(PlatformVersion)" /> <PackageReference Update="Sitecore.ContentSearch" Version="$(PlatformVersion)" /> <PackageReference Update="Sitecore.ContentSearch.Linq" Version="$(PlatformVersion)" /> <PackageReference Update="Sitecore.ContentSearch.ContentExtraction" Version="$(PlatformVersion)" /> <PackageReference Update="Sitecore.Assemblies.Platform" Version="$(PlatformVersion)" /> <PackageReference Update="Sitecore.LayoutService" Version="10.0.0" /> <PackageReference Update="Sitecore.LayoutService.Client" Version="14.0.1" /> <PackageReference Update="Sitecore.AspNet.RenderingEngine" Version="14.0.1" /> <PackageReference Update="Sitecore.AspNet.ExperienceEditor" Version="14.0.1" /> <PackageReference Update="Sitecore.AspNet.Tracking" Version="14.0.1" /> <PackageReference Update="Sitecore.AspNet.Tracking.VisitorIdentification" Version="14.0.1" /> <PackageReference Update="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.0" /> <PackageReference Update="Microsoft.Extensions.DependencyInjection.Abstractions" Version="3.1.1" /> <PackageReference Update="Microsoft.Extensions.Http" Version="3.1.1" /> <PackageReference Update="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="3.1.1" /> <PackageReference Update="RichardSzalay.Helix.Publishing.WebRoot" Version="1.5.6" /> <PackageReference Update="GraphQL.Client" Version="3.1.6" /> <PackageReference Update="GraphQL.Client.Serializer.Newtonsoft" Version="3.1.6" /> </ItemGroup> </Project>
I want to be able to access both GraphQL endpoints. Meaning, if it’s from the Experience Editor we should point to the CM instance, and if it’s the “actual” website we should point to the CD instance.
So how do we do this, well first we should add the endpoints to the appsettings.json. One for CD and one for CM.
"Foundation": { "GraphQL": { "UrlLive": "https://cd.basic-company-aspnetcore.localhost/api/content/", "UrlEdit": "https://cm/api/content/" } }
*When running in debug mode
I had an issue with the GraphQL endpoints when running the Rendering Host in the docker container, only internal “hosts” seem to work.
I ended up by creating a new appsettings type for this – appsettings.Docker.json
"Foundation": { "GraphQL": { "UrlLive": "https://cd/api/content/", "UrlEdit": "https://cm/api/content/" } }
*When running in “container mode”
The appsettings.json file:
{ "Sitecore": { "InstanceUri": "https://cd.basic-company-aspnetcore.localhost", "LayoutServicePath": "/sitecore/api/layout/render/jss", "DefaultSiteName": "basic-company", "ApiKey": "35537f26-6b0a-4a4f-8b76-02d823e4a4fe" }, "Feature": { "Products": { "CacheInMinutes": { "ProductSearchResults": 2 } } }, "Foundation": { "GraphQL": { "UrlLive": "https://cd/api/content/", "UrlEdit": "https://cm/api/content/", "BypassRemoteCertificateValidation": true }, "Multisite": { "DefaultLanguage": "en", "SupportedLanguages": [ "en", "sv-se" ] } }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } }, "AllowedHosts": "*" }
The next step is to create a factory class that will produce the GraphQL client, depending on whether it’s in the “Edit” mode(go for CM) or “Live” mode(go for CD). Notice how we get the appsettings values using the IConfiguration.
using GraphQL.Client.Abstractions; using GraphQL.Client.Http; using Microsoft.Extensions.Configuration; using System; using System.Net.Http; using GraphQL.Client.Serializer.Newtonsoft; namespace BasicCompany.Foundation.GraphQL.Rendering.Infrastructure { public class GraphQLClientFactory { private readonly IConfiguration _configuration; private readonly HttpClient _httpClient; public GraphQLClientFactory(IConfiguration configuration, HttpClient httpClient) { _configuration = configuration; _httpClient = httpClient; } private IGraphQLClient LiveClient { get; set; } private IGraphQLClient EditClient { get; set; } public IGraphQLClient CreateLiveClient() { return LiveClient ??= CreateGraphQLClient("Foundation:GraphQL:UrlLive"); } public IGraphQLClient CreateEditClient() { return EditClient ??= CreateGraphQLClient("Foundation:GraphQL:UrlEdit"); } private IGraphQLClient CreateGraphQLClient(string configurationKeyUrlLiveOrEditMode) { GraphQLHttpClientOptions graphQLHttpClientOptions = new GraphQLHttpClientOptions() { EndPoint = new Uri( $"{_configuration.GetValue<string>(configurationKeyUrlLiveOrEditMode)}?sc_apikey={_configuration.GetValue<string>("Sitecore:ApiKey")}"), }; return new GraphQLHttpClient(graphQLHttpClientOptions, new NewtonsoftJsonSerializer() , _httpClient); } } }
To register the factory class we will use TYPED CLIENTS.
serviceCollection.AddHttpClient<GraphQLClientFactory>();
Steve Gordon has some really good posts about the topic:
DEFINING NAMED AND TYPED CLIENTS
USING TYPED CLIENTS FROM SINGLETON SERVICES
The next thing is the GraphQLRequest, it’s needed for the GraphQLClient. The GraphQLRequest will contain all about the GraphQL query.
We will create a class which will build the GraphQLRequest – GraphQLRequestBuilder:
using GraphQL; using System; using System.IO; using System.Reflection; namespace BasicCompany.Foundation.GraphQL.Rendering.Infrastructure { public class GraphQLRequestBuilder { public GraphQLRequest BuildQuery(string query, string operationName, dynamic? variables) { return new GraphQLRequest { Query = query, OperationName = operationName, Variables = variables }; } public GraphQLRequest BuildQuery(GraphQLFiles queryFile, dynamic? variables) { return BuildQuery(GetOperationResource(queryFile), queryFile.ToString(), variables); } protected string GetOperationResource(GraphQLFiles queryFile) { var assembly = Assembly.GetExecutingAssembly(); var resourceName = $"{assembly.GetName().Name}.GrapQLQueries.{queryFile}.graphql"; if (assembly.GetManifestResourceInfo(resourceName) == null) { throw new Exception($"Unknown GraphQL resource: {resourceName} -- is the file embedded?"); } using var stream = assembly.GetManifestResourceStream(resourceName); using var reader = new StreamReader(stream ?? throw new InvalidOperationException($"An error occurred with GraphQL resource {resourceName}")); return reader.ReadToEnd(); } } [Flags] public enum GraphQLFiles { None = 0, ProductSearchAdvanced = 1 } }
Look at the beauty “GetOperationResource”, it allows you to use GraphQL files. Thank you Nick Wesselman for this very smart idea
Now we can wrap it all up in a provider class – GraphQLProvider. We also log the GraphQL result. Notice how the GraphQLClientFactory is used in order to get the “correct” client.
using GraphQL; using System; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace BasicCompany.Foundation.GraphQL.Rendering.Infrastructure { public class GraphQLProvider : IGraphQLProvider { private readonly ILogger<IGraphQLProvider> _logger; private readonly GraphQLRequestBuilder _graphQLRequestBuilder; private readonly GraphQLClientFactory _graphQLClientFactory; private readonly bool _isDevelopment; public GraphQLProvider(ILogger<IGraphQLProvider> logger, GraphQLRequestBuilder graphQLRequestBuilder, GraphQLClientFactory graphQLClientFactory) { _logger = logger; _graphQLRequestBuilder = graphQLRequestBuilder ?? throw new ArgumentNullException(nameof(graphQLRequestBuilder)); _graphQLClientFactory = graphQLClientFactory ?? throw new ArgumentNullException(nameof(graphQLClientFactory)); _isDevelopment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development"; } public async Task<GraphQLResponse<TResponse>> SendQueryAsync<TResponse>(bool? isEditMode, GraphQLFiles queryFile, dynamic? variables) where TResponse : class { var client = isEditMode.HasValue && isEditMode.Value ? _graphQLClientFactory.CreateEditClient() : _graphQLClientFactory.CreateLiveClient(); GraphQLRequest request = _graphQLRequestBuilder.BuildQuery(queryFile, variables); var result = await client.SendQueryAsync<TResponse>(request); if (_isDevelopment) { _logger.LogDebug(JsonConvert.SerializeObject(result.Data, Formatting.Indented)); } return await client.SendQueryAsync<TResponse>(request); } } }
The SendQueryAsync method will do the GraphQL call, deserialize the JSON result and return a GraphQLResponse.
The last part is ServiceCollectionExtensions. It will register the classes in the Startup.cs(In the BasicCompany.Project.BasicCompany.Rendering project).
using BasicCompany.Foundation.GraphQL.Rendering.Infrastructure; using Microsoft.Extensions.DependencyInjection; namespace BasicCompany.Foundation.GraphQL.Rendering.Extensions { public static class ServiceCollectionExtensions { public static void AddFoundationGraphQL(this IServiceCollection serviceCollection) { serviceCollection.AddSingleton<GraphQLRequestBuilder>(); serviceCollection.AddHttpClient<GraphQLClientFactory>(); serviceCollection.AddSingleton<IGraphQLProvider, GraphQLProvider>(); } } }
Notice that singleton is used, it’s all about performance
3. Create GraphQL search query(containing facets and paging)
Time to create a proper search query. Adam Lamarre has this wonderful post where he describes how to use GraphQL with faceted search – Implementing a faceted search page with Sitecore JSS and React
And I totally agree with Adam about the built-in parameters like rootItem, index, etc. They are already there. And GraphQL is really fast.
So why reinvent the wheel, let’s take Adam’s search query and add some stuff to it. Notice the … on ProductDetailPage. It’s a fragment and it means it will only list items that are using the ProductDetailPage template. How cool is that? And notice the “jss”, this magic little beast will render what is needed to do some very cool serialization/deserialization of fields. We will look into that later.
query ProductSearchAdvanced( $language: String! $rootItem: String! $pageSize: Int $cursorValueToGetItemsAfter: String! $siteNameForItemUrl: String! $facetOn: [String!] $fieldsEqual: [ItemSearchFieldQuery] $query: String ) { search( rootItem: $rootItem language: $language first: $pageSize after: $cursorValueToGetItemsAfter fieldsEqual: $fieldsEqual facetOn: $facetOn keyword: $query ) { facets { name values { value count } } results { items { item { ... on ProductDetailPage { title { jss } shortDescription { jss } image { jss } color { jss } price { jss } url(options: { siteName: $siteNameForItemUrl }) } } } totalCount pageInfo { startCursor endCursor hasNextPage hasPreviousPage } } } }
About the siteName in URL options, it’s just a failsafe to get the correct URL.
I’ve also created a new field(Single-Line Text) in the _Product template, the Color field. I wanted to test search/filter on several field values, but it seems that it only works on field type “Single-Line Text”.
"fieldsEqual" : [{"name": "color", "value": "red,black"}]
Ok, let’s test the search query:
Works great ??
The result in JSON form:
{ "Search": { "Facets": [ { "Name": "color", "Values": [ { "Value": "black", "Count": 6 }, { "Value": "red", "Count": 5 } ] } ], "Results": { "Items": [ { "Item": { "Title": { "jss": { "EditableMarkup": "", "Value": "Ac vivamus quam" } }, "ShortDescription": { "jss": { "EditableMarkup": "", "Value": "Bibendum molestie sed fermentum dictumst in" } }, "Image": { "jss": { "EditableMarkup": "", "Value": { "Src": "https://cd.basic-company-aspnetcore.localhost/-/media/Basic-Company/tshirt_red.png?h=644&iar=0&w=1010&hash=A26AAA09261D8C4B6F796AA2E72F1C49", "Alt": "" } } }, "Price": { "jss": { "EditableMarkup": "", "Value": "18.99" } }, "Color": { "jss": { "EditableMarkup": "", "Value": "Red" } }, "Url": "/en/Products/2020/08/05/08/30/Ac-vivamus-quam" } }, - - - - ], "TotalCount": 11, "PageInfo": { "StartCursor": "1", "EndCursor": "4", "HasNextPage": true, "HasPreviousPage": false } } } }
Let’s create a GraphQL file for the search query and put it in the Foundation project:
What we have left is to do some very cool deserializing of the jss fields. Thanks to the jss fields we can easily deserialize them to Sitecore.LayoutService.Client.Response.Model.Fields. How cool is that! Thank you Nick Wesselman for this very smart idea
using Newtonsoft.Json; using Sitecore.LayoutService.Client.Response.Model.Fields; using System.Runtime.Serialization; namespace BasicCompany.Foundation.GraphQL.Rendering.Models { public class WrappedImageField { [JsonProperty("jss")] public ImageField Field { get; set; } [OnDeserialized] private void OnDeserialized(StreamingContext context) { if (string.IsNullOrWhiteSpace(Field?.Value?.Src)) return; //Running in docker if(Field.Value.Src.Contains("https://cd/")) this.Field.Value.Src = this.Field.Value.Src.Replace("https://cd/", "https://cd.basic-company-aspnetcore.localhost/"); if (this.Field.Value.Src.Contains("https://cm/")) this.Field.Value.Src = this.Field.Value.Src.Replace("https://cm/", "https://cm.basic-company-aspnetcore.localhost/"); } } public class WrappedRichTextField { [JsonProperty("jss")] public RichTextField Field { get; set; } } public class WrappedTextField { [JsonProperty("jss")] public TextField Field { get; set; } } }
About the deserializing of the ImageField. When the rendering host is running in the Docker container, the hostnames(in the image URL) are “internal” and needs to be “replaced”.
4. Create a rendering(in the Sitecore instance) for presenting the GraphQL search result
Let’s move on and create the rendering in Sitecore. The rendering will present a list of products. There will also be the possibility to select what facets to show/use and what size of the page size( when using paging), we will do this with rendering parameters. The rendering we are going to use will be a JSON Rendering – GraphQL Product List:
Notice the “Component Name”, it will allow the Sitecore Rendering Engine to pick up the component from the SitecoreLayoutResponse (From the Sitecore Layout Service). Something like this in the Rendering Host:
.AddViewComponent("GraphQLProductList", "GraphQLProductList")
Next, the rendering parameters…
I had an idea I wanted to test, it’s about localizing rendering parameters by using Sitecore Dictionary items. The scenario is this:
I want to show what facets are available, make them selectable but also present them as filters/facets in the search result view and they should be localized.
This is how I did it:
In the rendering parameters template, I’ve added a field, DictionaryColor, using the field type “Checklist” and the source points to a Dictionary folder:
Notice the relative path to the Dictionary. This was indeed tricky to fix but thanks to this good old article/question in Sitecore Community – Rendering Parameter Template Source Query not working for Droplink, I finally got it to work. Thank you Sitecore Community
Here is the dictionary containing the Dictionary Colors:
And how it’s displayed in Control properties:
We will revisit this later on when we present/list the facets in a view component.
5. Create a search service(in the Rendering Host) to handle the GraphQL search
Back again to the Rendering host. It’s time to create a search service.
But before we begin I would like to introduce a new project type with the suffix “.Shared”. And as the name implies, the project can be shared between .Net Framework 8 projects and .Net Core 3.1 projects. The secret ingredient is to target .Netstandard 2.0. I’ve got the idea from Rob’s great repo Sitecore MVP site – build against Sitecore 10 utilising the new .NET Core development experience at GitHub.
We will create a new shared project called – BasicCompany.Feature.Products.Shared.
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework> <RootNamespace>BasicCompany.Feature.Products.Shared</RootNamespace> <AssemblyName>BasicCompany.Feature.Products.Shared</AssemblyName> </PropertyGroup> </Project>
It will contain a Templates class and a Constants class(which will be shared between the “.Platforms project” and the “.Rendering project”)
The Templates class and we only use strings for the Sitecore ID’s
namespace BasicCompany.Feature.Products.Shared { public struct Templates { public struct Product { public const string Id = "{ABCECB30-2777-48C7-8860-813E5268816C}"; public struct Fields { public const string Title = "{BE500A38-36A0-417B-86C5-E63BA71D0939}"; public const string ShortDescription = "{BE7E2D00-E405-4498-85F9-4F89D8EA2CFC}"; public const string Image = "{23F42F5E-645C-4A72-9C61-79DB1A331E64}"; public const string Features = "{2C2C386C-17C7-44C3-8796-3E720BA08079}"; public const string Price = "{9977FBE7-A0FF-475C-9A7C-ECB81C7F51CF}"; public const string Color = "{A19812F5-1CC4-42D8-9910-F34C5C2BE8AC}"; public const string RelatedProducts = "{FC7C99D4-7301-44EB-908C-BF4FFCA5A131}"; } } } }
Let’s continue ??
We will do our work in the “.Rendering” project – BasicCompany.Feature.Products.Rendering. The search service will use the GraphQLProvider(the one we did in the chapter: Making the Rendering Host consume the GraphQL endpoint/s). In fact, the service is basically a wrapper for the GraphQL search query. So let’s create the search service, we will call it GraphQLProductsService .
using BasicCompany.Feature.Products.Models; using BasicCompany.Feature.Products.Shared; using BasicCompany.Foundation.GraphQL.Rendering.Infrastructure; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using BasicCompany.Foundation.GraphQL.Rendering.Extensions; namespace BasicCompany.Feature.Products.Services { public class GraphQLProductsService : IGraphQLProductsService { private readonly IMemoryCache _memoryCache; private readonly IConfiguration _configuration; private readonly IGraphQLProvider _graphQLProvider; public GraphQLProductsService(IMemoryCache memoryCache, IConfiguration configuration, IGraphQLProvider graphQLProvider) { _memoryCache = memoryCache; _configuration = configuration; _graphQLProvider = graphQLProvider; } public SearchParams CreateSearchParams() { return new SearchParams() { FacetOn = new List<string>(){ Constants.FacetKeys.FacetOnFieldColor } }; } public async Task<ProductSearchResults> Search(SearchParams searchParams) { if (searchParams.IsInEditingMode.HasValue && searchParams.IsInEditingMode.Value) { return await ProductSearchResults(); } object cacheKey = searchParams.CacheKey; return await _memoryCache.GetOrCreateAsync( cacheKey, async e => { e.SetOptions(new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_configuration.GetValue<int>("Feature:Products:CacheInMinutes:ProductSearchResults")) }); return await ProductSearchResults(); }); async Task<ProductSearchResults> ProductSearchResults() { var fieldsEqualsList = new List<dynamic>(); //Facets from the rendering paramaters if (searchParams.FilterFacets != null && searchParams.FilterFacets.Any()) { fieldsEqualsList.AddFieldsEqualParams(searchParams.FilterFacets.Select(f => new KeyValuePair<string, string>(f.Item1.Key, f.Item2.Keys.Aggregate((concat, str) => $"{concat},{str}"))).ToArray()); } //Facets from URL if (searchParams.Facets !=null && searchParams.Facets.Any()) fieldsEqualsList.AddFieldsEqualParams(searchParams.Facets.ToArray()); GraphQLResponse<Response> response = await _graphQLProvider.SendQueryAsync<Response>(searchParams.IsInEditingMode, GraphQLFiles.ProductSearchAdvanced, new { language = searchParams.Language, rootItem = new Guid(searchParams.RootItemId).ToString("N"), pageSize = searchParams.PageSize, cursorValueToGetItemsAfter = searchParams.CursorValueToGetItemsAfter?.ToString(), siteNameForItemUrl = _configuration.GetValue<string>("Sitecore:DefaultSiteName"), query = searchParams.Query, fieldsEqual = fieldsEqualsList, facetOn = searchParams.FacetOn }); return new ProductSearchResults { Products = response.Data.Search.Results.Items.Select(x => x.Item), Facets = response.Data.Search.Facets, TotalCount = response.Data.Search.Results.TotalCount, StartCursor = int.Parse(response.Data.Search.Results.PageInfo.StartCursor), EndCursor = int.Parse(response.Data.Search.Results.PageInfo.EndCursor), HasNextPage = response.Data.Search.Results.PageInfo.HasNextPage, HasPreviousPage = response.Data.Search.Results.PageInfo.HasPreviousPage, PageSize = searchParams.PageSize != 0 ? searchParams.PageSize : null, FilterFacets = searchParams.FilterFacets, CurrentPage = !searchParams.PageSize.HasValue ? 1 : Convert.ToInt32(Math.Ceiling(int.Parse(response.Data.Search.Results.PageInfo.EndCursor) / Convert.ToDouble(searchParams.PageSize))) }; } } public class SearchParams { public string Language { get; set; } public string RootItemId { get; set; } public int? PageSize { get; set; } public int? CursorValueToGetItemsAfter { get; set; } public bool? IsInEditingMode { get; set; } public IList<(KeyValuePair<string, string>, IDictionary<string,string>)>? FilterFacets { get; set; } public IList<KeyValuePair<string, string>>? Facets { get; set; } public IList<string> FacetOn { get; set; } public string Query { get; set; } public string CacheKey { get; set; } } protected class ProductSearchResultsInternal { public IEnumerable<ProductSearchItem> Items { get; set; } public long TotalCount { get; set; } public PagingInfo PageInfo { get; set; } } protected class ProductSearchItem { public ListWrappedProduct Item { get; set; } } protected class PagingInfo { public string StartCursor { get; set; } public string EndCursor { get; set; } public bool HasNextPage { get; set; } public bool HasPreviousPage { get; set; } } protected class ProductSearch { public IEnumerable<Facet> Facets { get; set; } public ProductSearchResultsInternal Results { get; set; } } protected class Response { public ProductSearch Search { get; set; } } } }
Let me guide you through it ??
Did you notice the SearchParams parameter in the Search method? Instead of “clogging” the method with too many parameters, we will use a “params” class – SearchParams. CreateSearchParams() will return a fresh SearchParams object with default values, in this case, FacetOn.
In the Search method, we will check whether we are in EditMode or not. If it’s in EditMode we don’t want to cache anything. So why cache, is it not enough to just use caching in Sitecore? I’m not sure really, my question is, do we need caching in Sitecore at all? Why not take advantage of all the goodies in Asp.Net Core and do some serious caching on the Rendering Host, using IMemoryCache, IDistributedCache, and Response Caching. I think there will be a separate post for that topic, there are so many possibilities. Anyway, if it’s not in EditMode we will use the IMemoryCache. The Feature:Products:CacheInMinutes:ProductSearchResults setting(in the appsettings.json) decides the cache expiration.
In the local method ProductSearchResults, we build the fieldsEqualsList(It’s a parameter list of type ItemSearchFieldQuery for the graphQL query)
"fieldsEqual":[{"name":"color", "value":"red,black"}]
Lastly, we call the _graphQLProvider.SendQueryAsync method with all the parameters for the GraphQL query. We also set what object the JSON result should be deserialized too(Response object).
The response(GraphQLResponse) will be remapped to a ProductSearchResults class.
6. Create view components(in the Rendering Host) for the search result(with facets and paging)
Time to render it all, we will go for view components. Ah, don’t you just love the view components with the possibility to use a ViewComponent class(see it as code behind).
We will create a view component for the search result. Let’s start with the ViewComponent class and call it GraphQLProductListViewComponent. In InvokeAsync we call the GraphQLProductsService to make a search.
using BasicCompany.Feature.Products.Extensions; using BasicCompany.Feature.Products.Services; using BasicCompany.Feature.Products.Shared; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Mvc; using Sitecore.AspNet.RenderingEngine; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace BasicCompany.Feature.Products.Components { public class GraphQLProductListViewComponent : ViewComponent { private readonly IGraphQLProductsService _graphQLProductsService; public GraphQLProductListViewComponent(IGraphQLProductsService graphQLProductsService) { _graphQLProductsService = graphQLProductsService; } public async Task<IViewComponentResult> InvokeAsync() { var searchParams = _graphQLProductsService.CreateSearchParams(); searchParams.Language = GetLanguage(); searchParams.RootItemId = this.HttpContext.GetSitecoreRenderingContext().Response?.Content?.Sitecore?.Route?.ItemId; searchParams.IsInEditingMode = this.HttpContext.GetSitecoreRenderingContext().Response?.Content?.Sitecore?.Context?.IsEditing; searchParams.PageSize = this.HttpContext.GetSitecoreRenderingContext().Component?.Parameters.TryParseToNullableInt(Constants.RenderingParameters.PageSize); searchParams.CursorValueToGetItemsAfter = GetCursorIndex(searchParams.PageSize); searchParams.Facets = GetFacetsFromUrl(); searchParams.FilterFacets = GetFilterFacetsFromRenderingParameters(); searchParams.CacheKey = this.HttpContext.Request.GetEncodedPathAndQuery(); var results = await _graphQLProductsService.Search(searchParams); return View(results); } private IList<(KeyValuePair<string, string>, IDictionary<string, string>)>? GetFilterFacetsFromRenderingParameters() { var facetValues = this.HttpContext.GetSitecoreRenderingContext().Component?.Parameters.TryParseToNullableString(Constants.RenderingParameters.DictionaryColor); if (string.IsNullOrWhiteSpace(facetValues)) return null; var values = new Dictionary<string, string>(); foreach (var facetValue in facetValues?.Split('|')) { var facetKey = facetValue switch { Constants.Dictionaries.ColorBlack => Constants.FacetKeys.ColorBlack, Constants.Dictionaries.ColorRed => Constants.FacetKeys.ColorRed, Constants.Dictionaries.ColorWhite => Constants.FacetKeys.ColorWhite, _ => string.Empty }; values.Add(facetKey, facetValue); } IList<(KeyValuePair<string, string>, IDictionary<string, string>)>? facetsList = new List<(KeyValuePair<string, string>, IDictionary<string, string>)> { (new KeyValuePair<string, string>(Constants.FacetKeys.FacetOnFieldColor, Constants.Dictionaries.ColorFacetLabel), values) }; return facetsList; } private IList<KeyValuePair<string, string>>? GetFacetsFromUrl() { var facetsList = this.HttpContext.Request.Query.Where(kvp => kvp.Key.StartsWith(Constants.QueryParameters.FacetPrefix)) .Select(kvp => new KeyValuePair<string, string>(kvp.Key.Replace(Constants.QueryParameters.FacetPrefix, ""), kvp.Value)).ToList(); return facetsList; } private int? GetCursorIndex(int? pageSize) { int? cursorIndex = 0; if (this.HttpContext.Request.Query.ContainsKey(Constants.QueryParameters.Page)) cursorIndex = pageSize * Request.Query[Constants.QueryParameters.Page].ToString().TryParseToNullableInt() - pageSize; return cursorIndex; } private string GetLanguage() { var currentCulture = HttpContext.Features.Get<IRequestCultureFeature>(); return currentCulture.RequestCulture.Culture.Name; } } }
Notice how we access the rendering parameters HttpContext.GetSitecoreRenderingContext().Component?.Parameters (this is from the Sitecore Layout Service).
If you look at the logs in your docker container or in VisualStudio(when running Kestrel) you can see how the response looks like:
Layout Service Response JSON : { "sitecore": { "context": { "pageEditing": false, "site": { "name": "basic-company" }, "pageState": "normal", "language": "en" }, "route": { "name": "Products", "displayName": "Products", "fields": { "NavigationTitle": { "value": "Products" } }, "databaseName": "web", "deviceId": "fe5d7fdf-89c0-4d99-9aa3-b5fbd009c9f3", "itemId": "af068b22-d7ae-4a91-839b-dd15a9e76554", "itemLanguage": "en", "itemVersion": 1, "layoutId": "4ed48317-062f-4465-b9af-1e636841525b", "templateId": "9f57204e-5ce2-4fe8-98f7-9823ec53e8cc", "templateName": "Product List Page", "placeholders": { "header": [ { "uid": "27c921b1-e330-4fe8-ba33-05e461812742", "componentName": "Header", "dataSource": "", "params": {}, "fields": { "logoLink": { "NavigationTitle": { "value": "Home" }, "FooterCopyright": { "value": "Copyright" }, "HeaderLogo": { "value": { "src": "https://cd.basic-company-aspnetcore.localhost/-/media/Basic-Company/helix-logo.png?h=44&iar=0&w=139&hash=0A1AD60DFDAA0C78DD1BE4928E2659D8", "alt": "Sitecore Helix", "width": "139", "height": "44" } } }, "navItems": [ { "url": "/en/", "isActive": false, "title": "Home" }, { "url": "/en/Products", "isActive": true, "title": "Products" }, { "url": "/en/Services", "isActive": false, "title": "Services" } ], "supportedLanguages": [ { "name": "en", "nativeName": "English", "url": "/en/products/", "twoLetterCode": "en", "icon": "/~/icon/Office/32x32/flag_generic.png" }, { "name": "sv-SE", "nativeName": "svenska (Sverige)", "url": "/sv-se/products/", "twoLetterCode": "sv", "icon": "/~/icon/Flags/32x32/flag_sweden.png" } ] } } ], "main": [ { "uid": "37d2fd15-98b0-4b6e-89ec-2d52cee1acdd", "componentName": "HeroBanner", "dataSource": "{1FB6764A-97D1-41D7-A568-E2B62164DD02}", "params": {}, "fields": { "Title": { "value": "Product List" }, "Subtitle": { "value": "lorem ipsum dolor sit" }, "Image": { "value": { "src": "https://cd.basic-company-aspnetcore.localhost/-/media/Basic-Company/hero-products.jpg?h=510&iar=0&w=1920&hash=E7FFEEF738F6FEDA2BE23352DEDA6D3F", "alt": "Nam commodo", "width": "1920", "height": "510" } } } }, { "uid": "3877d7e5-b3f4-4198-951d-8bf165cf8ce9", "componentName": "GraphQLProductList", "dataSource": "", "params": { "DictionaryColor": "{895EFC7E-46E3-41AE-9A10-70FC21B1A686}|{1F3C23BF-4414-432F-9C4C-DB8F4DB2A8FE}", "PageSize": "4" } } ], "footer": [ { "uid": "d7cf07df-326a-4011-871d-fb0d85cd687a", "componentName": "Footer", "dataSource": "", "params": {}, "fields": { "footerText": "Copyright" } } ] } } } }
The search result will be presented in a view – Default.cshtml. (We will put the view in folder Views/Shared/Components/GraphQLProductList)
Nothing special here, the Model will contain the ProductSearchResults.
@model BasicCompany.Feature.Products.Models.ProductSearchResults @{ var hasFilterFacets = Model.FilterFacets != null && Model.FilterFacets.Any(); } @if (Model == null) { <div class="container"> ERROR </div> return; } <div class="container"> <div class="columns product-list-columns"> <vc:filters product-search-results="@Model"></vc:filters> <div class="column @(hasFilterFacets ? "is-four-fifths" : "" ) "> <div class="columns is-multiline"> @foreach (var product in Model.Products) { <a href="@product.Url" class="column product-list-column is-4-desktop is-6-tablet "> <div class="card"> <div class="card-image"> <figure style="background-image: url(@product.Image?.Field?.Value?.Src)"></figure> </div> <div class="card-content"> <div class="content"> <h4 asp-for="@product.Title.Field"></h4> <p asp-for="@product.ShortDescription.Field"></p> </div> </div> </div> </a> } </div> <vc:paging product-search-results="@Model"></vc:paging> </div> </div> </div>
To make the RenderingEngine aware of the view component, we need to add it to the RenderingEngineOptions:
The first parameter is the name of the component in the Sitecore Rendering, the second parameter is the name of the view component.
.AddViewComponent("GraphQLProductList", "GraphQLProductList")
Did you guys notice the other two view components?
<vc:filters product-search-results="@Model"></vc:filters> <vc:paging product-search-results="@Model"></vc:paging>
Let’s have a look at the Filters view component. The ViewComponents class, FiltersViewComponent, uses ProductSearchResults as a parameter in method InvokeAsync.
using BasicCompany.Feature.Products.Models; using Microsoft.AspNetCore.Mvc; using System.Threading.Tasks; namespace BasicCompany.Feature.Products.Components { public class FiltersViewComponent : ViewComponent { public Task<IViewComponentResult> InvokeAsync(ProductSearchResults productSearchResults) { var model = CreateFacetsModel(productSearchResults); return Task.FromResult<IViewComponentResult>(View(model)); } private Filters CreateFacetsModel(ProductSearchResults productSearchResults) { if (productSearchResults.FilterFacets == null) { return new Filters() { HasFilterFacets = false }; } Filters model = new Filters() { HasFilterFacets = true, FilterFacets = productSearchResults.FilterFacets, Facets = productSearchResults.Facets }; return model; } } }
Here is the view for the FiltersViewComponent, Default.cshtml, located in folder Views/Shared/Components/Filters. The Model.FilterFacets contains the “filter facets” we got from the rendering parameters. Notice the injected IStringLocalizer is used in order to “translate”.
@using BasicCompany.Feature.Products.Extensions; @inject IStringLocalizer<ProductsResource> Localizer @model BasicCompany.Feature.Products.Models.Filters @if (Model.HasFilterFacets) { <div class="column"> @foreach (var facet in Model.FilterFacets) { <article class="panel"> <p class="panel-heading"> @Localizer[facet.Item1.Value].Value.ToUpper() </p> @{ var facetValuesFromUrl = Model.Facets.FirstOrDefault(f => f.Name.ToLower().Equals(@facet.Item1.Key.ToLower()))?.Values; } @foreach (var facetValue in facet.Item2) { string checkedValue = ""; if (facetValuesFromUrl != null && facetValuesFromUrl.Any(f => f.Value == facetValue.Key)) { checkedValue = this.Context.Request.HasQueryParamAndValueInUrl($"facet_{facet.Item1.Key}", facetValue.Key) ? "checked" : ""; } <div class="panel-block"> <p class="control has-icons-left"> <label class="checkbox"> <input type="checkbox" onclick="window.location.assign('@this.Context.Request.UpdateQueryParamInUrl($"facet_{facet.Item1.Key}", facetValue.Key, false, ",", false)')" @checkedValue> @Localizer[facetValue.Value].Value.ToUpper() </label> </p> </div> } </article> } </div> }
Instead of using a form to “post” the filtering. We just add query params to the URL in order to “filter”.
https://www.basic-company-aspnetcore.localhost/en/Products?facet_color=black,red,
Time for some localization ?? If you guys remembered we used Sitecore Dictionary items for the facets. If we look at the response from the Sitecore Layout Service for the component GraphQLProductList. The values in DictionaryColor contains the id’s from the dictionary items. We also set the item ID as Key on the Sitecore Dictionary Entry item.
{ "uid": "3877d7e5-b3f4-4198-951d-8bf165cf8ce9", "componentName": "GraphQLProductList", "dataSource": "", "params": { "DictionaryColor": "{895EFC7E-46E3-41AE-9A10-70FC21B1A686}|{1F3C23BF-4414-432F-9C4C-DB8F4DB2A8FE}", "PageSize": "4" } }
The Localization.ProductsResource.en.resx is also updated with the Keys and Phrases from the Sitecore Dictionary items:
Finally, to localize/translate the facet labels(we got from the rendering parameters), we just do this:
@Localizer[facetValue.Value].Value.ToUpper()
Here it is in action:
The final view component – Paging. Exactly what it says, a paging component. The ViewComponents class, PagingViewComponent, uses ProductSearchResults as a parameter in method InvokeAsync.
The view for the PagingViewComponent, Default.cshtml, located in folder Views/Shared/Components/Paging. Notice the injected IStringLocalizer is used in order to “translate”.
@using BasicCompany.Feature.Products.Extensions; @using BasicCompany.Feature.Products.Shared @inject IStringLocalizer<ProductsResource> Localizer @model BasicCompany.Feature.Products.Models.Paging @if (Model.HasPages) { <div class="column has-text-right"> @Model.StartCursor @Localizer[Constants.Dictionaries.PagingTo] @Model.EndCursor @Localizer[Constants.Dictionaries.PagingOf] @Model.TotalCount @Localizer[Constants.Dictionaries.PagingTotal] </div> <div class="column box paging-box"> <nav class="pagination is-medium is-right" role="navigation" aria-label="pagination"> <a href="@Model.PreviousPageUrl" class="pagination-previous" disabled="@Model.IsPreviousButtonDisabled">@Localizer[Constants.Dictionaries.PagingPrevious]</a> <a href="@Model.NextPageUrl" class="pagination-next" disabled="@Model.IsNextButtonDisabled">@Localizer[Constants.Dictionaries.PagingNext]</a> <ul class="pagination-list"> @for (var i = 1; i <= Model.NumberOfPages; i++) { @if (i == Model.CurrentPage) { <li><a class="pagination-link is-current" aria-label="Page @i" aria-current="page">@i</a></li> } else { <li><a href="@this.Context.Request.UpdateQueryParamInUrl("page", i.ToString())" class="pagination-link" aria-label="Goto page @i">@i</a></li> } } </ul> </nav> </div> }
And here we have it all in action:
That’s all for now folks ??
Freelance Solution Architect
4 年Thanks for a super thorough and interesting article! G?ran, you are truly a frontier within the Sitecore community.
Senior System Architect - Cloud & SaaS
4 年Great share G?ran Halvarsson