Split the Examine specifics from API query service (#14257)

* Split the Examine specifics from API query service to a provider based model

* Review changes: Use paged model as provider return value + add logging
This commit is contained in:
Kenn Jacobsen
2023-05-22 11:06:22 +02:00
committed by GitHub
parent a4202f352a
commit 8bbca79e55
6 changed files with 235 additions and 191 deletions

View File

@@ -31,10 +31,6 @@ public abstract class ContentApiControllerBase : DeliveryApiControllerBase
.WithTitle("Filter option not found")
.WithDetail("One of the attempted 'filter' options does not exist")
.Build()),
ApiContentQueryOperationStatus.IndexNotFound => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Examine index not found")
.WithDetail($"No index found with name {Constants.UmbracoIndexes.DeliveryApiContentIndexName}")
.Build()),
ApiContentQueryOperationStatus.SelectorOptionNotFound => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Selector option not found")
.WithDetail("The attempted 'fetch' option does not exist")

View File

@@ -28,6 +28,7 @@ public static class UmbracoBuilderExtensions
builder.Services.AddSingleton<IRequestStartItemProviderAccessor, RequestContextRequestStartItemProviderAccessor>();
builder.Services.AddSingleton<IApiAccessService, ApiAccessService>();
builder.Services.AddSingleton<IApiContentQueryService, ApiContentQueryService>();
builder.Services.AddSingleton<IApiContentQueryProvider, ApiContentQueryProvider>();
builder.Services.ConfigureOptions<ConfigureUmbracoDeliveryApiSwaggerGenOptions>();
builder.AddUmbracoApiOpenApiUI();

View File

@@ -0,0 +1,164 @@
using Examine;
using Examine.Search;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Infrastructure.Examine;
using Umbraco.Extensions;
using Umbraco.New.Cms.Core.Models;
namespace Umbraco.Cms.Api.Delivery.Services;
/// <summary>
/// This is the Examine implementation of content querying for the Delivery API.
/// </summary>
internal sealed class ApiContentQueryProvider : IApiContentQueryProvider
{
private const string ItemIdFieldName = "itemId";
private readonly IExamineManager _examineManager;
private readonly ILogger<ApiContentQueryProvider> _logger;
private readonly string _fallbackGuidValue;
private readonly Dictionary<string, FieldType> _fieldTypes;
public ApiContentQueryProvider(
IExamineManager examineManager,
ContentIndexHandlerCollection indexHandlers,
ILogger<ApiContentQueryProvider> logger)
{
_examineManager = examineManager;
_logger = logger;
// A fallback value is needed for Examine queries in case we don't have a value - we can't pass null or empty string
// It is set to a random guid since this would be highly unlikely to yield any results
_fallbackGuidValue = Guid.NewGuid().ToString("D");
// build a look-up dictionary of field types by field name
_fieldTypes = indexHandlers
.SelectMany(handler => handler.GetFields())
.DistinctBy(field => field.FieldName)
.ToDictionary(field => field.FieldName, field => field.FieldType, StringComparer.InvariantCultureIgnoreCase);
}
public PagedModel<Guid> ExecuteQuery(SelectorOption selectorOption, IList<FilterOption> filterOptions, IList<SortOption> sortOptions, string culture, int skip, int take)
{
if (!_examineManager.TryGetIndex(Constants.UmbracoIndexes.DeliveryApiContentIndexName, out IIndex? index))
{
_logger.LogError("Could not find the index {IndexName} when attempting to execute a query.", Constants.UmbracoIndexes.DeliveryApiContentIndexName);
return new PagedModel<Guid>();
}
IBooleanOperation queryOperation = BuildSelectorOperation(selectorOption, index, culture);
ApplyFiltering(filterOptions, queryOperation);
ApplySorting(sortOptions, queryOperation);
ISearchResults? results = queryOperation
.SelectField(ItemIdFieldName)
.Execute(QueryOptions.SkipTake(skip, take));
if (results is null)
{
// The query yield no results
return new PagedModel<Guid>();
}
Guid[] items = results
.Where(r => r.Values.ContainsKey(ItemIdFieldName))
.Select(r => Guid.Parse(r.Values[ItemIdFieldName]))
.ToArray();
return new PagedModel<Guid>(results.TotalItemCount, items);
}
public SelectorOption AllContentSelectorOption() => new()
{
FieldName = UmbracoExamineFieldNames.CategoryFieldName, Values = new[] { "content" }
};
private IBooleanOperation BuildSelectorOperation(SelectorOption selectorOption, IIndex index, string culture)
{
IQuery query = index.Searcher.CreateQuery();
IBooleanOperation selectorOperation = selectorOption.Values.Length == 1
? query.Field(selectorOption.FieldName, selectorOption.Values.First())
: query.GroupedOr(new[] { selectorOption.FieldName }, selectorOption.Values);
// Item culture must be either the requested culture or "none"
selectorOperation.And().GroupedOr(new[] { UmbracoExamineFieldNames.DeliveryApiContentIndex.Culture }, culture.ToLowerInvariant().IfNullOrWhiteSpace(_fallbackGuidValue), "none");
return selectorOperation;
}
private void ApplyFiltering(IList<FilterOption> filterOptions, IBooleanOperation queryOperation)
{
void HandleExact(IQuery query, string fieldName, string[] values)
{
if (values.Length == 1)
{
query.Field(fieldName, values[0]);
}
else
{
query.GroupedOr(new[] { fieldName }, values);
}
}
foreach (FilterOption filterOption in filterOptions)
{
var values = filterOption.Values.Any()
? filterOption.Values
: new[] { _fallbackGuidValue };
switch (filterOption.Operator)
{
case FilterOperation.Is:
// TODO: test this for explicit word matching
HandleExact(queryOperation.And(), filterOption.FieldName, values);
break;
case FilterOperation.IsNot:
// TODO: test this for explicit word matching
HandleExact(queryOperation.Not(), filterOption.FieldName, values);
break;
// TODO: Fix
case FilterOperation.Contains:
break;
// TODO: Fix
case FilterOperation.DoesNotContain:
break;
default:
continue;
}
}
}
private void ApplySorting(IList<SortOption> sortOptions, IOrdering ordering)
{
foreach (SortOption sort in sortOptions)
{
if (_fieldTypes.TryGetValue(sort.FieldName, out FieldType fieldType) is false)
{
_logger.LogWarning(
"Sort implementation for field name {FieldName} does not match an index handler implementation, cannot resolve field type.",
sort.FieldName);
continue;
}
SortType sortType = fieldType switch
{
FieldType.Number => SortType.Int,
FieldType.Date => SortType.Long,
FieldType.StringRaw => SortType.String,
FieldType.StringAnalyzed => SortType.String,
FieldType.StringSortable => SortType.String,
_ => throw new ArgumentOutOfRangeException(nameof(fieldType))
};
ordering = sort.Direction switch
{
Direction.Ascending => ordering.OrderBy(new SortableField(sort.FieldName, sortType)),
Direction.Descending => ordering.OrderByDescending(new SortableField(sort.FieldName, sortType)),
_ => ordering
};
}
}
}

View File

@@ -1,57 +1,35 @@
using Examine;
using Examine.Search;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Api.Delivery.Indexing.Selectors;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Infrastructure.Examine;
using Umbraco.Extensions;
using Umbraco.New.Cms.Core.Models;
namespace Umbraco.Cms.Api.Delivery.Services;
internal sealed class ApiContentQueryService : IApiContentQueryService
{
private const string ItemIdFieldName = "itemId";
private readonly IExamineManager _examineManager;
private readonly IRequestStartItemProviderAccessor _requestStartItemProviderAccessor;
private readonly SelectorHandlerCollection _selectorHandlers;
private readonly FilterHandlerCollection _filterHandlers;
private readonly SortHandlerCollection _sortHandlers;
private readonly IVariationContextAccessor _variationContextAccessor;
private readonly ILogger<ApiContentQueryService> _logger;
private readonly string _fallbackGuidValue;
private readonly Dictionary<string, FieldType> _fieldTypes;
private readonly IApiContentQueryProvider _apiContentQueryProvider;
public ApiContentQueryService(
IExamineManager examineManager,
IRequestStartItemProviderAccessor requestStartItemProviderAccessor,
SelectorHandlerCollection selectorHandlers,
FilterHandlerCollection filterHandlers,
SortHandlerCollection sortHandlers,
ContentIndexHandlerCollection indexHandlers,
ILogger<ApiContentQueryService> logger,
IVariationContextAccessor variationContextAccessor)
IVariationContextAccessor variationContextAccessor,
IApiContentQueryProvider apiContentQueryProvider)
{
_examineManager = examineManager;
_requestStartItemProviderAccessor = requestStartItemProviderAccessor;
_selectorHandlers = selectorHandlers;
_filterHandlers = filterHandlers;
_sortHandlers = sortHandlers;
_variationContextAccessor = variationContextAccessor;
_logger = logger;
// A fallback value is needed for Examine queries in case we don't have a value - we can't pass null or empty string
// It is set to a random guid since this would be highly unlikely to yield any results
_fallbackGuidValue = Guid.NewGuid().ToString("D");
// build a look-up dictionary of field types by field name
_fieldTypes = indexHandlers
.SelectMany(handler => handler.GetFields())
.DistinctBy(field => field.FieldName)
.ToDictionary(field => field.FieldName, field => field.FieldType, StringComparer.InvariantCultureIgnoreCase);
_apiContentQueryProvider = apiContentQueryProvider;
}
/// <inheritdoc/>
@@ -59,198 +37,78 @@ internal sealed class ApiContentQueryService : IApiContentQueryService
{
var emptyResult = new PagedModel<Guid>();
if (!_examineManager.TryGetIndex(Constants.UmbracoIndexes.DeliveryApiContentIndexName, out IIndex? apiIndex))
{
return Attempt.FailWithStatus(ApiContentQueryOperationStatus.IndexNotFound, emptyResult);
}
IQuery baseQuery = apiIndex.Searcher.CreateQuery();
// Handle Selecting
IBooleanOperation? queryOperation = HandleSelector(fetch, baseQuery);
// If no Selector could be found, we return no results
if (queryOperation is null)
SelectorOption? selectorOption = GetSelectorOption(fetch);
if (selectorOption is null)
{
// If no Selector could be found, we return no results
return Attempt.FailWithStatus(ApiContentQueryOperationStatus.SelectorOptionNotFound, emptyResult);
}
// Item culture must be either the requested culture or "none"
var culture = CurrentCulture();
queryOperation.And().GroupedOr(new[] { UmbracoExamineFieldNames.DeliveryApiContentIndex.Culture }, culture.ToLowerInvariant().IfNullOrWhiteSpace(_fallbackGuidValue), "none");
// Handle Filtering
var canApplyFiltering = CanHandleFiltering(filters, queryOperation);
// If there is an invalid Filter option, we return no results
if (canApplyFiltering is false)
var filterOptions = new List<FilterOption>();
foreach (var filter in filters)
{
return Attempt.FailWithStatus(ApiContentQueryOperationStatus.FilterOptionNotFound, emptyResult);
FilterOption? filterOption = GetFilterOption(filter);
if (filterOption is null)
{
// If there is an invalid Filter option, we return no results
return Attempt.FailWithStatus(ApiContentQueryOperationStatus.FilterOptionNotFound, emptyResult);
}
filterOptions.Add(filterOption);
}
// Handle Sorting
IOrdering? sortQuery = HandleSorting(sorts, queryOperation);
// If there is an invalid Sort option, we return no results
if (sortQuery is null)
var sortOptions = new List<SortOption>();
foreach (var sort in sorts)
{
return Attempt.FailWithStatus(ApiContentQueryOperationStatus.SortOptionNotFound, emptyResult);
SortOption? sortOption = GetSortOption(sort);
if (sortOption is null)
{
// If there is an invalid Sort option, we return no results
return Attempt.FailWithStatus(ApiContentQueryOperationStatus.SortOptionNotFound, emptyResult);
}
sortOptions.Add(sortOption);
}
ISearchResults? results = sortQuery
.SelectField(ItemIdFieldName)
.Execute(QueryOptions.SkipTake(skip, take));
var culture = _variationContextAccessor.VariationContext?.Culture ?? string.Empty;
if (results is null)
{
// The query yield no results
return Attempt.SucceedWithStatus(ApiContentQueryOperationStatus.Success, emptyResult);
}
Guid[] items = results
.Where(r => r.Values.ContainsKey(ItemIdFieldName))
.Select(r => Guid.Parse(r.Values[ItemIdFieldName]))
.ToArray();
return Attempt.SucceedWithStatus(ApiContentQueryOperationStatus.Success, new PagedModel<Guid>(results.TotalItemCount, items));
PagedModel<Guid> result = _apiContentQueryProvider.ExecuteQuery(selectorOption, filterOptions, sortOptions, culture, skip, take);
return Attempt.SucceedWithStatus(ApiContentQueryOperationStatus.Success, result);
}
private IBooleanOperation? HandleSelector(string? fetch, IQuery baseQuery)
private SelectorOption? GetSelectorOption(string? fetch)
{
string? fieldName = null;
string[] fieldValues = Array.Empty<string>();
if (fetch is not null)
{
ISelectorHandler? selectorHandler = _selectorHandlers.FirstOrDefault(h => h.CanHandle(fetch));
SelectorOption? selector = selectorHandler?.BuildSelectorOption(fetch);
if (selector is null)
{
return null;
}
fieldName = selector.FieldName;
fieldValues = selector.Values.Any()
? selector.Values
: new[] { _fallbackGuidValue };
return selectorHandler?.BuildSelectorOption(fetch);
}
// Take into account the "start-item" header if present, as it defines a starting root node to query from
if (fieldName is null && _requestStartItemProviderAccessor.TryGetValue(out IRequestStartItemProvider? requestStartItemProvider))
if (_requestStartItemProviderAccessor.TryGetValue(out IRequestStartItemProvider? requestStartItemProvider))
{
IPublishedContent? startItem = requestStartItemProvider.GetStartItem();
if (startItem is not null)
{
// Reusing the boolean operation of the "Descendants" selector, as we want to get all the nodes from the given starting point
fieldName = DescendantsSelectorIndexer.FieldName;
fieldValues = new [] { startItem.Key.ToString() };
return new SelectorOption
{
FieldName = DescendantsSelectorIndexer.FieldName, Values = new[] { startItem.Key.ToString() }
};
}
}
// If no params or no fetch value, get everything from the index - this is a way to do that with Examine
fieldName ??= UmbracoExamineFieldNames.CategoryFieldName;
fieldValues = fieldValues.Any() ? fieldValues : new [] { "content" };
return fieldValues.Length == 1
? baseQuery.Field(fieldName, fieldValues.First())
: baseQuery.GroupedOr(new[] { fieldName }, fieldValues);
return _apiContentQueryProvider.AllContentSelectorOption();
}
private bool CanHandleFiltering(IEnumerable<string> filters, IBooleanOperation queryOperation)
private FilterOption? GetFilterOption(string filter)
{
void HandleExact(IQuery query, string fieldName, string[] values)
{
if (values.Length == 1)
{
query.Field(fieldName, values[0]);
}
else
{
query.GroupedOr(new[] { fieldName }, values);
}
}
foreach (var filterValue in filters)
{
IFilterHandler? filterHandler = _filterHandlers.FirstOrDefault(h => h.CanHandle(filterValue));
FilterOption? filter = filterHandler?.BuildFilterOption(filterValue);
if (filter is null)
{
return false;
}
var values = filter.Values.Any()
? filter.Values
: new[] { _fallbackGuidValue };
switch (filter.Operator)
{
case FilterOperation.Is:
// TODO: test this for explicit word matching
HandleExact(queryOperation.And(), filter.FieldName, values);
break;
case FilterOperation.IsNot:
// TODO: test this for explicit word matching
HandleExact(queryOperation.Not(), filter.FieldName, values);
break;
// TODO: Fix
case FilterOperation.Contains:
break;
// TODO: Fix
case FilterOperation.DoesNotContain:
break;
default:
continue;
}
}
return true;
IFilterHandler? filterHandler = _filterHandlers.FirstOrDefault(h => h.CanHandle(filter));
return filterHandler?.BuildFilterOption(filter);
}
private IOrdering? HandleSorting(IEnumerable<string> sorts, IBooleanOperation queryCriteria)
private SortOption? GetSortOption(string sort)
{
IOrdering? orderingQuery = null;
foreach (var sortValue in sorts)
{
ISortHandler? sortHandler = _sortHandlers.FirstOrDefault(h => h.CanHandle(sortValue));
SortOption? sort = sortHandler?.BuildSortOption(sortValue);
if (sort is null)
{
return null;
}
if (_fieldTypes.TryGetValue(sort.FieldName, out FieldType fieldType) is false)
{
_logger.LogWarning("Sort implementation for field name {FieldName} does not match an index handler implementation, cannot resolve field type.", sort.FieldName);
continue;
}
SortType sortType = fieldType switch
{
FieldType.Number => SortType.Int,
FieldType.Date => SortType.Long,
FieldType.StringRaw => SortType.String,
FieldType.StringAnalyzed => SortType.String,
FieldType.StringSortable => SortType.String,
_ => throw new ArgumentOutOfRangeException(nameof(fieldType))
};
orderingQuery = sort.Direction switch
{
Direction.Ascending => queryCriteria.OrderBy(new SortableField(sort.FieldName, sortType)),
Direction.Descending => queryCriteria.OrderByDescending(new SortableField(sort.FieldName, sortType)),
_ => orderingQuery
};
}
// Keep the index sorting as default
return orderingQuery ?? queryCriteria.OrderBy();
ISortHandler? sortHandler = _sortHandlers.FirstOrDefault(h => h.CanHandle(sort));
return sortHandler?.BuildSortOption(sort);
}
private string CurrentCulture()
=> _variationContextAccessor.VariationContext?.Culture ?? string.Empty;
}

View File

@@ -0,0 +1,26 @@
using Umbraco.New.Cms.Core.Models;
namespace Umbraco.Cms.Core.DeliveryApi;
/// <summary>
/// Concrete implementation of content querying (e.g. based on Examine)
/// </summary>
public interface IApiContentQueryProvider
{
/// <summary>
/// Returns a page of item ids that passed the search criteria.
/// </summary>
/// <param name="selectorOption">The selector option of the search criteria.</param>
/// <param name="filterOptions">The filter options of the search criteria.</param>
/// <param name="sortOptions">The sorting options of the search criteria.</param>
/// <param name="culture">The requested culture.</param>
/// <param name="skip">Number of search results to skip (for pagination).</param>
/// <param name="take">Number of search results to retrieve (for pagination).</param>
/// <returns>A paged model containing the resulting IDs and the total number of results that matching the search criteria.</returns>
PagedModel<Guid> ExecuteQuery(SelectorOption selectorOption, IList<FilterOption> filterOptions, IList<SortOption> sortOptions, string culture, int skip, int take);
/// <summary>
/// Returns a selector option that can be applied to fetch "all content" (i.e. if a selector option is not present when performing a search).
/// </summary>
SelectorOption AllContentSelectorOption();
}

View File

@@ -4,7 +4,6 @@ public enum ApiContentQueryOperationStatus
{
Success,
FilterOptionNotFound,
IndexNotFound,
SelectorOptionNotFound,
SortOptionNotFound
}