diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs index 834332fcbb..e260200d5e 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs @@ -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") diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index 6370fc24f9..37d84bc273 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -28,6 +28,7 @@ public static class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.ConfigureOptions(); builder.AddUmbracoApiOpenApiUI(); diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryProvider.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryProvider.cs new file mode 100644 index 0000000000..d9ad8d0f15 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryProvider.cs @@ -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; + +/// +/// This is the Examine implementation of content querying for the Delivery API. +/// +internal sealed class ApiContentQueryProvider : IApiContentQueryProvider +{ + private const string ItemIdFieldName = "itemId"; + private readonly IExamineManager _examineManager; + private readonly ILogger _logger; + private readonly string _fallbackGuidValue; + private readonly Dictionary _fieldTypes; + + public ApiContentQueryProvider( + IExamineManager examineManager, + ContentIndexHandlerCollection indexHandlers, + ILogger 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 ExecuteQuery(SelectorOption selectorOption, IList filterOptions, IList 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(); + } + + 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[] items = results + .Where(r => r.Values.ContainsKey(ItemIdFieldName)) + .Select(r => Guid.Parse(r.Values[ItemIdFieldName])) + .ToArray(); + + return new PagedModel(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 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 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 + }; + } + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs index d044d774d1..8aac5db6ee 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs @@ -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 _logger; - private readonly string _fallbackGuidValue; - private readonly Dictionary _fieldTypes; + private readonly IApiContentQueryProvider _apiContentQueryProvider; public ApiContentQueryService( - IExamineManager examineManager, IRequestStartItemProviderAccessor requestStartItemProviderAccessor, SelectorHandlerCollection selectorHandlers, FilterHandlerCollection filterHandlers, SortHandlerCollection sortHandlers, - ContentIndexHandlerCollection indexHandlers, - ILogger 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; } /// @@ -59,198 +37,78 @@ internal sealed class ApiContentQueryService : IApiContentQueryService { var emptyResult = new PagedModel(); - 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(); + 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(); + 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(results.TotalItemCount, items)); + PagedModel 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(); - 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 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 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; } diff --git a/src/Umbraco.Core/DeliveryApi/IApiContentQueryProvider.cs b/src/Umbraco.Core/DeliveryApi/IApiContentQueryProvider.cs new file mode 100644 index 0000000000..df889184fd --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IApiContentQueryProvider.cs @@ -0,0 +1,26 @@ +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Core.DeliveryApi; + +/// +/// Concrete implementation of content querying (e.g. based on Examine) +/// +public interface IApiContentQueryProvider +{ + /// + /// Returns a page of item ids that passed the search criteria. + /// + /// The selector option of the search criteria. + /// The filter options of the search criteria. + /// The sorting options of the search criteria. + /// The requested culture. + /// Number of search results to skip (for pagination). + /// Number of search results to retrieve (for pagination). + /// A paged model containing the resulting IDs and the total number of results that matching the search criteria. + PagedModel ExecuteQuery(SelectorOption selectorOption, IList filterOptions, IList sortOptions, string culture, int skip, int take); + + /// + /// 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). + /// + SelectorOption AllContentSelectorOption(); +} diff --git a/src/Umbraco.Core/Services/OperationStatus/ApiContentQueryOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/ApiContentQueryOperationStatus.cs index 0c3c859070..2e8da1a0b1 100644 --- a/src/Umbraco.Core/Services/OperationStatus/ApiContentQueryOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/ApiContentQueryOperationStatus.cs @@ -4,7 +4,6 @@ public enum ApiContentQueryOperationStatus { Success, FilterOptionNotFound, - IndexNotFound, SelectorOptionNotFound, SortOptionNotFound }