From c06e89af6449e6cf0029ddaa38189797826bf49a Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Wed, 19 Apr 2023 11:21:31 +0200 Subject: [PATCH] Content Delivery API (#14051) * Add the core parts of the headless PoC * Add Content API project (WIP - loads of TODOs and dupes that need to be fixed!) * Rename the content API project and namespaces * Fixed bad merge * Rename everything "Headless" to "ContentApi" or "Api" * Refactor Content + Media: Key => Id, Name not nullable * Make Content API property return value types independent of datatype configuration * Clean up refactorings * First stab at an expansion strategy using content picker as example implementation * Use named JSON options for content API serialization * Proper inclusion and registration of the content API * Introduce API media builder * Make MNTP return API content/media depending on configuration (instead of links) and support output expansion * Content API: Get by controllers (#13740) * Adding ContentApiControllerBase * Adding get by id and url controllers * Change route of get all test controller * Rename to ContentApiController * Refactoring * Removing test controller * Content API: Add start-node header value to deal with url collisions (#13746) * Use start-node header value to deal with url collisions * Cleanup * Rename "url" param to "path" * Adding a start node service to get the start-node header value * Trim '/' from both beginning and end * Content API: Support Accept-Language header (#13831) * Move the content API JSON type resolver to an appropriate namespace * Add localization based on Accept-Language header * Content API: Output expansion (#13848) * Implement request based output expansion strategy + expansion output cache at property level * Slighty leaner implementation for default output expansion strategy * Clarify the code a bit * Fix bad merge * Encapsulate content API dependencies in the DI * Support multi-site and multi-culture routing + a little rename/refactor (#13882) * Support multi-site and multi-culture routing + a little rename/refactor * Make the by route controller handle root nodes * Rename Url to Path in API content output * Add a few comments for magic route creation * Rename services from "Default" to "Noop" * Ensure that Umbraco can boot without adding "AddContentApi()" to ConfigureServices * Moved incorrectly placed media builder * Fix API routes (#13915) * Fix multi URL picker value converter trying to access disposed objects in edge cases * Delivery API: Content routing and structure (#13984) * Introduce content route instead of content path, rename and rework start item (previously start node) handling * Strip out start node path in generated route path * Make the start-item header take precedence over the request domain * Conditionally enabling the delivery API + add protection and preview support + refactor all services to be singletons + ensure no-op implementations for all required services (#13992) * Include umbraco properties (width, height, ...) in the Media Properties collection (#14023) * Move umbraco properties (width, height, ...) to the Properties collection of the API Media model * Don't output the umbracoFile property of media items * Add content type deny list (#14025) * Create a deny list of content types and utilize it for output generation * Add unit tests * Dedicated property cache level for Content API (#14027) * Support redirect tracking (#14033) * Create a deny list of content types and utilize it for output generation * Add unit tests * Handle redirect tracking in the content API * Include start item routing info for redirects * Add cultures and their routes to the API output (#14038) * Create a deny list of content types and utilize it for output generation * Add unit tests * Handle redirect tracking in the content API * Include start item routing info for redirects * Add culture routes to root output (for HREFLANG support) * Rename redirect service method to better match its purpose * Review changes * Delivery API: Query controller (#14041) * Initial commit * Custom ContentAPIFieldDefinitionCollection * Make index IUmbracoContentIndex * Add querying for children by parent id (key) * Add missing interface * Adding querying endpoint * Test code * Compose unpublishedValueSet, so that you get the correct data in the ContentAPI index * Renaming * Fix ancestorKeys index values * Adding IApiQueryExtensionService to be able to query the ContentAPI index in a generic way * Fix IApiQueryService and clean up QueryContentApiController using it * Support querying for path * Fix content API indexing * Fix default sorting * Implement concrete QueryOption implementations * Introduce new ExecuteQuery that uses the Core OptionHandlers * Implement ExecuteQuery * Change ExecuteQuery signature and implementation * Implement demo sorting and fetching * Add query option handlers and collection builder for them * Cleanup * Revert "Conditionally enabling the delivery API + add protection and preview support + refactor all services to be singletons + ensure no-op implementations for all required services (#13992)" This reverts commit 78e1f748e55383baecd123d06457111e18f13365. * Revert "Delivery API: Content routing and structure (#13984)" This reverts commit a0292ae5350362dd6c1c5bc9763deda928c78a75. * Revert "Fix multi URL picker value converter trying to access disposed objects in edge cases" This reverts commit 6b7c37a5bf7871bee93a2b2640bbc6ef591f14db. * Revert "Conditionally enabling the delivery API + add protection and preview support + refactor all services to be singletons + ensure no-op implementations for all required services (#13992)" This reverts commit 78e1f748e55383baecd123d06457111e18f13365. * Revert "Delivery API: Content routing and structure (#13984)" This reverts commit a0292ae5350362dd6c1c5bc9763deda928c78a75. * Revert "Fix multi URL picker value converter trying to access disposed objects in edge cases" This reverts commit 6b7c37a5bf7871bee93a2b2640bbc6ef591f14db. * Fix multi URL picker value converter trying to access disposed objects in edge cases * Delivery API: Content routing and structure (#13984) * Introduce content route instead of content path, rename and rework start item (previously start node) handling * Strip out start node path in generated route path * Make the start-item header take precedence over the request domain * Conditionally enabling the delivery API + add protection and preview support + refactor all services to be singletons + ensure no-op implementations for all required services (#13992) * Test commit * Refactored interfaces for the query handlers and for the selectors (that will handle the value of the fetch query option) * Implemented a base class for the query options * Refactored the names of the selectors and made use of the base class * Refactored the ApiQueryService * Refactored the QueryContentApiController.cs * Conditionally enabling the delivery API + add protection and preview support + refactor all services to be singletons + ensure no-op implementations for all required services (#13992) * Fixing merge gone wrong * Fix multi URL picker value converter trying to access disposed objects in edge cases * Delivery API: Content routing and structure (#13984) * Introduce content route instead of content path, rename and rework start item (previously start node) handling * Strip out start node path in generated route path * Make the start-item header take precedence over the request domain * Conditionally enabling the delivery API + add protection and preview support + refactor all services to be singletons + ensure no-op implementations for all required services (#13992) * Make fetching work with the new setup * Moving files to dedicated folders * Removing ? for array * Rename selector query method * Implement FilterHandler and some filters * Implement SortHandler and sort some sorts * Refactoring * Adding more fields to index due to querying * Appending filtering and sorting queries * Implementing a new ISelectorHandler without Examine types * Re-implementing the collection to have a dedicated one for the selectors * Implementing a new IFilterHandler without Examine types & refactoring the filters implementing it * Adding a new collection dedicated to filters * Renaming the old collection * Implementing a new ISortHandler without Examine types & refactoring the sorts implementing it * Adding a new collection for the sorts & adding all collections to UmbracoBuilder.Collections * Refactoring the service to use the new collections and types * Refactoring the fields in ContentApiFieldDefinitionCollection * Remove nullability in Handlers * Don't return null for selector * Add TODO for having the filters support negation * Changing the SortType to FieldType with our custom types on the SortOption * Fix AncestorsSelector * Fix ApiQueryService * Documentation * Fix Swagger docs * Refactor the QueryContentApiController * Adding handling for the IApiContentResponse in the JsonTypeResolver * Refactor the service to use a safe fallback value in Examine queries * Adding Noop for the IApiQueryService * Cleanup * Remove comment * Fix name field for indexing * Don't inherit QueryOptionBase in filters * Fix casing for API index constant + swap FIXME with TODO * Add TODO for handling missing fetch with start-item header * Rename query handler parameters to not leak source (i.e. query string) --------- Co-authored-by: kjac Co-authored-by: Elitsa <> Co-authored-by: Zeegaan * Delivery API: Adding pagination to query endpoint (#14083) * Adding pagination to query endpoint * Optimize the paging using Examine directly * Fix comment * Remove skip/take code duplication --------- Co-authored-by: kjac * Add missing CompatibilitySuppressions.xml * Make Delivery API packable * Make Api.Common packable * Renamed extension method and namespace so it is discoverable * Untangle ApiVersion configuration into api.common, so delivery api do not require the management api to boot. * configure options in management api * RTE output as JSON for Content API (#14067) * Conditionally serve RTE output as JSON instead of HTML * Fixed merge * Rename to Delivery API (#14119) * Rename ContentApi to DeliveryApi * Rename delivery API index implementation * Update comments from "Content API" to "Delivery API" * Rename project from Content to Delivery * Add dedicated controller base for content delivery API * Rename delivery API content index to include "content" specifically * Fix compat suppressions --------- Co-authored-by: kjac Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Co-authored-by: Zeegaan --- .../ConfigureApiExplorerOptions.cs | 25 + .../ConfigureApiVersioningOptions.cs | 17 + .../Configuration/ConfigureMvcOptions.cs | 6 + .../Umbraco.Cms.Api.Common.csproj | 6 +- ...tContextOutputExpansionStrategyAccessor.cs | 12 + ...ContextRequestStartItemProviderAccessor.cs | 12 + .../RequestContextServiceAccessorBase.cs | 20 + .../Controllers/ByIdContentApiController.cs | 36 ++ .../ByRouteContentApiController.cs | 60 +++ .../Controllers/ContentApiControllerBase.cs | 20 + .../Controllers/DeliveryApiControllerBase.cs | 15 + .../Controllers/QueryContentApiController.cs | 54 +++ .../UmbracoBuilderExtensions.cs | 50 ++ .../Filters/DeliveryApiAccessAttribute.cs | 43 ++ ...calizeFromAcceptLanguageHeaderAttribute.cs | 37 ++ .../Json/DeliveryApiJsonTypeResolver.cs | 34 ++ .../Querying/Filters/ContentTypeFilter.cs | 38 ++ .../Querying/Filters/NameFilter.cs | 38 ++ .../Querying/QueryOptionBase.cs | 39 ++ .../Querying/Selectors/AncestorsSelector.cs | 51 ++ .../Querying/Selectors/ChildrenSelector.cs | 31 ++ .../Querying/Selectors/DescendantsSelector.cs | 32 ++ .../Querying/Sorts/LevelSort.cs | 26 + .../Querying/Sorts/NameSort.cs | 26 + .../Querying/Sorts/PathSort.cs | 26 + .../Querying/Sorts/SortOrderSort.cs | 26 + .../RequestContextOutputExpansionStrategy.cs | 99 ++++ .../VersionedDeliveryApiRouteAttribute.cs | 11 + .../Services/ApiAccessService.cs | 30 ++ .../Services/ApiContentQueryService.cs | 182 +++++++ .../Services/RequestCultureService.cs | 29 ++ .../Services/RequestHeaderHandler.cs | 18 + .../Services/RequestPreviewService.cs | 15 + .../Services/RequestRedirectService.cs | 78 +++ .../Services/RequestRoutingService.cs | 56 +++ .../Services/RequestStartItemProvider.cs | 49 ++ .../Services/RoutingServiceBase.cs | 63 +++ .../Umbraco.Cms.Api.Delivery.csproj | 23 + .../ManagementApiComposer.cs | 23 +- .../Umbraco.Cms.Api.Management.csproj | 2 - .../Umbraco.Cms.Targets.csproj | 1 + .../CompatibilitySuppressions.xml | 32 ++ .../Models/DeliveryApiSettings.cs | 51 ++ src/Umbraco.Core/Constants-Configuration.cs | 1 + src/Umbraco.Core/Constants-Indexes.cs | 1 + .../Constants-JsonOptionsNames.cs | 9 + .../NoopOutputExpansionStrategyAccessor.cs | 12 + .../NoopRequestStartItemProviderAccessor.cs | 12 + .../DeliveryApi/ApiContentBuilder.cs | 15 + .../DeliveryApi/ApiContentBuilderBase.cs | 37 ++ .../DeliveryApi/ApiContentNameProvider.cs | 8 + .../DeliveryApi/ApiContentResponseBuilder.cs | 25 + .../DeliveryApi/ApiContentRouteBuilder.cs | 42 ++ .../DeliveryApi/ApiElementBuilder.cs | 27 ++ .../DeliveryApi/ApiMediaBuilder.cs | 35 ++ .../DeliveryApi/ApiMediaUrlProvider.cs | 22 + .../DeliveryApi/ApiPublishedContentCache.cs | 74 +++ .../DeliveryApi/FilterHandlerCollection.cs | 11 + .../FilterHandlerCollectionBuilder.cs | 9 + src/Umbraco.Core/DeliveryApi/FilterOption.cs | 19 + .../DeliveryApi/IApiAccessService.cs | 14 + .../DeliveryApi/IApiContentBuilder.cs | 9 + .../DeliveryApi/IApiContentNameProvider.cs | 8 + .../DeliveryApi/IApiContentQueryService.cs | 20 + .../DeliveryApi/IApiContentResponseBuilder.cs | 9 + .../DeliveryApi/IApiContentRouteBuilder.cs | 9 + .../DeliveryApi/IApiElementBuilder.cs | 9 + .../DeliveryApi/IApiMediaBuilder.cs | 9 + .../DeliveryApi/IApiMediaUrlProvider.cs | 8 + .../DeliveryApi/IApiPublishedContentCache.cs | 12 + .../DeliveryApi/IFilterHandler.cs | 14 + .../DeliveryApi/IOutputExpansionStrategy.cs | 12 + .../IOutputExpansionStrategyAccessor.cs | 8 + src/Umbraco.Core/DeliveryApi/IQueryHandler.cs | 13 + .../DeliveryApi/IRequestCultureService.cs | 15 + .../DeliveryApi/IRequestPreviewService.cs | 9 + .../DeliveryApi/IRequestRedirectService.cs | 11 + .../DeliveryApi/IRequestRoutingService.cs | 9 + .../DeliveryApi/IRequestStartItemProvider.cs | 11 + .../IRequestStartItemProviderAccessor.cs | 8 + .../DeliveryApi/ISelectorHandler.cs | 14 + src/Umbraco.Core/DeliveryApi/ISortHandler.cs | 14 + .../DeliveryApi/NoopApiAccessService.cs | 10 + .../DeliveryApi/NoopApiContentQueryService.cs | 10 + .../NoopOutputExpansionStrategy.cs | 15 + .../DeliveryApi/NoopRequestCultureService.cs | 12 + .../DeliveryApi/NoopRequestPreviewService.cs | 8 + .../DeliveryApi/NoopRequestRedirectService.cs | 9 + .../DeliveryApi/NoopRequestRoutingService.cs | 7 + .../NoopRequestStartItemProvider.cs | 9 + .../DeliveryApi/SelectorHandlerCollection.cs | 11 + .../SelectorHandlerCollectionBuilder.cs | 9 + .../DeliveryApi/SelectorOption.cs | 8 + .../DeliveryApi/SortHandlerCollection.cs | 11 + .../SortHandlerCollectionBuilder.cs | 9 + src/Umbraco.Core/DeliveryApi/SortOption.cs | 17 + .../UmbracoBuilder.Collections.cs | 22 + .../UmbracoBuilder.Configuration.cs | 1 + .../Models/DeliveryApi/ApiBlockGridArea.cs | 20 + .../Models/DeliveryApi/ApiBlockGridItem.cs | 21 + .../Models/DeliveryApi/ApiBlockGridModel.cs | 14 + .../Models/DeliveryApi/ApiBlockItem.cs | 14 + .../Models/DeliveryApi/ApiBlockListModel.cs | 8 + .../Models/DeliveryApi/ApiContent.cs | 15 + .../Models/DeliveryApi/ApiContentResponse.cs | 17 + .../Models/DeliveryApi/ApiContentRoute.cs | 14 + .../Models/DeliveryApi/ApiContentStartItem.cs | 14 + .../Models/DeliveryApi/ApiElement.cs | 17 + .../Models/DeliveryApi/ApiLink.cs | 38 ++ .../Models/DeliveryApi/ApiMedia.cs | 23 + .../Models/DeliveryApi/IApiContent.cs | 8 + .../Models/DeliveryApi/IApiContentResponse.cs | 6 + .../Models/DeliveryApi/IApiContentRoute.cs | 8 + .../DeliveryApi/IApiContentStartItem.cs | 8 + .../Models/DeliveryApi/IApiElement.cs | 10 + .../Models/DeliveryApi/IApiMedia.cs | 14 + .../PublishedContent/IPublishedProperty.cs | 10 + .../IPublishedPropertyType.cs | 15 + .../PublishedContent/PublishedPropertyBase.cs | 4 + .../PublishedContent/PublishedPropertyType.cs | 35 ++ .../PublishedContent/RawValueProperty.cs | 6 + .../IDeliveryApiPropertyValueConverter.cs | 47 ++ .../TextStringValueConverter.cs | 12 +- .../ContentPickerValueConverter.cs | 66 ++- .../MediaPickerValueConverter.cs | 50 +- .../MemberGroupPickerValueConverter.cs | 37 +- .../MemberPickerValueConverter.cs | 11 +- .../MultiNodeTreePickerValueConverter.cs | 75 ++- .../Internal/InternalPublishedProperty.cs | 4 + .../PublishedElementPropertyBase.cs | 51 +- .../DeliveryApiContentIndex.cs | 20 + .../ConfigureIndexOptions.cs | 6 + .../UmbracoBuilderExtensions.cs | 2 + .../CompatibilitySuppressions.xml | 8 + .../DeliveryApi/ApiRichTextParser.cs | 172 +++++++ .../DeliveryApi/IApiRichTextParser.cs | 8 + .../UmbracoBuilder.CoreServices.cs | 30 +- .../UmbracoBuilder.Examine.cs | 2 + ...piContentIndexFieldDefinitionCollection.cs | 22 + .../DeliveryApiContentIndexPopulator.cs | 37 ++ .../DeliveryApiContentIndexValueSetBuilder.cs | 51 ++ .../Examine/ExamineUmbracoIndexingHandler.cs | 1 + ...IDeliveryApiContentIndexValueSetBuilder.cs | 7 + .../DeliveryApi/ApiImageCropperValue.cs | 19 + .../Models/DeliveryApi/ApiMediaWithCrops.cs | 32 ++ .../Models/DeliveryApi/RichTextElement.cs | 20 + .../BlockGridPropertyValueConverter.cs | 56 ++- .../BlockListPropertyValueConverter.cs | 64 ++- .../ImageCropperValueConverter.cs | 13 +- .../MarkdownEditorValueConverter.cs | 19 +- .../MediaPickerWithCropsValueConverter.cs | 55 ++- .../MultiUrlPickerValueConverter.cs | 101 +++- .../NestedContentManyValueConverter.cs | 44 +- .../NestedContentSingleValueConverter.cs | 44 +- .../RteMacroRenderingValueConverter.cs | 48 +- .../Property.cs | 51 ++ src/Umbraco.Web.UI/Startup.cs | 1 + .../Umbraco.Core/DeliveryApi/CacheTests.cs | 74 +++ .../DeliveryApi/ApiMediaUrlProviderTests.cs | 49 ++ .../Umbraco.Core/DeliveryApi/CacheTests.cs | 60 +++ .../DeliveryApi/ContentBuilderTests.cs | 69 +++ .../ContentPickerValueConverterTests.cs | 109 +++++ .../DeliveryApi/ContentRouteBuilderTests.cs | 210 +++++++++ .../DeliveryApi/DeliveryApiTests.cs | 108 +++++ .../ImageCropperValueConverterTests.cs | 69 +++ .../MarkdownEditorValueConverterTests.cs | 35 ++ .../DeliveryApi/MediaBuilderTests.cs | 108 +++++ .../MediaPickerValueConverterTests.cs | 102 ++++ ...MediaPickerWithCropsValueConverterTests.cs | 275 +++++++++++ .../MultiNodeTreePickerValueConverterTests.cs | 277 +++++++++++ .../MultiUrlPickerValueConverterTests.cs | 275 +++++++++++ .../NestedContentValueConverterTests.cs | 154 ++++++ .../OutputExpansionStrategyTests.cs | 444 ++++++++++++++++++ .../PropertyValueConverterTests.cs | 110 +++++ .../DeliveryApi/PublishedContentCacheTests.cs | 200 ++++++++ .../DeliveryApi/PublishedPropertyTypeTests.cs | 34 ++ .../DeliveryApi/RichTextParserTests.cs | 250 ++++++++++ .../BlockListPropertyValueConverterTests.cs | 5 +- .../Published/NestedContentTests.cs | 11 +- .../Umbraco.Tests.UnitTests.csproj | 1 + tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs | 2 + umbraco.sln | 8 + 182 files changed, 6904 insertions(+), 74 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Common/Configuration/ConfigureApiExplorerOptions.cs create mode 100644 src/Umbraco.Cms.Api.Common/Configuration/ConfigureApiVersioningOptions.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Accessors/RequestContextOutputExpansionStrategyAccessor.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Accessors/RequestContextRequestStartItemProviderAccessor.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Accessors/RequestContextServiceAccessorBase.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Controllers/ByIdContentApiController.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Controllers/ByRouteContentApiController.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Controllers/QueryContentApiController.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Filters/DeliveryApiAccessAttribute.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Filters/LocalizeFromAcceptLanguageHeaderAttribute.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiJsonTypeResolver.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Querying/Filters/ContentTypeFilter.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Querying/Filters/NameFilter.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Querying/QueryOptionBase.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Querying/Selectors/AncestorsSelector.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Querying/Selectors/ChildrenSelector.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Querying/Selectors/DescendantsSelector.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Querying/Sorts/LevelSort.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Querying/Sorts/NameSort.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Querying/Sorts/PathSort.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Querying/Sorts/SortOrderSort.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategy.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Routing/VersionedDeliveryApiRouteAttribute.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Services/ApiAccessService.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Services/RequestCultureService.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Services/RequestHeaderHandler.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Services/RequestPreviewService.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Services/RequestRoutingService.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Services/RequestStartItemProvider.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Services/RoutingServiceBase.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj create mode 100644 src/Umbraco.Core/CompatibilitySuppressions.xml create mode 100644 src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs create mode 100644 src/Umbraco.Core/Constants-JsonOptionsNames.cs create mode 100644 src/Umbraco.Core/DeliveryApi/Accessors/NoopOutputExpansionStrategyAccessor.cs create mode 100644 src/Umbraco.Core/DeliveryApi/Accessors/NoopRequestStartItemProviderAccessor.cs create mode 100644 src/Umbraco.Core/DeliveryApi/ApiContentBuilder.cs create mode 100644 src/Umbraco.Core/DeliveryApi/ApiContentBuilderBase.cs create mode 100644 src/Umbraco.Core/DeliveryApi/ApiContentNameProvider.cs create mode 100644 src/Umbraco.Core/DeliveryApi/ApiContentResponseBuilder.cs create mode 100644 src/Umbraco.Core/DeliveryApi/ApiContentRouteBuilder.cs create mode 100644 src/Umbraco.Core/DeliveryApi/ApiElementBuilder.cs create mode 100644 src/Umbraco.Core/DeliveryApi/ApiMediaBuilder.cs create mode 100644 src/Umbraco.Core/DeliveryApi/ApiMediaUrlProvider.cs create mode 100644 src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs create mode 100644 src/Umbraco.Core/DeliveryApi/FilterHandlerCollection.cs create mode 100644 src/Umbraco.Core/DeliveryApi/FilterHandlerCollectionBuilder.cs create mode 100644 src/Umbraco.Core/DeliveryApi/FilterOption.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IApiAccessService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IApiContentBuilder.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IApiContentNameProvider.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IApiContentQueryService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IApiContentResponseBuilder.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IApiContentRouteBuilder.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IApiElementBuilder.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IApiMediaBuilder.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IApiMediaUrlProvider.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IApiPublishedContentCache.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IFilterHandler.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IOutputExpansionStrategy.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IOutputExpansionStrategyAccessor.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IQueryHandler.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IRequestCultureService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IRequestPreviewService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IRequestRedirectService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IRequestRoutingService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IRequestStartItemProvider.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IRequestStartItemProviderAccessor.cs create mode 100644 src/Umbraco.Core/DeliveryApi/ISelectorHandler.cs create mode 100644 src/Umbraco.Core/DeliveryApi/ISortHandler.cs create mode 100644 src/Umbraco.Core/DeliveryApi/NoopApiAccessService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/NoopApiContentQueryService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/NoopOutputExpansionStrategy.cs create mode 100644 src/Umbraco.Core/DeliveryApi/NoopRequestCultureService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/NoopRequestPreviewService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/NoopRequestRedirectService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/NoopRequestRoutingService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/NoopRequestStartItemProvider.cs create mode 100644 src/Umbraco.Core/DeliveryApi/SelectorHandlerCollection.cs create mode 100644 src/Umbraco.Core/DeliveryApi/SelectorHandlerCollectionBuilder.cs create mode 100644 src/Umbraco.Core/DeliveryApi/SelectorOption.cs create mode 100644 src/Umbraco.Core/DeliveryApi/SortHandlerCollection.cs create mode 100644 src/Umbraco.Core/DeliveryApi/SortHandlerCollectionBuilder.cs create mode 100644 src/Umbraco.Core/DeliveryApi/SortOption.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/ApiBlockGridArea.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/ApiBlockGridItem.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/ApiBlockGridModel.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/ApiBlockItem.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/ApiBlockListModel.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/ApiContent.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/ApiContentResponse.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/ApiContentRoute.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/ApiContentStartItem.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/ApiElement.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/ApiLink.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/ApiMedia.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/IApiContent.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/IApiContentResponse.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/IApiContentRoute.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/IApiContentStartItem.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/IApiElement.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/IApiMedia.cs create mode 100644 src/Umbraco.Core/PropertyEditors/DeliveryApi/IDeliveryApiPropertyValueConverter.cs create mode 100644 src/Umbraco.Examine.Lucene/DeliveryApiContentIndex.cs create mode 100644 src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextParser.cs create mode 100644 src/Umbraco.Infrastructure/DeliveryApi/IApiRichTextParser.cs create mode 100644 src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexFieldDefinitionCollection.cs create mode 100644 src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexPopulator.cs create mode 100644 src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexValueSetBuilder.cs create mode 100644 src/Umbraco.Infrastructure/Examine/IDeliveryApiContentIndexValueSetBuilder.cs create mode 100644 src/Umbraco.Infrastructure/Models/DeliveryApi/ApiImageCropperValue.cs create mode 100644 src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCrops.cs create mode 100644 src/Umbraco.Infrastructure/Models/DeliveryApi/RichTextElement.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/CacheTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ApiMediaUrlProviderTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/CacheTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentBuilderTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentPickerValueConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentRouteBuilderTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ImageCropperValueConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MarkdownEditorValueConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaBuilderTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerValueConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiNodeTreePickerValueConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiUrlPickerValueConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/NestedContentValueConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PropertyValueConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PublishedContentCacheTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PublishedPropertyTypeTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/RichTextParserTests.cs diff --git a/src/Umbraco.Cms.Api.Common/Configuration/ConfigureApiExplorerOptions.cs b/src/Umbraco.Cms.Api.Common/Configuration/ConfigureApiExplorerOptions.cs new file mode 100644 index 0000000000..e6093cf29e --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/Configuration/ConfigureApiExplorerOptions.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Versioning; +using Microsoft.Extensions.Options; + +namespace Umbraco.Cms.Api.Common.Configuration; + +public class ConfigureApiExplorerOptions : IConfigureOptions +{ + private readonly IOptions _apiVersioningOptions; + + public ConfigureApiExplorerOptions(IOptions apiVersioningOptions) + { + _apiVersioningOptions = apiVersioningOptions; + } + + public void Configure(ApiExplorerOptions options) + { + options.DefaultApiVersion = _apiVersioningOptions.Value.DefaultApiVersion; + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + options.AddApiVersionParametersWhenVersionNeutral = true; + options.AssumeDefaultVersionWhenUnspecified = true; + } +} diff --git a/src/Umbraco.Cms.Api.Common/Configuration/ConfigureApiVersioningOptions.cs b/src/Umbraco.Cms.Api.Common/Configuration/ConfigureApiVersioningOptions.cs new file mode 100644 index 0000000000..928ea3925e --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/Configuration/ConfigureApiVersioningOptions.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Versioning; +using Microsoft.Extensions.Options; + +namespace Umbraco.Cms.Api.Common.Configuration; + +public class ConfigureApiVersioningOptions : IConfigureOptions +{ + public void Configure(ApiVersioningOptions options) + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.ReportApiVersions = true; + options.ApiVersionReader = new UrlSegmentApiVersionReader(); + options.AssumeDefaultVersionWhenUnspecified = true; + options.UseApiBehavior = false; + } +} diff --git a/src/Umbraco.Cms.Api.Common/Configuration/ConfigureMvcOptions.cs b/src/Umbraco.Cms.Api.Common/Configuration/ConfigureMvcOptions.cs index 7dd11bd629..0eefc6710c 100644 --- a/src/Umbraco.Cms.Api.Common/Configuration/ConfigureMvcOptions.cs +++ b/src/Umbraco.Cms.Api.Common/Configuration/ConfigureMvcOptions.cs @@ -14,6 +14,12 @@ public class ConfigureMvcOptions : IConfigureOptions public void Configure(MvcOptions options) { + // these MVC options may be applied more than once; let's make sure we only execute once. + if (options.Conventions.Any(convention => convention is UmbracoBackofficeToken)) + { + return; + } + // Replace the BackOfficeToken in routes. var backofficePath = _globalSettings.Value.UmbracoPath.TrimStart(Constants.CharArrays.TildeForwardSlash); diff --git a/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj b/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj index 0f37635a2c..f979fbd49b 100644 --- a/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj +++ b/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj @@ -2,13 +2,15 @@ Umbraco CMS - API Common Contains the bits and pieces that are shared between the Umbraco CMS APIs. - false + true false Umbraco.Cms.Api.Common Umbraco.Cms.Api.Common - + + + diff --git a/src/Umbraco.Cms.Api.Delivery/Accessors/RequestContextOutputExpansionStrategyAccessor.cs b/src/Umbraco.Cms.Api.Delivery/Accessors/RequestContextOutputExpansionStrategyAccessor.cs new file mode 100644 index 0000000000..146e356684 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Accessors/RequestContextOutputExpansionStrategyAccessor.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Http; +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Accessors; + +public class RequestContextOutputExpansionStrategyAccessor : RequestContextServiceAccessorBase, IOutputExpansionStrategyAccessor +{ + public RequestContextOutputExpansionStrategyAccessor(IHttpContextAccessor httpContextAccessor) + : base(httpContextAccessor) + { + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Accessors/RequestContextRequestStartItemProviderAccessor.cs b/src/Umbraco.Cms.Api.Delivery/Accessors/RequestContextRequestStartItemProviderAccessor.cs new file mode 100644 index 0000000000..b993a25f5c --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Accessors/RequestContextRequestStartItemProviderAccessor.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Http; +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Accessors; + +public class RequestContextRequestStartItemProviderAccessor : RequestContextServiceAccessorBase, IRequestStartItemProviderAccessor +{ + public RequestContextRequestStartItemProviderAccessor(IHttpContextAccessor httpContextAccessor) + : base(httpContextAccessor) + { + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Accessors/RequestContextServiceAccessorBase.cs b/src/Umbraco.Cms.Api.Delivery/Accessors/RequestContextServiceAccessorBase.cs new file mode 100644 index 0000000000..fb95ccb86b --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Accessors/RequestContextServiceAccessorBase.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Umbraco.Cms.Api.Delivery.Accessors; + +public abstract class RequestContextServiceAccessorBase + where T : class +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + protected RequestContextServiceAccessorBase(IHttpContextAccessor httpContextAccessor) + => _httpContextAccessor = httpContextAccessor; + + public bool TryGetValue([NotNullWhen(true)] out T? requestStartNodeService) + { + requestStartNodeService = _httpContextAccessor.HttpContext?.RequestServices.GetService(); + return requestStartNodeService is not null; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdContentApiController.cs new file mode 100644 index 0000000000..514ac518d5 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdContentApiController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +public class ByIdContentApiController : ContentApiControllerBase +{ + public ByIdContentApiController(IApiPublishedContentCache apiPublishedContentCache, IApiContentResponseBuilder apiContentResponseBuilderBuilder) + : base(apiPublishedContentCache, apiContentResponseBuilderBuilder) + { + } + + /// + /// Gets a content item by id. + /// + /// The unique identifier of the content item. + /// The content item or not found result. + [HttpGet("item/{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ById(Guid id) + { + IPublishedContent? contentItem = ApiPublishedContentCache.GetById(id); + + if (contentItem is null) + { + return NotFound(); + } + + return await Task.FromResult(Ok(ApiContentResponseBuilder.Build(contentItem))); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ByRouteContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ByRouteContentApiController.cs new file mode 100644 index 0000000000..ae1bc6c311 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ByRouteContentApiController.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +public class ByRouteContentApiController : ContentApiControllerBase +{ + private readonly IRequestRoutingService _requestRoutingService; + private readonly IRequestRedirectService _requestRedirectService; + + public ByRouteContentApiController( + IApiPublishedContentCache apiPublishedContentCache, + IApiContentResponseBuilder apiContentResponseBuilder, + IRequestRoutingService requestRoutingService, + IRequestRedirectService requestRedirectService) + : base(apiPublishedContentCache, apiContentResponseBuilder) + { + _requestRoutingService = requestRoutingService; + _requestRedirectService = requestRedirectService; + } + + /// + /// Gets a content item by route. + /// + /// The path to the content item. + /// + /// Optional URL segment for the root content item + /// can be added through the "start-item" header. + /// + /// The content item or not found result. + [HttpGet("item/{*path}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ByRoute(string path = "/") + { + var contentRoute = _requestRoutingService.GetContentRoute(path); + + IPublishedContent? contentItem = ApiPublishedContentCache.GetByRoute(contentRoute); + if (contentItem is not null) + { + return await Task.FromResult(Ok(ApiContentResponseBuilder.Build(contentItem))); + } + + IApiContentRoute? redirectRoute = _requestRedirectService.GetRedirectRoute(path); + return redirectRoute != null + ? RedirectTo(redirectRoute) + : NotFound(); + } + + private IActionResult RedirectTo(IApiContentRoute redirectRoute) + { + Response.Headers.Add("Location-Start-Item-Path", redirectRoute.StartItem.Path); + Response.Headers.Add("Location-Start-Item-Id", redirectRoute.StartItem.Id.ToString("D")); + return RedirectPermanent(redirectRoute.Path); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs new file mode 100644 index 0000000000..ea85c5f544 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Delivery.Routing; +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +[VersionedDeliveryApiRoute("content")] +[ApiExplorerSettings(GroupName = "Content")] +public abstract class ContentApiControllerBase : DeliveryApiControllerBase +{ + protected IApiPublishedContentCache ApiPublishedContentCache { get; } + + protected IApiContentResponseBuilder ApiContentResponseBuilder { get; } + + protected ContentApiControllerBase(IApiPublishedContentCache apiPublishedContentCache, IApiContentResponseBuilder apiContentResponseBuilder) + { + ApiPublishedContentCache = apiPublishedContentCache; + ApiContentResponseBuilder = apiContentResponseBuilder; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs new file mode 100644 index 0000000000..323211726b --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Filters; +using Umbraco.Cms.Api.Delivery.Filters; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +[ApiController] +[ApiVersion("1.0")] +[DeliveryApiAccess] +[JsonOptionsName(Constants.JsonOptionsNames.DeliveryApi)] +[LocalizeFromAcceptLanguageHeader] +public abstract class DeliveryApiControllerBase : Controller +{ +} diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/QueryContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/QueryContentApiController.cs new file mode 100644 index 0000000000..e220269310 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/QueryContentApiController.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +public class QueryContentApiController : ContentApiControllerBase +{ + private readonly IApiContentQueryService _apiContentQueryService; + + public QueryContentApiController( + IApiPublishedContentCache apiPublishedContentCache, + IApiContentResponseBuilder apiContentResponseBuilderBuilder, + IApiContentQueryService apiContentQueryService) + : base(apiPublishedContentCache, apiContentResponseBuilderBuilder) + => _apiContentQueryService = apiContentQueryService; + + /// + /// Gets a paginated list of content item(s) from query. + /// + /// Optional fetch query parameter value. + /// Optional filter query parameters values. + /// Optional sort query parameters values. + /// The amount of items to skip. + /// The amount of items to take. + /// The paged result of the content item(s). + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> Query( + string? fetch, + [FromQuery] string[] filter, + [FromQuery] string[] sort, + int skip = 0, + int take = 10) + { + PagedModel pagedResult = _apiContentQueryService.ExecuteQuery(fetch, filter, sort, skip, take); + IEnumerable contentItems = ApiPublishedContentCache.GetByIds(pagedResult.Items); + IApiContentResponse[] apiContentItems = contentItems.Select(ApiContentResponseBuilder.Build).ToArray(); + + var model = new PagedViewModel + { + Total = pagedResult.Total, + Items = apiContentItems + }; + + return await Task.FromResult(Ok(model)); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs new file mode 100644 index 0000000000..6f2ed5a2a3 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Common.Configuration; +using Umbraco.Cms.Api.Common.DependencyInjection; +using Umbraco.Cms.Api.Delivery.Accessors; +using Umbraco.Cms.Api.Delivery.Json; +using Umbraco.Cms.Api.Delivery.Rendering; +using Umbraco.Cms.Api.Delivery.Services; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.DependencyInjection; + +namespace Umbraco.Extensions; + +public static class UmbracoBuilderExtensions +{ + public static IUmbracoBuilder AddDeliveryApi(this IUmbracoBuilder builder) + { + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + builder.Services.ConfigureOptions(); + builder.Services.AddApiVersioning(); + builder.Services.ConfigureOptions(); + builder.Services.AddVersionedApiExplorer(); + + builder + .Services + .ConfigureOptions() + .AddControllers() + .AddJsonOptions(Constants.JsonOptionsNames.DeliveryApi, options => + { + // all Delivery API specific JSON options go here + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.JsonSerializerOptions.TypeInfoResolver = new DeliveryApiJsonTypeResolver(); + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + + return builder; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/DeliveryApiAccessAttribute.cs b/src/Umbraco.Cms.Api.Delivery/Filters/DeliveryApiAccessAttribute.cs new file mode 100644 index 0000000000..e67b9eb444 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Filters/DeliveryApiAccessAttribute.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Filters; + +public class DeliveryApiAccessAttribute : TypeFilterAttribute +{ + public DeliveryApiAccessAttribute() + : base(typeof(DeliveryApiAccessFilter)) + { + } + + private class DeliveryApiAccessFilter : IActionFilter + { + private readonly IApiAccessService _apiAccessService; + private readonly IRequestPreviewService _requestPreviewService; + + public DeliveryApiAccessFilter(IApiAccessService apiAccessService, IRequestPreviewService requestPreviewService) + { + _apiAccessService = apiAccessService; + _requestPreviewService = requestPreviewService; + } + + public void OnActionExecuting(ActionExecutingContext context) + { + var hasAccess = _requestPreviewService.IsPreview() + ? _apiAccessService.HasPreviewAccess() + : _apiAccessService.HasPublicAccess(); + + if (hasAccess) + { + return; + } + + context.Result = new UnauthorizedResult(); + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/LocalizeFromAcceptLanguageHeaderAttribute.cs b/src/Umbraco.Cms.Api.Delivery/Filters/LocalizeFromAcceptLanguageHeaderAttribute.cs new file mode 100644 index 0000000000..7f3d8c2146 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Filters/LocalizeFromAcceptLanguageHeaderAttribute.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Filters; + +public class LocalizeFromAcceptLanguageHeaderAttribute : TypeFilterAttribute +{ + public LocalizeFromAcceptLanguageHeaderAttribute() + : base(typeof(LocalizeFromAcceptLanguageHeaderAttributeFilter)) + { + } + + private class LocalizeFromAcceptLanguageHeaderAttributeFilter : IActionFilter + { + private readonly IRequestCultureService _requestCultureService; + + public LocalizeFromAcceptLanguageHeaderAttributeFilter(IRequestCultureService requestCultureService) + => _requestCultureService = requestCultureService; + + public void OnActionExecuting(ActionExecutingContext context) + { + var requestedCulture = _requestCultureService.GetRequestedCulture(); + if (requestedCulture.IsNullOrWhiteSpace()) + { + return; + } + + _requestCultureService.SetRequestCulture(requestedCulture); + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiJsonTypeResolver.cs b/src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiJsonTypeResolver.cs new file mode 100644 index 0000000000..899ea98380 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiJsonTypeResolver.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Umbraco.Cms.Core.Models.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Json; + +// see https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/polymorphism?pivots=dotnet-7-0 +// TODO: if this type resolver is to be used for extendable content models (custom IApiContent implementations) we need to work out an extension model for known derived types +public class DeliveryApiJsonTypeResolver : DefaultJsonTypeInfoResolver +{ + public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) + { + JsonTypeInfo jsonTypeInfo = base.GetTypeInfo(type, options); + + if (jsonTypeInfo.Type == typeof(IApiContent)) + { + ConfigureJsonPolymorphismOptions(jsonTypeInfo, typeof(ApiContent)); + } + else if (jsonTypeInfo.Type == typeof(IApiContentResponse)) + { + ConfigureJsonPolymorphismOptions(jsonTypeInfo, typeof(ApiContentResponse)); + } + + return jsonTypeInfo; + } + + private void ConfigureJsonPolymorphismOptions(JsonTypeInfo jsonTypeInfo, Type derivedType) + => jsonTypeInfo.PolymorphismOptions = new JsonPolymorphismOptions + { + UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization, + DerivedTypes = { new JsonDerivedType(derivedType) } + }; +} diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Filters/ContentTypeFilter.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Filters/ContentTypeFilter.cs new file mode 100644 index 0000000000..be6069ec8f --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Filters/ContentTypeFilter.cs @@ -0,0 +1,38 @@ +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Querying.Filters; + +internal sealed class ContentTypeFilter : IFilterHandler +{ + private const string ContentTypeSpecifier = "contentType:"; + + /// + public bool CanHandle(string query) + => query.StartsWith(ContentTypeSpecifier, StringComparison.OrdinalIgnoreCase); + + /// + public FilterOption BuildFilterOption(string filter) + { + var alias = filter.Substring(ContentTypeSpecifier.Length); + + var filterOption = new FilterOption + { + FieldName = "__NodeTypeAlias", + Value = string.Empty + }; + + // TODO: do we support negation? + if (alias.StartsWith('!')) + { + filterOption.Value = alias.Substring(1); + filterOption.Operator = FilterOperation.IsNot; + } + else + { + filterOption.Value = alias; + filterOption.Operator = FilterOperation.Is; + } + + return filterOption; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Filters/NameFilter.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Filters/NameFilter.cs new file mode 100644 index 0000000000..a2fcdd6872 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Filters/NameFilter.cs @@ -0,0 +1,38 @@ +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Querying.Filters; + +internal sealed class NameFilter : IFilterHandler +{ + private const string NameSpecifier = "name:"; + + /// + public bool CanHandle(string query) + => query.StartsWith(NameSpecifier, StringComparison.OrdinalIgnoreCase); + + /// + public FilterOption BuildFilterOption(string filter) + { + var value = filter.Substring(NameSpecifier.Length); + + var filterOption = new FilterOption + { + FieldName = "name", + Value = string.Empty + }; + + // TODO: do we support negation? + if (value.StartsWith('!')) + { + filterOption.Value = value.Substring(1); + filterOption.Operator = FilterOperation.IsNot; + } + else + { + filterOption.Value = value; + filterOption.Operator = FilterOperation.Is; + } + + return filterOption; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/QueryOptionBase.cs b/src/Umbraco.Cms.Api.Delivery/Querying/QueryOptionBase.cs new file mode 100644 index 0000000000..daf6d9fe16 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Querying/QueryOptionBase.cs @@ -0,0 +1,39 @@ +using System; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; + +namespace Umbraco.Cms.Api.Delivery.Querying; + +public abstract class QueryOptionBase +{ + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly IRequestRoutingService _requestRoutingService; + + public QueryOptionBase(IPublishedSnapshotAccessor publishedSnapshotAccessor, + IRequestRoutingService requestRoutingService) + { + _publishedSnapshotAccessor = publishedSnapshotAccessor; + _requestRoutingService = requestRoutingService; + } + + public Guid? GetGuidFromQuery(string queryStringValue) + { + if (Guid.TryParse(queryStringValue, out Guid id)) + { + return id; + } + + if (!_publishedSnapshotAccessor.TryGetPublishedSnapshot(out IPublishedSnapshot? publishedSnapshot) || + publishedSnapshot?.Content is null) + { + return null; + } + + // Check if the passed value is a path of a content item + var contentRoute = _requestRoutingService.GetContentRoute(queryStringValue); + IPublishedContent? contentItem = publishedSnapshot.Content.GetByRoute(contentRoute); + + return contentItem?.Key; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/AncestorsSelector.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/AncestorsSelector.cs new file mode 100644 index 0000000000..4539a8f3d6 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/AncestorsSelector.cs @@ -0,0 +1,51 @@ +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Querying.Selectors; + +internal sealed class AncestorsSelector : QueryOptionBase, ISelectorHandler +{ + private const string AncestorsSpecifier = "ancestors:"; + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + + public AncestorsSelector(IPublishedSnapshotAccessor publishedSnapshotAccessor, IRequestRoutingService requestRoutingService) + : base(publishedSnapshotAccessor, requestRoutingService) => + _publishedSnapshotAccessor = publishedSnapshotAccessor; + + /// + public bool CanHandle(string query) + => query.StartsWith(AncestorsSpecifier, StringComparison.OrdinalIgnoreCase); + + /// + public SelectorOption BuildSelectorOption(string selector) + { + var fieldValue = selector.Substring(AncestorsSpecifier.Length); + Guid? id = GetGuidFromQuery(fieldValue); + + if (id is null || + !_publishedSnapshotAccessor.TryGetPublishedSnapshot(out IPublishedSnapshot? publishedSnapshot) || + publishedSnapshot?.Content is null) + { + // Setting the Value to "" since that would yield no results. + // It won't be appropriate to return null here since if we reached this, + // it means that CanHandle() returned true, meaning that this Selector should be able to handle the selector value + return new SelectorOption + { + FieldName = "id", + Value = string.Empty + }; + } + + // With the previous check we made sure that if we reach this, we already made sure that there is a valid content item + IPublishedContent contentItem = publishedSnapshot.Content.GetById((Guid)id)!; // so it can't be null + IEnumerable ancestorKeys = contentItem.Ancestors().Select(a => a.Key); + + return new SelectorOption + { + FieldName = "id", + Value = string.Join(" ", ancestorKeys) + }; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/ChildrenSelector.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/ChildrenSelector.cs new file mode 100644 index 0000000000..044b5c99b5 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/ChildrenSelector.cs @@ -0,0 +1,31 @@ +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.PublishedCache; + +namespace Umbraco.Cms.Api.Delivery.Querying.Selectors; + +internal sealed class ChildrenSelector : QueryOptionBase, ISelectorHandler +{ + private const string ChildrenSpecifier = "children:"; + + public ChildrenSelector(IPublishedSnapshotAccessor publishedSnapshotAccessor, IRequestRoutingService requestRoutingService) + : base(publishedSnapshotAccessor, requestRoutingService) + { + } + + /// + public bool CanHandle(string query) + => query.StartsWith(ChildrenSpecifier, StringComparison.OrdinalIgnoreCase); + + /// + public SelectorOption BuildSelectorOption(string selector) + { + var fieldValue = selector.Substring(ChildrenSpecifier.Length); + Guid? id = GetGuidFromQuery(fieldValue); + + return new SelectorOption + { + FieldName = "parentKey", + Value = id.ToString() ?? string.Empty + }; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/DescendantsSelector.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/DescendantsSelector.cs new file mode 100644 index 0000000000..354cbd5483 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Selectors/DescendantsSelector.cs @@ -0,0 +1,32 @@ +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.PublishedCache; + +namespace Umbraco.Cms.Api.Delivery.Querying.Selectors; + +internal sealed class DescendantsSelector : QueryOptionBase, ISelectorHandler +{ + private const string DescendantsSpecifier = "descendants:"; + + public DescendantsSelector(IPublishedSnapshotAccessor publishedSnapshotAccessor, + IRequestRoutingService requestRoutingService) + : base(publishedSnapshotAccessor, requestRoutingService) + { + } + + /// + public bool CanHandle(string query) + => query.StartsWith(DescendantsSpecifier, StringComparison.OrdinalIgnoreCase); + + /// + public SelectorOption BuildSelectorOption(string selector) + { + var fieldValue = selector.Substring(DescendantsSpecifier.Length); + Guid? id = GetGuidFromQuery(fieldValue); + + return new SelectorOption + { + FieldName = "ancestorKeys", + Value = id.ToString() ?? string.Empty + }; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Sorts/LevelSort.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Sorts/LevelSort.cs new file mode 100644 index 0000000000..6a14589c86 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Sorts/LevelSort.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Querying.Sorts; + +internal sealed class LevelSort : ISortHandler +{ + private const string SortOptionSpecifier = "level:"; + + /// + public bool CanHandle(string query) + => query.StartsWith(SortOptionSpecifier, StringComparison.OrdinalIgnoreCase); + + /// + public SortOption BuildSortOption(string sort) + { + var sortDirection = sort.Substring(SortOptionSpecifier.Length); + + return new SortOption + { + FieldName = "level", + Direction = sortDirection.StartsWith("asc") ? Direction.Ascending : Direction.Descending, + FieldType = FieldType.Number + }; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Sorts/NameSort.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Sorts/NameSort.cs new file mode 100644 index 0000000000..fd5b659b96 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Sorts/NameSort.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Querying.Sorts; + +internal sealed class NameSort : ISortHandler +{ + private const string SortOptionSpecifier = "name:"; + + /// + public bool CanHandle(string query) + => query.StartsWith(SortOptionSpecifier, StringComparison.OrdinalIgnoreCase); + + /// + public SortOption BuildSortOption(string sort) + { + var sortDirection = sort.Substring(SortOptionSpecifier.Length); + + return new SortOption + { + FieldName = "name", + Direction = sortDirection.StartsWith("asc") ? Direction.Ascending : Direction.Descending, + FieldType = FieldType.String + }; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Sorts/PathSort.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Sorts/PathSort.cs new file mode 100644 index 0000000000..ac4ac617af --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Sorts/PathSort.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Querying.Sorts; + +internal sealed class PathSort : ISortHandler +{ + private const string SortOptionSpecifier = "path:"; + + /// + public bool CanHandle(string query) + => query.StartsWith(SortOptionSpecifier, StringComparison.OrdinalIgnoreCase); + + /// + public SortOption BuildSortOption(string sort) + { + var sortDirection = sort.Substring(SortOptionSpecifier.Length); + + return new SortOption + { + FieldName = "path", + Direction = sortDirection.StartsWith("asc") ? Direction.Ascending : Direction.Descending, + FieldType = FieldType.String + }; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Sorts/SortOrderSort.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Sorts/SortOrderSort.cs new file mode 100644 index 0000000000..0d5038ae14 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Sorts/SortOrderSort.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Querying.Sorts; + +internal sealed class SortOrderSort : ISortHandler +{ + private const string SortOptionSpecifier = "sortOrder:"; + + /// + public bool CanHandle(string query) + => query.StartsWith(SortOptionSpecifier, StringComparison.OrdinalIgnoreCase); + + /// + public SortOption BuildSortOption(string sort) + { + var sortDirection = sort.Substring(SortOptionSpecifier.Length); + + return new SortOption + { + FieldName = "sortOrder", + Direction = sortDirection.StartsWith("asc") ? Direction.Ascending : Direction.Descending, + FieldType = FieldType.Number + }; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategy.cs b/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategy.cs new file mode 100644 index 0000000000..b20f6ec76a --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategy.cs @@ -0,0 +1,99 @@ +using Microsoft.AspNetCore.Http; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Rendering; + +internal sealed class RequestContextOutputExpansionStrategy : IOutputExpansionStrategy +{ + private readonly bool _expandAll; + private readonly string[] _expandAliases; + + private ExpansionState _state; + + public RequestContextOutputExpansionStrategy(IHttpContextAccessor httpContextAccessor) + { + (bool ExpandAll, string[] ExpanedAliases) initialState = InitialRequestState(httpContextAccessor); + _expandAll = initialState.ExpandAll; + _expandAliases = initialState.ExpanedAliases; + _state = ExpansionState.Initial; + } + + public IDictionary MapElementProperties(IPublishedElement element) + => MapProperties(element.Properties); + + public IDictionary MapProperties(IEnumerable properties) + => properties.ToDictionary( + p => p.Alias, + p => p.GetDeliveryApiValue(_state == ExpansionState.Expanding)); + + public IDictionary MapContentProperties(IPublishedContent content) + { + // in the initial state, content properties should always be rendered (expanded if the requests dictates it). + // this corresponds to the root level of a content item, i.e. when the initial content rendering starts. + if (_state == ExpansionState.Initial) + { + // update state to pending so we don't end up here the next time around + _state = ExpansionState.Pending; + var rendered = content.Properties.ToDictionary( + property => property.Alias, + property => + { + // update state to expanding if the property should be expanded (needed for nested elements) + if (_expandAll || _expandAliases.Contains(property.Alias)) + { + _state = ExpansionState.Expanding; + } + + var value = property.GetDeliveryApiValue(_state == ExpansionState.Expanding); + + // always revert to pending after rendering the property value + _state = ExpansionState.Pending; + return value; + }); + _state = ExpansionState.Initial; + return rendered; + } + + // in an expanding state, properties should always be rendered as collapsed. + // this corresponds to properties of a content based property placed directly below a root level property that is being expanded + // (i.e. properties for picked content for an expanded content picker at root level). + if (_state == ExpansionState.Expanding) + { + _state = ExpansionState.Expanded; + var rendered = content.Properties.ToDictionary( + property => property.Alias, + property => property.GetDeliveryApiValue(false)); + _state = ExpansionState.Expanding; + return rendered; + } + + return new Dictionary(); + } + + private (bool ExpandAll, string[] ExpanedAliases) InitialRequestState(IHttpContextAccessor httpContextAccessor) + { + string? toExpand = httpContextAccessor.HttpContext?.Request.Query["expand"]; + if (toExpand.IsNullOrWhiteSpace()) + { + return new(false, Array.Empty()); + } + + const string propertySpecifier = "property:"; + return new( + toExpand == "all", + toExpand.StartsWith(propertySpecifier) + ? toExpand.Substring(propertySpecifier.Length).Split(Constants.CharArrays.Comma) + : Array.Empty()); + } + + private enum ExpansionState + { + Initial, + Pending, + Expanding, + Expanded + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Routing/VersionedDeliveryApiRouteAttribute.cs b/src/Umbraco.Cms.Api.Delivery/Routing/VersionedDeliveryApiRouteAttribute.cs new file mode 100644 index 0000000000..2f9a3f055a --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Routing/VersionedDeliveryApiRouteAttribute.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Api.Common.Routing; + +namespace Umbraco.Cms.Api.Delivery.Routing; + +public class VersionedDeliveryApiRouteAttribute : BackOfficeRouteAttribute +{ + public VersionedDeliveryApiRouteAttribute(string template) + : base($"delivery/api/v{{version:apiVersion}}/{template.TrimStart('/')}") + { + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiAccessService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiAccessService.cs new file mode 100644 index 0000000000..b87205501c --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiAccessService.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Services; + +internal sealed class ApiAccessService : RequestHeaderHandler, IApiAccessService +{ + private DeliveryApiSettings _deliveryApiSettings; + + public ApiAccessService(IHttpContextAccessor httpContextAccessor, IOptionsMonitor deliveryApiSettings) + : base(httpContextAccessor) + { + _deliveryApiSettings = deliveryApiSettings.CurrentValue; + deliveryApiSettings.OnChange(settings => _deliveryApiSettings = settings); + } + + /// + public bool HasPublicAccess() => IfEnabled(() => _deliveryApiSettings.PublicAccess || HasValidApiKey()); + + /// + public bool HasPreviewAccess() => IfEnabled(HasValidApiKey); + + private bool IfEnabled(Func condition) => _deliveryApiSettings.Enabled && condition(); + + private bool HasValidApiKey() => _deliveryApiSettings.ApiKey.IsNullOrWhiteSpace() == false + && _deliveryApiSettings.ApiKey.Equals(GetHeaderValue("Api-Key")); +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs new file mode 100644 index 0000000000..193375d135 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs @@ -0,0 +1,182 @@ +using Examine; +using Examine.Search; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Delivery.Services; + +internal sealed class ApiContentQueryService : IApiContentQueryService // Examine-specific implementation - can be swapped out +{ + private readonly IExamineManager _examineManager; + private readonly SelectorHandlerCollection _selectorHandlers; + private readonly FilterHandlerCollection _filterHandlers; + private readonly SortHandlerCollection _sortHandlers; + private readonly string _fallbackGuidValue; + + public ApiContentQueryService( + IExamineManager examineManager, + SelectorHandlerCollection selectorHandlers, + FilterHandlerCollection filterHandlers, + SortHandlerCollection sortHandlers) + { + _examineManager = examineManager; + _selectorHandlers = selectorHandlers; + _filterHandlers = filterHandlers; + _sortHandlers = sortHandlers; + + // 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"); + } + + /// + public PagedModel ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, int skip, int take) + { + var emptyResult = new PagedModel(); + + if (!_examineManager.TryGetIndex(Constants.UmbracoIndexes.DeliveryApiContentIndexName, out IIndex? apiIndex)) + { + return 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) + { + return emptyResult; + } + + // Handle Filtering + HandleFiltering(filters, queryOperation); + + // Handle Sorting + IOrdering? sortQuery = HandleSorting(sorts, queryOperation); + + ISearchResults? results = (sortQuery ?? DefaultSort(queryOperation))?.Execute(QueryOptions.SkipTake(skip, take)); + + if (results is null) + { + return emptyResult; + } + else + { + Guid[] items = results.Select(x => Guid.Parse(x.Id)).ToArray(); + return new PagedModel(results.TotalItemCount, items); + } + } + + private IBooleanOperation? HandleSelector(string? fetch, IQuery baseQuery) + { + IBooleanOperation? queryOperation; + + if (fetch is not null) + { + ISelectorHandler? selectorHandler = _selectorHandlers.FirstOrDefault(h => h.CanHandle(fetch)); + SelectorOption? selector = selectorHandler?.BuildSelectorOption(fetch); + + if (selector is null) + { + return null; + } + + var value = string.IsNullOrWhiteSpace(selector.Value) == false + ? selector.Value + : _fallbackGuidValue; + queryOperation = baseQuery.Field(selector.FieldName, value); + } + else + { + // TODO: If no params or no fetch value, get everything from the index - make a default selector and register it by the end of the collection + // TODO: This selects everything without regard to the current start-item header - make sure we honour that if it is present + // This is a temp Examine solution + queryOperation = baseQuery.Field("__IndexType", "content"); + } + + return queryOperation; + } + + private void HandleFiltering(IEnumerable filters, IBooleanOperation queryOperation) + { + foreach (var filterValue in filters) + { + IFilterHandler? filterHandler = _filterHandlers.FirstOrDefault(h => h.CanHandle(filterValue)); + FilterOption? filter = filterHandler?.BuildFilterOption(filterValue); + + if (filter is not null) + { + var value = string.IsNullOrWhiteSpace(filter.Value) == false + ? filter.Value + : _fallbackGuidValue; + + switch (filter.Operator) + { + case FilterOperation.Is: + queryOperation.And().Field(filter.FieldName, + (IExamineValue)new ExamineValue(Examineness.Explicit, + value)); // TODO: doesn't work for explicit word(s) match + break; + case FilterOperation.IsNot: + queryOperation.Not().Field(filter.FieldName, + (IExamineValue)new ExamineValue(Examineness.Explicit, + value)); // TODO: doesn't work for explicit word(s) match + break; + // TODO: Fix + case FilterOperation.Contains: + break; + // TODO: Fix + case FilterOperation.DoesNotContain: + break; + default: + continue; + } + } + } + } + + private IOrdering? HandleSorting(IEnumerable sorts, IBooleanOperation queryCriteria) + { + 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) + { + continue; + } + + SortType sortType = sort.FieldType switch + { + FieldType.Number => // TODO: do we need more explicit types like float, long, double + SortType.Int, + FieldType.Date => + // The field definition type should be FieldDefinitionTypes.DateTime + SortType.Long, + _ => SortType.String + }; + + orderingQuery = sort.Direction switch + { + Direction.Ascending => queryCriteria.OrderBy(new SortableField(sort.FieldName, sortType)), + Direction.Descending => queryCriteria.OrderByDescending(new SortableField(sort.FieldName, sortType)), + _ => orderingQuery + }; + } + + return orderingQuery; + } + + private IOrdering? DefaultSort(IBooleanOperation queryCriteria) + { + var defaultSorts = new[] { "path:asc", "sortOrder:asc" }; + + return HandleSorting(defaultSorts, queryCriteria); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestCultureService.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestCultureService.cs new file mode 100644 index 0000000000..9fe8b79251 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestCultureService.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Api.Delivery.Services; + +internal sealed class RequestCultureService : RequestHeaderHandler, IRequestCultureService +{ + private readonly IVariationContextAccessor _variationContextAccessor; + + public RequestCultureService(IHttpContextAccessor httpContextAccessor, IVariationContextAccessor variationContextAccessor) + : base(httpContextAccessor) => + _variationContextAccessor = variationContextAccessor; + + /// + public string? GetRequestedCulture() => GetHeaderValue(HeaderNames.AcceptLanguage); + + /// + public void SetRequestCulture(string culture) + { + if (_variationContextAccessor.VariationContext?.Culture == culture) + { + return; + } + + _variationContextAccessor.VariationContext = new VariationContext(culture); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestHeaderHandler.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestHeaderHandler.cs new file mode 100644 index 0000000000..08d7a916e6 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestHeaderHandler.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Http; + +namespace Umbraco.Cms.Api.Delivery.Services; + +internal abstract class RequestHeaderHandler +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + protected RequestHeaderHandler(IHttpContextAccessor httpContextAccessor) => _httpContextAccessor = httpContextAccessor; + + protected string? GetHeaderValue(string headerName) + { + HttpContext httpContext = _httpContextAccessor.HttpContext ?? + throw new InvalidOperationException("Could not obtain an HTTP context"); + + return httpContext.Request.Headers[headerName]; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestPreviewService.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestPreviewService.cs new file mode 100644 index 0000000000..874e2af7bb --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestPreviewService.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Http; +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Services; + +internal sealed class RequestPreviewService : RequestHeaderHandler, IRequestPreviewService +{ + public RequestPreviewService(IHttpContextAccessor httpContextAccessor) + : base(httpContextAccessor) + { + } + + /// + public bool IsPreview() => GetHeaderValue("Preview") == "true"; +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs new file mode 100644 index 0000000000..882525c8d0 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Services; + +internal sealed class RequestRedirectService : RoutingServiceBase, IRequestRedirectService +{ + private readonly IRequestCultureService _requestCultureService; + private readonly IRedirectUrlService _redirectUrlService; + private readonly IApiPublishedContentCache _apiPublishedContentCache; + private readonly IApiContentRouteBuilder _apiContentRouteBuilder; + private readonly GlobalSettings _globalSettings; + + public RequestRedirectService( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IHttpContextAccessor httpContextAccessor, + IRequestStartItemProviderAccessor requestStartItemProviderAccessor, + IRequestCultureService requestCultureService, + IRedirectUrlService redirectUrlService, + IApiPublishedContentCache apiPublishedContentCache, + IApiContentRouteBuilder apiContentRouteBuilder, + IOptions globalSettings) + : base(publishedSnapshotAccessor, httpContextAccessor, requestStartItemProviderAccessor) + { + _requestCultureService = requestCultureService; + _redirectUrlService = redirectUrlService; + _apiPublishedContentCache = apiPublishedContentCache; + _apiContentRouteBuilder = apiContentRouteBuilder; + _globalSettings = globalSettings.Value; + } + + public IApiContentRoute? GetRedirectRoute(string requestedPath) + { + requestedPath = requestedPath.EnsureStartsWith("/"); + + // must append the root content url segment if it is not hidden by config, because + // the URL tracking is based on the actual URL, including the root content url segment + if (_globalSettings.HideTopLevelNodeFromPath == false) + { + IPublishedContent? startItem = GetStartItem(); + if (startItem?.UrlSegment != null) + { + requestedPath = $"{startItem.UrlSegment.EnsureStartsWith("/")}{requestedPath}"; + } + } + + var culture = _requestCultureService.GetRequestedCulture(); + + // append the configured domain content ID to the path if we have a domain bound request, + // because URL tracking registers the tracked url like "{domain content ID}/{content path}" + Uri contentRoute = GetDefaultRequestUri(requestedPath); + DomainAndUri? domainAndUri = GetDomainAndUriForRoute(contentRoute); + if (domainAndUri != null) + { + requestedPath = GetContentRoute(domainAndUri, contentRoute); + culture ??= domainAndUri.Culture; + } + + // important: redirect URLs are always tracked without trailing slashes + IRedirectUrl? redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath.TrimEnd("/"), culture); + IPublishedContent? content = redirectUrl != null + ? _apiPublishedContentCache.GetById(redirectUrl.ContentKey) + : null; + + return content != null + ? _apiContentRouteBuilder.Build(content) + : null; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestRoutingService.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestRoutingService.cs new file mode 100644 index 0000000000..993780680d --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestRoutingService.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Http; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Services; + +internal sealed class RequestRoutingService : RoutingServiceBase, IRequestRoutingService +{ + private readonly IRequestCultureService _requestCultureService; + + public RequestRoutingService( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IHttpContextAccessor httpContextAccessor, + IRequestStartItemProviderAccessor requestStartItemProviderAccessor, + IRequestCultureService requestCultureService) + : base(publishedSnapshotAccessor, httpContextAccessor, requestStartItemProviderAccessor) => + _requestCultureService = requestCultureService; + + /// + public string GetContentRoute(string requestedPath) + { + requestedPath = requestedPath.EnsureStartsWith("/"); + + // do we have an explicit start item? + IPublishedContent? startItem = GetStartItem(); + if (startItem != null) + { + // the content cache can resolve content by the route "{root ID}/{content path}", which is what we construct here + return $"{startItem.Id}{requestedPath}"; + } + + // construct the (assumed) absolute URL for the requested content, and use that + // to look for a domain configuration that would match the URL + Uri contentRoute = GetDefaultRequestUri(requestedPath); + DomainAndUri? domainAndUri = GetDomainAndUriForRoute(contentRoute); + if (domainAndUri == null) + { + // no start item was found and no domain could be resolved, we will return the requested path + // as route and hope the content cache can resolve that (it likely can) + return requestedPath; + } + + // the Accept-Language header takes precedence over configured domain culture + if (domainAndUri.Culture != null && _requestCultureService.GetRequestedCulture().IsNullOrWhiteSpace()) + { + _requestCultureService.SetRequestCulture(domainAndUri.Culture); + } + + // when resolving content from a configured domain, the content cache expects the content route + // to be "{domain content ID}/{content path}", which is what we construct here + return GetContentRoute(domainAndUri, contentRoute); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestStartItemProvider.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestStartItemProvider.cs new file mode 100644 index 0000000000..8b1c467a63 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestStartItemProvider.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Http; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Services; + +internal sealed class RequestStartItemProvider : RequestHeaderHandler, IRequestStartItemProvider +{ + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + + // this provider lifetime is Scope, so we can cache this as a field + private IPublishedContent? _requestedStartContent; + + public RequestStartItemProvider( + IHttpContextAccessor httpContextAccessor, + IPublishedSnapshotAccessor publishedSnapshotAccessor) + : base(httpContextAccessor) => + _publishedSnapshotAccessor = publishedSnapshotAccessor; + + /// + public IPublishedContent? GetStartItem() + { + if (_requestedStartContent != null) + { + return _requestedStartContent; + } + + var headerValue = GetHeaderValue("Start-Item"); + if (headerValue.IsNullOrWhiteSpace()) + { + return null; + } + + if (_publishedSnapshotAccessor.TryGetPublishedSnapshot(out IPublishedSnapshot? publishedSnapshot) == false || publishedSnapshot?.Content == null) + { + return null; + } + + IEnumerable rootContent = publishedSnapshot.Content.GetAtRoot(); + + _requestedStartContent = Guid.TryParse(headerValue, out Guid key) + ? rootContent.FirstOrDefault(c => c.Key == key) + : rootContent.FirstOrDefault(c => c.UrlSegment == headerValue); + + return _requestedStartContent; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RoutingServiceBase.cs b/src/Umbraco.Cms.Api.Delivery/Services/RoutingServiceBase.cs new file mode 100644 index 0000000000..32a8affd61 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/RoutingServiceBase.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Http; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Services; + +internal abstract class RoutingServiceBase +{ + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IRequestStartItemProviderAccessor _requestStartItemProviderAccessor; + + protected RoutingServiceBase( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IHttpContextAccessor httpContextAccessor, + IRequestStartItemProviderAccessor requestStartItemProviderAccessor) + { + _publishedSnapshotAccessor = publishedSnapshotAccessor; + _httpContextAccessor = httpContextAccessor; + _requestStartItemProviderAccessor = requestStartItemProviderAccessor; + } + + protected Uri GetDefaultRequestUri(string requestedPath) + { + HttpRequest? request = _httpContextAccessor.HttpContext?.Request; + if (request == null) + { + throw new InvalidOperationException("Could not obtain an HTTP request context"); + } + + // construct the (assumed) absolute URL for the requested content + return new Uri($"{request.Scheme}://{request.Host}{requestedPath}", UriKind.Absolute); + } + + protected static string GetContentRoute(DomainAndUri domainAndUri, Uri contentRoute) + => $"{domainAndUri.ContentId}{DomainUtilities.PathRelativeToDomain(domainAndUri.Uri, contentRoute.AbsolutePath)}"; + + protected DomainAndUri? GetDomainAndUriForRoute(Uri contentUrl) + { + IDomainCache? domainCache = _publishedSnapshotAccessor.GetRequiredPublishedSnapshot().Domains; + if (domainCache == null) + { + throw new InvalidOperationException("Could not obtain the domain cache in the current context"); + } + + IEnumerable domains = domainCache.GetAll(false); + + return DomainUtilities.SelectDomain(domains, contentUrl, defaultCulture: domainCache.DefaultCulture); + } + + protected IPublishedContent? GetStartItem() + { + if (_requestStartItemProviderAccessor.TryGetValue(out IRequestStartItemProvider? requestStartItemProvider) is false) + { + throw new InvalidOperationException($"Could not obtain an {nameof(IRequestStartItemProvider)} instance"); + } + + return requestStartItemProvider.GetStartItem(); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj b/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj new file mode 100644 index 0000000000..5efada8f00 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj @@ -0,0 +1,23 @@ + + + Umbraco CMS - Delivery API + Contains the presentation layer for the Umbraco CMS Delivery API. + true + false + Umbraco.Cms.Api.Delivery + Umbraco.Cms.Api.Delivery + Umbraco.Cms.Api.Delivery + + + + + + + + + + + <_Parameter1>Umbraco.Tests.UnitTests + + + diff --git a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs index eacdb5f9c9..d7a967853e 100644 --- a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs +++ b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs @@ -34,7 +34,7 @@ public class ManagementApiComposer : IComposer private const string ApiTitle = "Umbraco Backoffice API"; private const string ApiDefaultDocumentName = "v1"; - private ApiVersion DefaultApiVersion => new(1, 0); + private ApiVersion DefaultApiVersion => new ApiVersion(1, 0); public void Compose(IUmbracoBuilder builder) { @@ -53,14 +53,8 @@ public class ManagementApiComposer : IComposer .AddMappers() .AddBackOfficeAuthentication(); - services.AddApiVersioning(options => - { - options.DefaultApiVersion = DefaultApiVersion; - options.ReportApiVersions = true; - options.ApiVersionReader = new UrlSegmentApiVersionReader(); - options.AssumeDefaultVersionWhenUnspecified = true; - options.UseApiBehavior = false; - }); + services.ConfigureOptions(); + services.AddApiVersioning(); services.AddSwaggerGen(swaggerGenOptions => { @@ -163,14 +157,9 @@ public class ManagementApiComposer : IComposer swaggerGenOptions.CustomSchemaIds(SchemaIdGenerator.Generate); }); - services.AddVersionedApiExplorer(options => - { - options.DefaultApiVersion = DefaultApiVersion; - options.GroupNameFormat = "'v'VVV"; - options.SubstituteApiVersionInUrl = true; - options.AddApiVersionParametersWhenVersionNeutral = true; - options.AssumeDefaultVersionWhenUnspecified = true; - }); + + services.ConfigureOptions(); + services.AddVersionedApiExplorer(); services.AddControllers() .AddJsonOptions(options => { diff --git a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj index 5148896b87..191d22193e 100644 --- a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj +++ b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj @@ -10,8 +10,6 @@ - - diff --git a/src/Umbraco.Cms.Targets/Umbraco.Cms.Targets.csproj b/src/Umbraco.Cms.Targets/Umbraco.Cms.Targets.csproj index 18f9beea96..e7dcdd5bec 100644 --- a/src/Umbraco.Cms.Targets/Umbraco.Cms.Targets.csproj +++ b/src/Umbraco.Cms.Targets/Umbraco.Cms.Targets.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Umbraco.Core/CompatibilitySuppressions.xml b/src/Umbraco.Core/CompatibilitySuppressions.xml new file mode 100644 index 0000000000..edd49bcf95 --- /dev/null +++ b/src/Umbraco.Core/CompatibilitySuppressions.xml @@ -0,0 +1,32 @@ + + + + + CP0005 + M:Umbraco.Cms.Core.Models.PublishedContent.PublishedPropertyBase.GetDeliveryApiValue(System.Boolean,System.String,System.String) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + M:Umbraco.Cms.Core.Models.PublishedContent.IPublishedProperty.GetDeliveryApiValue(System.Boolean,System.String,System.String) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + M:Umbraco.Cms.Core.Models.PublishedContent.IPublishedPropertyType.ConvertInterToDeliveryApiObject(Umbraco.Cms.Core.Models.PublishedContent.IPublishedElement,Umbraco.Cms.Core.PropertyEditors.PropertyCacheLevel,System.Object,System.Boolean) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + P:Umbraco.Cms.Core.Models.PublishedContent.IPublishedPropertyType.DeliveryApiCacheLevel + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + diff --git a/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs b/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs new file mode 100644 index 0000000000..eeb4bb2bcb --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs @@ -0,0 +1,51 @@ +using System.ComponentModel; + +namespace Umbraco.Cms.Core.Configuration.Models; + +/// +/// Typed configuration options for Delivery API settings. +/// +[UmbracoOptions(Constants.Configuration.ConfigDeliveryApi)] +public class DeliveryApiSettings +{ + private const bool StaticEnabled = false; + + private const bool StaticPublicAccess = true; + + private const bool StaticRichTextOutputAsJson = false; + + /// + /// Gets or sets a value indicating whether the Delivery API should be enabled. + /// + /// true if the Delivery API should be enabled; otherwise, false. + [DefaultValue(StaticEnabled)] + public bool Enabled { get; set; } = StaticEnabled; + + /// + /// Gets or sets a value indicating whether the Delivery API (if enabled) should be + /// publicly available or should require an API key for access. + /// + /// true if the Delivery API should be publicly available; false if an API key should be required for access. + [DefaultValue(StaticPublicAccess)] + public bool PublicAccess { get; set; } = StaticPublicAccess; + + /// + /// Gets or sets the API key used for authorizing API access (if the API is not publicly available) and preview access. + /// + /// A string representing the API key. + public string? ApiKey { get; set; } = null; + + /// + /// Gets or sets the aliases of the content types that may never be exposed through the Delivery API. Content of these + /// types will never be returned from any Delivery API endpoint, nor added to the query index. + /// + /// The content type aliases that are not to be exposed. + public string[] DisallowedContentTypeAliases { get; set; } = Array.Empty(); + + /// + /// Gets or sets a value indicating whether the Delivery API should output rich text values as JSON instead of HTML. + /// + /// true if the Delivery API should output rich text values as JSON; false they should be output as HTML (default). + [DefaultValue(StaticRichTextOutputAsJson)] + public bool RichTextOutputAsJson { get; set; } = StaticRichTextOutputAsJson; +} diff --git a/src/Umbraco.Core/Constants-Configuration.cs b/src/Umbraco.Core/Constants-Configuration.cs index 656bcd1cdf..bd84ab828f 100644 --- a/src/Umbraco.Core/Constants-Configuration.cs +++ b/src/Umbraco.Core/Constants-Configuration.cs @@ -30,6 +30,7 @@ public static partial class Constants public const string ConfigMarketplace = ConfigPrefix + "Marketplace"; public const string ConfigLegacyPasswordMigration = ConfigPrefix + "LegacyPasswordMigration"; public const string ConfigContent = ConfigPrefix + "Content"; + public const string ConfigDeliveryApi = ConfigPrefix + "DeliveryApi"; public const string ConfigCoreDebug = ConfigCorePrefix + "Debug"; public const string ConfigExceptionFilter = ConfigPrefix + "ExceptionFilter"; public const string ConfigGlobal = ConfigPrefix + "Global"; diff --git a/src/Umbraco.Core/Constants-Indexes.cs b/src/Umbraco.Core/Constants-Indexes.cs index 9c5d9ca48e..a3c358676a 100644 --- a/src/Umbraco.Core/Constants-Indexes.cs +++ b/src/Umbraco.Core/Constants-Indexes.cs @@ -7,5 +7,6 @@ public static partial class Constants public const string InternalIndexName = "InternalIndex"; public const string ExternalIndexName = "ExternalIndex"; public const string MembersIndexName = "MembersIndex"; + public const string DeliveryApiContentIndexName = "DeliveryApiContentIndex"; } } diff --git a/src/Umbraco.Core/Constants-JsonOptionsNames.cs b/src/Umbraco.Core/Constants-JsonOptionsNames.cs new file mode 100644 index 0000000000..53b0313158 --- /dev/null +++ b/src/Umbraco.Core/Constants-JsonOptionsNames.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core; + +public static partial class Constants +{ + public static class JsonOptionsNames + { + public const string DeliveryApi = "DeliveryApi"; + } +} diff --git a/src/Umbraco.Core/DeliveryApi/Accessors/NoopOutputExpansionStrategyAccessor.cs b/src/Umbraco.Core/DeliveryApi/Accessors/NoopOutputExpansionStrategyAccessor.cs new file mode 100644 index 0000000000..4ae4a19260 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/Accessors/NoopOutputExpansionStrategyAccessor.cs @@ -0,0 +1,12 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Umbraco.Cms.Core.DeliveryApi.Accessors; + +public class NoopOutputExpansionStrategyAccessor : IOutputExpansionStrategyAccessor +{ + public bool TryGetValue([NotNullWhen(true)] out IOutputExpansionStrategy? outputExpansionStrategy) + { + outputExpansionStrategy = new NoopOutputExpansionStrategy(); + return true; + } +} diff --git a/src/Umbraco.Core/DeliveryApi/Accessors/NoopRequestStartItemProviderAccessor.cs b/src/Umbraco.Core/DeliveryApi/Accessors/NoopRequestStartItemProviderAccessor.cs new file mode 100644 index 0000000000..37754db472 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/Accessors/NoopRequestStartItemProviderAccessor.cs @@ -0,0 +1,12 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Umbraco.Cms.Core.DeliveryApi.Accessors; + +public sealed class NoopRequestStartItemProviderAccessor : IRequestStartItemProviderAccessor +{ + public bool TryGetValue([NotNullWhen(true)] out IRequestStartItemProvider? requestStartItemProvider) + { + requestStartItemProvider = new NoopRequestStartItemProvider(); + return true; + } +} diff --git a/src/Umbraco.Core/DeliveryApi/ApiContentBuilder.cs b/src/Umbraco.Core/DeliveryApi/ApiContentBuilder.cs new file mode 100644 index 0000000000..2082da3a2a --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/ApiContentBuilder.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public class ApiContentBuilder : ApiContentBuilderBase, IApiContentBuilder +{ + public ApiContentBuilder(IApiContentNameProvider apiContentNameProvider, IApiContentRouteBuilder apiContentRouteBuilder, IOutputExpansionStrategyAccessor outputExpansionStrategyAccessor) + : base(apiContentNameProvider, apiContentRouteBuilder, outputExpansionStrategyAccessor) + { + } + + protected override IApiContent Create(IPublishedContent content, Guid id, string name, string contentType, IApiContentRoute route, IDictionary properties) + => new ApiContent(id, name, contentType, route, properties); +} diff --git a/src/Umbraco.Core/DeliveryApi/ApiContentBuilderBase.cs b/src/Umbraco.Core/DeliveryApi/ApiContentBuilderBase.cs new file mode 100644 index 0000000000..8c70c3ff5b --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/ApiContentBuilderBase.cs @@ -0,0 +1,37 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public abstract class ApiContentBuilderBase + where T : IApiContent +{ + private readonly IApiContentNameProvider _apiContentNameProvider; + private readonly IApiContentRouteBuilder _apiContentRouteBuilder; + private readonly IOutputExpansionStrategyAccessor _outputExpansionStrategyAccessor; + + protected ApiContentBuilderBase(IApiContentNameProvider apiContentNameProvider, IApiContentRouteBuilder apiContentRouteBuilder, IOutputExpansionStrategyAccessor outputExpansionStrategyAccessor) + { + _apiContentNameProvider = apiContentNameProvider; + _apiContentRouteBuilder = apiContentRouteBuilder; + _outputExpansionStrategyAccessor = outputExpansionStrategyAccessor; + } + + protected abstract T Create(IPublishedContent content, Guid id, string name, string contentType, IApiContentRoute route, IDictionary properties); + + public virtual T Build(IPublishedContent content) + { + IDictionary properties = + _outputExpansionStrategyAccessor.TryGetValue(out IOutputExpansionStrategy? outputExpansionStrategy) + ? outputExpansionStrategy.MapContentProperties(content) + : new Dictionary(); + + return Create( + content, + content.Key, + _apiContentNameProvider.GetName(content), + content.ContentType.Alias, + _apiContentRouteBuilder.Build(content), + properties); + } +} diff --git a/src/Umbraco.Core/DeliveryApi/ApiContentNameProvider.cs b/src/Umbraco.Core/DeliveryApi/ApiContentNameProvider.cs new file mode 100644 index 0000000000..f1fa370027 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/ApiContentNameProvider.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public class ApiContentNameProvider : IApiContentNameProvider +{ + public string GetName(IPublishedContent content) => content.Name; +} diff --git a/src/Umbraco.Core/DeliveryApi/ApiContentResponseBuilder.cs b/src/Umbraco.Core/DeliveryApi/ApiContentResponseBuilder.cs new file mode 100644 index 0000000000..0e475aef71 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/ApiContentResponseBuilder.cs @@ -0,0 +1,25 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public class ApiContentResponseBuilder : ApiContentBuilderBase, IApiContentResponseBuilder +{ + private readonly IApiContentRouteBuilder _apiContentRouteBuilder; + + public ApiContentResponseBuilder(IApiContentNameProvider apiContentNameProvider, IApiContentRouteBuilder apiContentRouteBuilder, IOutputExpansionStrategyAccessor outputExpansionStrategyAccessor) + : base(apiContentNameProvider, apiContentRouteBuilder, outputExpansionStrategyAccessor) + => _apiContentRouteBuilder = apiContentRouteBuilder; + + protected override IApiContentResponse Create(IPublishedContent content, Guid id, string name, string contentType, IApiContentRoute route, IDictionary properties) + { + var cultures = content.Cultures.Values + .Where(publishedCultureInfo => publishedCultureInfo.Culture.IsNullOrWhiteSpace() == false) // filter out invariant cultures + .ToDictionary( + publishedCultureInfo => publishedCultureInfo.Culture, + publishedCultureInfo => _apiContentRouteBuilder.Build(content, publishedCultureInfo.Culture)); + + return new ApiContentResponse(id, name, contentType, route, properties, cultures); + } +} diff --git a/src/Umbraco.Core/DeliveryApi/ApiContentRouteBuilder.cs b/src/Umbraco.Core/DeliveryApi/ApiContentRouteBuilder.cs new file mode 100644 index 0000000000..7fe6aec063 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/ApiContentRouteBuilder.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Routing; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public class ApiContentRouteBuilder : IApiContentRouteBuilder +{ + private readonly IPublishedUrlProvider _publishedUrlProvider; + private readonly GlobalSettings _globalSettings; + private readonly IVariationContextAccessor _variationContextAccessor; + + public ApiContentRouteBuilder(IPublishedUrlProvider publishedUrlProvider, IOptions globalSettings, IVariationContextAccessor variationContextAccessor) + { + _publishedUrlProvider = publishedUrlProvider; + _variationContextAccessor = variationContextAccessor; + _globalSettings = globalSettings.Value; + } + + public IApiContentRoute Build(IPublishedContent content, string? culture = null) + { + if (content.ItemType != PublishedItemType.Content) + { + throw new ArgumentException("Content locations can only be built from Content items.", nameof(content)); + } + + IPublishedContent root = content.Root(); + var rootPath = root.UrlSegment(_variationContextAccessor, culture) ?? string.Empty; + + var contentPath = _publishedUrlProvider.GetUrl(content, UrlMode.Relative, culture).EnsureStartsWith("/"); + + if (_globalSettings.HideTopLevelNodeFromPath == false) + { + contentPath = contentPath.TrimStart(rootPath.EnsureStartsWith("/")).EnsureStartsWith("/"); + } + + return new ApiContentRoute(contentPath, new ApiContentStartItem(root.Key, rootPath)); + } +} diff --git a/src/Umbraco.Core/DeliveryApi/ApiElementBuilder.cs b/src/Umbraco.Core/DeliveryApi/ApiElementBuilder.cs new file mode 100644 index 0000000000..b2f81b2c9a --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/ApiElementBuilder.cs @@ -0,0 +1,27 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public class ApiElementBuilder : IApiElementBuilder +{ + private readonly IOutputExpansionStrategyAccessor _outputExpansionStrategyAccessor; + + public ApiElementBuilder(IOutputExpansionStrategyAccessor outputExpansionStrategyAccessor) + { + _outputExpansionStrategyAccessor = outputExpansionStrategyAccessor; + } + + public IApiElement Build(IPublishedElement element) + { + IDictionary properties = + _outputExpansionStrategyAccessor.TryGetValue(out IOutputExpansionStrategy? outputExpansionStrategy) + ? outputExpansionStrategy.MapElementProperties(element) + : new Dictionary(); + + return new ApiElement( + element.Key, + element.ContentType.Alias, + properties); + } +} diff --git a/src/Umbraco.Core/DeliveryApi/ApiMediaBuilder.cs b/src/Umbraco.Core/DeliveryApi/ApiMediaBuilder.cs new file mode 100644 index 0000000000..53a0968ea0 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/ApiMediaBuilder.cs @@ -0,0 +1,35 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public class ApiMediaBuilder : IApiMediaBuilder +{ + private readonly IApiContentNameProvider _apiContentNameProvider; + private readonly IApiMediaUrlProvider _apiMediaUrlProvider; + private readonly IOutputExpansionStrategyAccessor _outputExpansionStrategyAccessor; + + public ApiMediaBuilder( + IApiContentNameProvider apiContentNameProvider, + IApiMediaUrlProvider apiMediaUrlProvider, + IOutputExpansionStrategyAccessor outputExpansionStrategyAccessor) + { + _apiContentNameProvider = apiContentNameProvider; + _apiMediaUrlProvider = apiMediaUrlProvider; + _outputExpansionStrategyAccessor = outputExpansionStrategyAccessor; + } + + public IApiMedia Build(IPublishedContent media) => + new ApiMedia( + media.Key, + _apiContentNameProvider.GetName(media), + media.ContentType.Alias, + _apiMediaUrlProvider.GetUrl(media), + Properties(media)); + + // map all media properties except the umbracoFile one, as we've already included the file URL etc. in the output + private IDictionary Properties(IPublishedContent media) => + _outputExpansionStrategyAccessor.TryGetValue(out IOutputExpansionStrategy? outputExpansionStrategy) + ? outputExpansionStrategy.MapProperties(media.Properties.Where(p => p.Alias != Constants.Conventions.Media.File)) + : new Dictionary(); +} diff --git a/src/Umbraco.Core/DeliveryApi/ApiMediaUrlProvider.cs b/src/Umbraco.Core/DeliveryApi/ApiMediaUrlProvider.cs new file mode 100644 index 0000000000..4bf3ec9a28 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/ApiMediaUrlProvider.cs @@ -0,0 +1,22 @@ +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Routing; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public class ApiMediaUrlProvider : IApiMediaUrlProvider +{ + private readonly IPublishedUrlProvider _publishedUrlProvider; + + public ApiMediaUrlProvider(IPublishedUrlProvider publishedUrlProvider) + => _publishedUrlProvider = publishedUrlProvider; + + public string GetUrl(IPublishedContent media) + { + if (media.ItemType != PublishedItemType.Media) + { + throw new ArgumentException("Media URLs can only be generated from Media items.", nameof(media)); + } + + return _publishedUrlProvider.GetMediaUrl(media, UrlMode.Relative); + } +} diff --git a/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs b/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs new file mode 100644 index 0000000000..db45ed74eb --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/ApiPublishedContentCache.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public class ApiPublishedContentCache : IApiPublishedContentCache +{ + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly IRequestPreviewService _requestPreviewService; + private DeliveryApiSettings _deliveryApiSettings; + + public ApiPublishedContentCache(IPublishedSnapshotAccessor publishedSnapshotAccessor, IRequestPreviewService requestPreviewService, IOptionsMonitor deliveryApiSettings) + { + _publishedSnapshotAccessor = publishedSnapshotAccessor; + _requestPreviewService = requestPreviewService; + _deliveryApiSettings = deliveryApiSettings.CurrentValue; + deliveryApiSettings.OnChange(settings => _deliveryApiSettings = settings); + } + + public IPublishedContent? GetByRoute(string route) + { + IPublishedContentCache? contentCache = GetContentCache(); + if (contentCache == null) + { + return null; + } + + IPublishedContent? content = contentCache.GetByRoute(_requestPreviewService.IsPreview(), route); + return ContentOrNullIfDisallowed(content); + } + + public IPublishedContent? GetById(Guid contentId) + { + IPublishedContentCache? contentCache = GetContentCache(); + if (contentCache == null) + { + return null; + } + + IPublishedContent? content = contentCache.GetById(_requestPreviewService.IsPreview(), contentId); + return ContentOrNullIfDisallowed(content); + } + + public IEnumerable GetByIds(IEnumerable contentIds) + { + IPublishedContentCache? contentCache = GetContentCache(); + if (contentCache == null) + { + return Enumerable.Empty(); + } + + return contentIds + .Select(contentId => contentCache.GetById(_requestPreviewService.IsPreview(), contentId)) + .WhereNotNull() + .Where(IsAllowedContentType) + .ToArray(); + } + + private IPublishedContentCache? GetContentCache() => + _publishedSnapshotAccessor.TryGetPublishedSnapshot(out IPublishedSnapshot? publishedSnapshot) + ? publishedSnapshot?.Content + : null; + + private IPublishedContent? ContentOrNullIfDisallowed(IPublishedContent? content) + => content != null && IsAllowedContentType(content) + ? content + : null; + + private bool IsAllowedContentType(IPublishedContent content) + => _deliveryApiSettings.DisallowedContentTypeAliases.InvariantContains(content.ContentType.Alias) is false; +} diff --git a/src/Umbraco.Core/DeliveryApi/FilterHandlerCollection.cs b/src/Umbraco.Core/DeliveryApi/FilterHandlerCollection.cs new file mode 100644 index 0000000000..73b4dda3a6 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/FilterHandlerCollection.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public class FilterHandlerCollection : BuilderCollectionBase +{ + public FilterHandlerCollection(Func> items) + : base(items) + { + } +} diff --git a/src/Umbraco.Core/DeliveryApi/FilterHandlerCollectionBuilder.cs b/src/Umbraco.Core/DeliveryApi/FilterHandlerCollectionBuilder.cs new file mode 100644 index 0000000000..118b49dfee --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/FilterHandlerCollectionBuilder.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public class FilterHandlerCollectionBuilder + : LazyCollectionBuilderBase +{ + protected override FilterHandlerCollectionBuilder This => this; +} diff --git a/src/Umbraco.Core/DeliveryApi/FilterOption.cs b/src/Umbraco.Core/DeliveryApi/FilterOption.cs new file mode 100644 index 0000000000..78fa5ea1e1 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/FilterOption.cs @@ -0,0 +1,19 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public class FilterOption +{ + public required string FieldName { get; set; } + + public required string Value { get; set; } + + public FilterOperation Operator { get; set; } +} + +public enum FilterOperation +{ + Is, + IsNot, + // TODO: how to handle these in Examine? + Contains, + DoesNotContain +} diff --git a/src/Umbraco.Core/DeliveryApi/IApiAccessService.cs b/src/Umbraco.Core/DeliveryApi/IApiAccessService.cs new file mode 100644 index 0000000000..e734571b7b --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IApiAccessService.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IApiAccessService +{ + /// + /// Retrieves information on whether or not the API currently allows public access. + /// + bool HasPublicAccess(); + + /// + /// Retrieves information on whether or not the API currently allows preview access. + /// + bool HasPreviewAccess(); +} diff --git a/src/Umbraco.Core/DeliveryApi/IApiContentBuilder.cs b/src/Umbraco.Core/DeliveryApi/IApiContentBuilder.cs new file mode 100644 index 0000000000..784cb29370 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IApiContentBuilder.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IApiContentBuilder +{ + IApiContent Build(IPublishedContent content); +} diff --git a/src/Umbraco.Core/DeliveryApi/IApiContentNameProvider.cs b/src/Umbraco.Core/DeliveryApi/IApiContentNameProvider.cs new file mode 100644 index 0000000000..ace5a0095e --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IApiContentNameProvider.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IApiContentNameProvider +{ + string GetName(IPublishedContent content); +} diff --git a/src/Umbraco.Core/DeliveryApi/IApiContentQueryService.cs b/src/Umbraco.Core/DeliveryApi/IApiContentQueryService.cs new file mode 100644 index 0000000000..142cd4734f --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IApiContentQueryService.cs @@ -0,0 +1,20 @@ +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Core.DeliveryApi; + +/// +/// Service that handles querying of the Delivery API. +/// +public interface IApiContentQueryService +{ + /// + /// Returns a collection of item ids that passed the search criteria as a paged model. + /// + /// Optional fetch query parameter value. + /// Optional filter query parameters values. + /// Optional sort query parameters values. + /// The amount of items to skip. + /// The amount of items to take. + /// A paged model of item ids that are returned after applying the search queries. + PagedModel ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, int skip, int take); +} diff --git a/src/Umbraco.Core/DeliveryApi/IApiContentResponseBuilder.cs b/src/Umbraco.Core/DeliveryApi/IApiContentResponseBuilder.cs new file mode 100644 index 0000000000..82c25f3284 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IApiContentResponseBuilder.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IApiContentResponseBuilder +{ + IApiContentResponse Build(IPublishedContent content); +} diff --git a/src/Umbraco.Core/DeliveryApi/IApiContentRouteBuilder.cs b/src/Umbraco.Core/DeliveryApi/IApiContentRouteBuilder.cs new file mode 100644 index 0000000000..764e7765c2 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IApiContentRouteBuilder.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IApiContentRouteBuilder +{ + IApiContentRoute Build(IPublishedContent content, string? culture = null); +} diff --git a/src/Umbraco.Core/DeliveryApi/IApiElementBuilder.cs b/src/Umbraco.Core/DeliveryApi/IApiElementBuilder.cs new file mode 100644 index 0000000000..09fe845f88 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IApiElementBuilder.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IApiElementBuilder +{ + IApiElement Build(IPublishedElement element); +} diff --git a/src/Umbraco.Core/DeliveryApi/IApiMediaBuilder.cs b/src/Umbraco.Core/DeliveryApi/IApiMediaBuilder.cs new file mode 100644 index 0000000000..68bcb83a51 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IApiMediaBuilder.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IApiMediaBuilder +{ + IApiMedia Build(IPublishedContent media); +} diff --git a/src/Umbraco.Core/DeliveryApi/IApiMediaUrlProvider.cs b/src/Umbraco.Core/DeliveryApi/IApiMediaUrlProvider.cs new file mode 100644 index 0000000000..71c44653f0 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IApiMediaUrlProvider.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IApiMediaUrlProvider +{ + string GetUrl(IPublishedContent media); +} diff --git a/src/Umbraco.Core/DeliveryApi/IApiPublishedContentCache.cs b/src/Umbraco.Core/DeliveryApi/IApiPublishedContentCache.cs new file mode 100644 index 0000000000..e24e43474c --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IApiPublishedContentCache.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IApiPublishedContentCache +{ + IPublishedContent? GetByRoute(string route); + + IPublishedContent? GetById(Guid contentId); + + IEnumerable GetByIds(IEnumerable contentIds); +} diff --git a/src/Umbraco.Core/DeliveryApi/IFilterHandler.cs b/src/Umbraco.Core/DeliveryApi/IFilterHandler.cs new file mode 100644 index 0000000000..db3a3fbe34 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IFilterHandler.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +/// +/// A handler that handles filter query parameters. +/// +public interface IFilterHandler : IQueryHandler +{ + /// + /// Builds a for the filter query. + /// + /// The filter query (i.e. "contentType:article"). + /// A that can be used when building specific filter queries. + FilterOption BuildFilterOption(string filter); +} diff --git a/src/Umbraco.Core/DeliveryApi/IOutputExpansionStrategy.cs b/src/Umbraco.Core/DeliveryApi/IOutputExpansionStrategy.cs new file mode 100644 index 0000000000..56ed9cec73 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IOutputExpansionStrategy.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IOutputExpansionStrategy +{ + IDictionary MapElementProperties(IPublishedElement element); + + IDictionary MapProperties(IEnumerable properties); + + IDictionary MapContentProperties(IPublishedContent content); +} diff --git a/src/Umbraco.Core/DeliveryApi/IOutputExpansionStrategyAccessor.cs b/src/Umbraco.Core/DeliveryApi/IOutputExpansionStrategyAccessor.cs new file mode 100644 index 0000000000..ea7188d0ce --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IOutputExpansionStrategyAccessor.cs @@ -0,0 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IOutputExpansionStrategyAccessor +{ + bool TryGetValue([NotNullWhen(true)] out IOutputExpansionStrategy? outputExpansionStrategy); +} diff --git a/src/Umbraco.Core/DeliveryApi/IQueryHandler.cs b/src/Umbraco.Core/DeliveryApi/IQueryHandler.cs new file mode 100644 index 0000000000..0c1a7c72b4 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IQueryHandler.cs @@ -0,0 +1,13 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IQueryHandler : IDiscoverable +{ + /// + /// Determines whether this query handler can handle the given query. + /// + /// The query string to check (i.e. "children:articles", "contentType:article", "name:asc", ...). + /// True if this query handler can handle the given query; otherwise, false. + bool CanHandle(string query); +} diff --git a/src/Umbraco.Core/DeliveryApi/IRequestCultureService.cs b/src/Umbraco.Core/DeliveryApi/IRequestCultureService.cs new file mode 100644 index 0000000000..2ca725ae8b --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IRequestCultureService.cs @@ -0,0 +1,15 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IRequestCultureService +{ + /// + /// Gets the requested culture from the "Accept-Language" header, if present. + /// + string? GetRequestedCulture(); + + /// + /// Updates the current request culture if applicable. + /// + /// The culture to use for the current request. + void SetRequestCulture(string culture); +} diff --git a/src/Umbraco.Core/DeliveryApi/IRequestPreviewService.cs b/src/Umbraco.Core/DeliveryApi/IRequestPreviewService.cs new file mode 100644 index 0000000000..c605c41730 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IRequestPreviewService.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IRequestPreviewService +{ + /// + /// Retrieves information on whether or not to output draft content for preview. + /// + bool IsPreview(); +} diff --git a/src/Umbraco.Core/DeliveryApi/IRequestRedirectService.cs b/src/Umbraco.Core/DeliveryApi/IRequestRedirectService.cs new file mode 100644 index 0000000000..c2a8312d8e --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IRequestRedirectService.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IRequestRedirectService +{ + /// + /// Retrieves the redirect URL (if any) for a requested content path + /// + IApiContentRoute? GetRedirectRoute(string requestedPath); +} diff --git a/src/Umbraco.Core/DeliveryApi/IRequestRoutingService.cs b/src/Umbraco.Core/DeliveryApi/IRequestRoutingService.cs new file mode 100644 index 0000000000..10d8203676 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IRequestRoutingService.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IRequestRoutingService +{ + /// + /// Retrieves the actual route for content in the content cache from a requested content path + /// + string GetContentRoute(string requestedPath); +} diff --git a/src/Umbraco.Core/DeliveryApi/IRequestStartItemProvider.cs b/src/Umbraco.Core/DeliveryApi/IRequestStartItemProvider.cs new file mode 100644 index 0000000000..36dfbd525a --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IRequestStartItemProvider.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IRequestStartItemProvider +{ + /// + /// Gets the requested start item from the "Start-Item" header, if present. + /// + IPublishedContent? GetStartItem(); +} diff --git a/src/Umbraco.Core/DeliveryApi/IRequestStartItemProviderAccessor.cs b/src/Umbraco.Core/DeliveryApi/IRequestStartItemProviderAccessor.cs new file mode 100644 index 0000000000..f913c97b77 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IRequestStartItemProviderAccessor.cs @@ -0,0 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IRequestStartItemProviderAccessor +{ + bool TryGetValue([NotNullWhen(true)] out IRequestStartItemProvider? requestStartItemProvider); +} diff --git a/src/Umbraco.Core/DeliveryApi/ISelectorHandler.cs b/src/Umbraco.Core/DeliveryApi/ISelectorHandler.cs new file mode 100644 index 0000000000..e0ba7a4221 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/ISelectorHandler.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +/// +/// A handler that handles fetch query parameter. +/// +public interface ISelectorHandler : IQueryHandler +{ + /// + /// Builds a for the selector query. + /// + /// The selector query (i.e. "children:articles"). + /// A that can be used when building specific search query for requesting a subset of the items. + SelectorOption BuildSelectorOption(string selector); +} diff --git a/src/Umbraco.Core/DeliveryApi/ISortHandler.cs b/src/Umbraco.Core/DeliveryApi/ISortHandler.cs new file mode 100644 index 0000000000..d2d59bcd53 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/ISortHandler.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +/// +/// A handler that handles sort query parameters. +/// +public interface ISortHandler : IQueryHandler +{ + /// + /// Builds a for the sort query. + /// + /// The sort query (i.e. "name:asc"). + /// A that can be used when building specific sorting queries. + SortOption BuildSortOption(string sort); +} diff --git a/src/Umbraco.Core/DeliveryApi/NoopApiAccessService.cs b/src/Umbraco.Core/DeliveryApi/NoopApiAccessService.cs new file mode 100644 index 0000000000..3559b10464 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopApiAccessService.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public sealed class NoopApiAccessService : IApiAccessService +{ + /// + public bool HasPublicAccess() => false; + + /// + public bool HasPreviewAccess() => false; +} diff --git a/src/Umbraco.Core/DeliveryApi/NoopApiContentQueryService.cs b/src/Umbraco.Core/DeliveryApi/NoopApiContentQueryService.cs new file mode 100644 index 0000000000..f95aa8a06e --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopApiContentQueryService.cs @@ -0,0 +1,10 @@ +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public sealed class NoopApiContentQueryService : IApiContentQueryService +{ + /// + public PagedModel ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, int skip, int take) + => new(); +} diff --git a/src/Umbraco.Core/DeliveryApi/NoopOutputExpansionStrategy.cs b/src/Umbraco.Core/DeliveryApi/NoopOutputExpansionStrategy.cs new file mode 100644 index 0000000000..8ff223324a --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopOutputExpansionStrategy.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +internal sealed class NoopOutputExpansionStrategy : IOutputExpansionStrategy +{ + public IDictionary MapElementProperties(IPublishedElement element) + => MapProperties(element.Properties); + + public IDictionary MapProperties(IEnumerable properties) + => properties.ToDictionary(p => p.Alias, p => p.GetDeliveryApiValue(true)); + + public IDictionary MapContentProperties(IPublishedContent content) + => MapProperties(content.Properties); +} diff --git a/src/Umbraco.Core/DeliveryApi/NoopRequestCultureService.cs b/src/Umbraco.Core/DeliveryApi/NoopRequestCultureService.cs new file mode 100644 index 0000000000..8b9cec3aa9 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopRequestCultureService.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public sealed class NoopRequestCultureService : IRequestCultureService +{ + /// + public string? GetRequestedCulture() => null; + + /// + public void SetRequestCulture(string culture) + { + } +} diff --git a/src/Umbraco.Core/DeliveryApi/NoopRequestPreviewService.cs b/src/Umbraco.Core/DeliveryApi/NoopRequestPreviewService.cs new file mode 100644 index 0000000000..c7adf31ee1 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopRequestPreviewService.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public sealed class NoopRequestPreviewService : IRequestPreviewService +{ + /// + public bool IsPreview() => false; +} + diff --git a/src/Umbraco.Core/DeliveryApi/NoopRequestRedirectService.cs b/src/Umbraco.Core/DeliveryApi/NoopRequestRedirectService.cs new file mode 100644 index 0000000000..51437bb683 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopRequestRedirectService.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public sealed class NoopRequestRedirectService : IRequestRedirectService +{ + /// + public IApiContentRoute? GetRedirectRoute(string requestedPath) => null; +} diff --git a/src/Umbraco.Core/DeliveryApi/NoopRequestRoutingService.cs b/src/Umbraco.Core/DeliveryApi/NoopRequestRoutingService.cs new file mode 100644 index 0000000000..50451a713c --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopRequestRoutingService.cs @@ -0,0 +1,7 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public sealed class NoopRequestRoutingService : IRequestRoutingService +{ + /// + public string GetContentRoute(string requestedPath) => requestedPath; +} diff --git a/src/Umbraco.Core/DeliveryApi/NoopRequestStartItemProvider.cs b/src/Umbraco.Core/DeliveryApi/NoopRequestStartItemProvider.cs new file mode 100644 index 0000000000..6f22c43904 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopRequestStartItemProvider.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.DeliveryApi; + +internal sealed class NoopRequestStartItemProvider : IRequestStartItemProvider +{ + /// + public IPublishedContent? GetStartItem() => null; +} diff --git a/src/Umbraco.Core/DeliveryApi/SelectorHandlerCollection.cs b/src/Umbraco.Core/DeliveryApi/SelectorHandlerCollection.cs new file mode 100644 index 0000000000..ee97e2e187 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/SelectorHandlerCollection.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public class SelectorHandlerCollection : BuilderCollectionBase +{ + public SelectorHandlerCollection(Func> items) + : base(items) + { + } +} diff --git a/src/Umbraco.Core/DeliveryApi/SelectorHandlerCollectionBuilder.cs b/src/Umbraco.Core/DeliveryApi/SelectorHandlerCollectionBuilder.cs new file mode 100644 index 0000000000..0af9550915 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/SelectorHandlerCollectionBuilder.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public class SelectorHandlerCollectionBuilder + : LazyCollectionBuilderBase +{ + protected override SelectorHandlerCollectionBuilder This => this; +} diff --git a/src/Umbraco.Core/DeliveryApi/SelectorOption.cs b/src/Umbraco.Core/DeliveryApi/SelectorOption.cs new file mode 100644 index 0000000000..547267983d --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/SelectorOption.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public class SelectorOption +{ + public required string FieldName { get; set; } + + public required string Value { get; set; } +} diff --git a/src/Umbraco.Core/DeliveryApi/SortHandlerCollection.cs b/src/Umbraco.Core/DeliveryApi/SortHandlerCollection.cs new file mode 100644 index 0000000000..8878c6336d --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/SortHandlerCollection.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public class SortHandlerCollection : BuilderCollectionBase +{ + public SortHandlerCollection(Func> items) + : base(items) + { + } +} diff --git a/src/Umbraco.Core/DeliveryApi/SortHandlerCollectionBuilder.cs b/src/Umbraco.Core/DeliveryApi/SortHandlerCollectionBuilder.cs new file mode 100644 index 0000000000..767d5e59a6 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/SortHandlerCollectionBuilder.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public class SortHandlerCollectionBuilder + : LazyCollectionBuilderBase +{ + protected override SortHandlerCollectionBuilder This => this; +} diff --git a/src/Umbraco.Core/DeliveryApi/SortOption.cs b/src/Umbraco.Core/DeliveryApi/SortOption.cs new file mode 100644 index 0000000000..94f02f7be1 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/SortOption.cs @@ -0,0 +1,17 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public class SortOption +{ + public required string FieldName { get; set; } + + public Direction Direction { get; set; } + + public FieldType FieldType { get; set; } +} + +public enum FieldType +{ + String, + Number, + Date +} diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs index 6f735a003c..d674eb839e 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs @@ -1,6 +1,7 @@ using Umbraco.Cms.Core.Actions; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.ContentApps; using Umbraco.Cms.Core.Dashboards; using Umbraco.Cms.Core.Editors; @@ -123,6 +124,9 @@ public static partial class UmbracoBuilderExtensions .Append(); builder.SearchableTrees().Add(() => builder.TypeLoader.GetTypes()); builder.BackOfficeAssets(); + builder.SelectorHandlers().Add(() => builder.TypeLoader.GetTypes()); + builder.FilterHandlers().Add(() => builder.TypeLoader.GetTypes()); + builder.SortHandlers().Add(() => builder.TypeLoader.GetTypes()); } /// @@ -298,4 +302,22 @@ public static partial class UmbracoBuilderExtensions /// public static CustomBackOfficeAssetsCollectionBuilder BackOfficeAssets(this IUmbracoBuilder builder) => builder.WithCollectionBuilder(); + + /// + /// Gets the Delivery API selector handler collection builder + /// + public static SelectorHandlerCollectionBuilder SelectorHandlers(this IUmbracoBuilder builder) + => builder.WithCollectionBuilder(); + + /// + /// Gets the Delivery API filter handler collection builder + /// + public static FilterHandlerCollectionBuilder FilterHandlers(this IUmbracoBuilder builder) + => builder.WithCollectionBuilder(); + + /// + /// Gets the Delivery API sort handler collection builder + /// + public static SortHandlerCollectionBuilder SortHandlers(this IUmbracoBuilder builder) + => builder.WithCollectionBuilder(); } diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index 92e10c7b1c..c4ff4bdab6 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -52,6 +52,7 @@ public static partial class UmbracoBuilderExtensions .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions() + .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions(optionsBuilder => optionsBuilder.PostConfigure(options => diff --git a/src/Umbraco.Core/Models/DeliveryApi/ApiBlockGridArea.cs b/src/Umbraco.Core/Models/DeliveryApi/ApiBlockGridArea.cs new file mode 100644 index 0000000000..96855849ef --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/ApiBlockGridArea.cs @@ -0,0 +1,20 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public class ApiBlockGridArea +{ + public ApiBlockGridArea(string alias, int rowSpan, int columnSpan, IEnumerable items) + { + Alias = alias; + RowSpan = rowSpan; + ColumnSpan = columnSpan; + Items = items; + } + + public string Alias { get; } + + public int RowSpan { get; } + + public int ColumnSpan { get; } + + public IEnumerable Items { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/ApiBlockGridItem.cs b/src/Umbraco.Core/Models/DeliveryApi/ApiBlockGridItem.cs new file mode 100644 index 0000000000..4e84c06f01 --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/ApiBlockGridItem.cs @@ -0,0 +1,21 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public class ApiBlockGridItem : ApiBlockItem +{ + public ApiBlockGridItem(IApiElement content, IApiElement? settings, int rowSpan, int columnSpan, int areaGridColumns, IEnumerable areas) + : base(content, settings) + { + RowSpan = rowSpan; + ColumnSpan = columnSpan; + AreaGridColumns = areaGridColumns; + Areas = areas; + } + + public int RowSpan { get; } + + public int ColumnSpan { get; } + + public int AreaGridColumns { get; } + + public IEnumerable Areas { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/ApiBlockGridModel.cs b/src/Umbraco.Core/Models/DeliveryApi/ApiBlockGridModel.cs new file mode 100644 index 0000000000..2b1887b18d --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/ApiBlockGridModel.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public class ApiBlockGridModel +{ + public ApiBlockGridModel(int gridColumns, IEnumerable items) + { + GridColumns = gridColumns; + Items = items; + } + + public int GridColumns { get; } + + public IEnumerable Items { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/ApiBlockItem.cs b/src/Umbraco.Core/Models/DeliveryApi/ApiBlockItem.cs new file mode 100644 index 0000000000..f6809ce2cf --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/ApiBlockItem.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public class ApiBlockItem +{ + public ApiBlockItem(IApiElement content, IApiElement? settings) + { + Content = content; + Settings = settings; + } + + public IApiElement Content { get; } + + public IApiElement? Settings { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/ApiBlockListModel.cs b/src/Umbraco.Core/Models/DeliveryApi/ApiBlockListModel.cs new file mode 100644 index 0000000000..caa0002899 --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/ApiBlockListModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public class ApiBlockListModel +{ + public ApiBlockListModel(IEnumerable items) => Items = items; + + public IEnumerable Items { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/ApiContent.cs b/src/Umbraco.Core/Models/DeliveryApi/ApiContent.cs new file mode 100644 index 0000000000..30a186d6e8 --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/ApiContent.cs @@ -0,0 +1,15 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public class ApiContent : ApiElement, IApiContent +{ + public ApiContent(Guid id, string name, string contentType, IApiContentRoute route, IDictionary properties) + : base(id, contentType, properties) + { + Name = name; + Route = route; + } + + public string Name { get; } + + public IApiContentRoute Route { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/ApiContentResponse.cs b/src/Umbraco.Core/Models/DeliveryApi/ApiContentResponse.cs new file mode 100644 index 0000000000..d692ff464e --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/ApiContentResponse.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public class ApiContentResponse : ApiContent, IApiContentResponse +{ + public ApiContentResponse(Guid id, string name, string contentType, IApiContentRoute route, IDictionary properties, IDictionary cultures) + : base(id, name, contentType, route, properties) + => Cultures = cultures; + + // a little DX; by default this dictionary will be serialized as the first part of the response due to the inner workings of the serializer. + // that's rather confusing to see as the very first thing when you get a response from the API, so let's move it downwards. + // hopefully some day System.Text.Json will be able to order the properties differently in a centralized way. for now we have to live + // with this annotation :( + [JsonPropertyOrder(100)] + public IDictionary Cultures { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/ApiContentRoute.cs b/src/Umbraco.Core/Models/DeliveryApi/ApiContentRoute.cs new file mode 100644 index 0000000000..7e15ea6696 --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/ApiContentRoute.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public class ApiContentRoute : IApiContentRoute +{ + public ApiContentRoute(string path, ApiContentStartItem startItem) + { + Path = path; + StartItem = startItem; + } + + public string Path { get; } + + public IApiContentStartItem StartItem { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/ApiContentStartItem.cs b/src/Umbraco.Core/Models/DeliveryApi/ApiContentStartItem.cs new file mode 100644 index 0000000000..5a7f2b7082 --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/ApiContentStartItem.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public class ApiContentStartItem : IApiContentStartItem +{ + public ApiContentStartItem(Guid id, string path) + { + Id = id; + Path = path; + } + + public Guid Id { get; } + + public string Path { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/ApiElement.cs b/src/Umbraco.Core/Models/DeliveryApi/ApiElement.cs new file mode 100644 index 0000000000..b7d10814fc --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/ApiElement.cs @@ -0,0 +1,17 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public class ApiElement : IApiElement +{ + public ApiElement(Guid id, string contentType, IDictionary properties) + { + Id = id; + ContentType = contentType; + Properties = properties; + } + + public Guid Id { get; } + + public string ContentType { get; } + + public IDictionary Properties { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/ApiLink.cs b/src/Umbraco.Core/Models/DeliveryApi/ApiLink.cs new file mode 100644 index 0000000000..1b0acef86a --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/ApiLink.cs @@ -0,0 +1,38 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public class ApiLink +{ + public static ApiLink Content(string title, string? target, Guid destinationId, string destinationType, IApiContentRoute route) + => new(LinkType.Content, null, title, target, destinationId, destinationType, route); + + public static ApiLink Media(string title, string url, string? target, Guid destinationId, string destinationType) + => new(LinkType.Media, url, title, target, destinationId, destinationType, null); + + public static ApiLink External(string? title, string url, string? target) + => new(LinkType.External, url, title, target, null, null, null); + + private ApiLink(LinkType linkType, string? url, string? title, string? target, Guid? destinationId, string? destinationType, IApiContentRoute? route) + { + LinkType = linkType; + Url = url; + Title = title; + Target = target; + DestinationId = destinationId; + DestinationType = destinationType; + Route = route; + } + + public string? Url { get; } + + public string? Title { get; } + + public string? Target { get; } + + public Guid? DestinationId { get; } + + public string? DestinationType { get; } + + public IApiContentRoute? Route { get; } + + public LinkType LinkType { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/ApiMedia.cs b/src/Umbraco.Core/Models/DeliveryApi/ApiMedia.cs new file mode 100644 index 0000000000..7ad66f313e --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/ApiMedia.cs @@ -0,0 +1,23 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public class ApiMedia : IApiMedia +{ + public ApiMedia(Guid id, string name, string mediaType, string url, IDictionary properties) + { + Id = id; + Name = name; + MediaType = mediaType; + Url = url; + Properties = properties; + } + + public Guid Id { get; } + + public string Name { get; } + + public string MediaType { get; } + + public string Url { get; } + + public IDictionary Properties { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/IApiContent.cs b/src/Umbraco.Core/Models/DeliveryApi/IApiContent.cs new file mode 100644 index 0000000000..0f605eda19 --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/IApiContent.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public interface IApiContent : IApiElement +{ + string? Name { get; } + + IApiContentRoute Route { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/IApiContentResponse.cs b/src/Umbraco.Core/Models/DeliveryApi/IApiContentResponse.cs new file mode 100644 index 0000000000..fe7d80ce0b --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/IApiContentResponse.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public interface IApiContentResponse : IApiContent +{ + IDictionary Cultures { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/IApiContentRoute.cs b/src/Umbraco.Core/Models/DeliveryApi/IApiContentRoute.cs new file mode 100644 index 0000000000..1cc2b36b9d --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/IApiContentRoute.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public interface IApiContentRoute +{ + string Path { get; } + + IApiContentStartItem StartItem { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/IApiContentStartItem.cs b/src/Umbraco.Core/Models/DeliveryApi/IApiContentStartItem.cs new file mode 100644 index 0000000000..7cf117c647 --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/IApiContentStartItem.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public interface IApiContentStartItem +{ + Guid Id { get; } + + string Path { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/IApiElement.cs b/src/Umbraco.Core/Models/DeliveryApi/IApiElement.cs new file mode 100644 index 0000000000..7630225f64 --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/IApiElement.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public interface IApiElement +{ + Guid Id { get; } + + string ContentType { get; } + + IDictionary Properties { get; } +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/IApiMedia.cs b/src/Umbraco.Core/Models/DeliveryApi/IApiMedia.cs new file mode 100644 index 0000000000..6ae1575e61 --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/IApiMedia.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public interface IApiMedia +{ + public Guid Id { get; } + + public string Name { get; } + + public string MediaType { get; } + + public string Url { get; } + + public IDictionary Properties { get; } +} diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedProperty.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedProperty.cs index b030f145fd..a7bff33ba4 100644 --- a/src/Umbraco.Core/Models/PublishedContent/IPublishedProperty.cs +++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedProperty.cs @@ -70,4 +70,14 @@ public interface IPublishedProperty /// It has been fully prepared and processed by the appropriate converter. /// object? GetXPathValue(string? culture = null, string? segment = null); + + /// + /// Gets the object value of the property for Delivery API representation. + /// + /// + /// The value is what you want to use when rendering content through the Delivery API. + /// It can be null, or any type of CLR object. + /// It has been fully prepared and processed by the appropriate converter. + /// + object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null); } diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs index 3caaee9a37..718f0421a1 100644 --- a/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs +++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs @@ -51,6 +51,11 @@ public interface IPublishedPropertyType /// PropertyCacheLevel CacheLevel { get; } + /// + /// Gets the property cache level for Delivery API representation. + /// + PropertyCacheLevel DeliveryApiCacheLevel { get; } + /// /// Gets the property model CLR type. /// @@ -109,4 +114,14 @@ public interface IPublishedPropertyType /// The XPath value can be either a string or an XPathNavigator. /// object? ConvertInterToXPath(IPublishedElement owner, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview); + + /// + /// Converts the intermediate value into the object value for Delivery API representation. + /// + /// The published element owning the property. + /// The reference cache level. + /// The intermediate value. + /// A value indicating whether content should be considered draft. + /// The object value. + object? ConvertInterToDeliveryApiObject(IPublishedElement owner, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview); } diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyBase.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyBase.cs index 25cf64899b..a06d2006ba 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyBase.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyBase.cs @@ -20,6 +20,7 @@ public abstract class PublishedPropertyBase : IPublishedProperty ValidateCacheLevel(ReferenceCacheLevel, true); ValidateCacheLevel(PropertyType.CacheLevel, false); + ValidateCacheLevel(PropertyType.DeliveryApiCacheLevel, false); } /// @@ -47,6 +48,9 @@ public abstract class PublishedPropertyBase : IPublishedProperty /// public abstract object? GetXPathValue(string? culture = null, string? segment = null); + /// + public abstract object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null); + // validates the cache level private static void ValidateCacheLevel(PropertyCacheLevel cacheLevel, bool validateUnknown) { diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs index e6b5b5ffa2..de8cc43f15 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Xml.Linq; using System.Xml.XPath; using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; namespace Umbraco.Cms.Core.Models.PublishedContent { @@ -19,6 +20,7 @@ namespace Umbraco.Cms.Core.Models.PublishedContent private volatile bool _initialized; private IPropertyValueConverter? _converter; private PropertyCacheLevel _cacheLevel; + private PropertyCacheLevel _deliveryApiCacheLevel; private Type? _modelClrType; private Type? _clrType; @@ -190,6 +192,9 @@ namespace Umbraco.Cms.Core.Models.PublishedContent } _cacheLevel = _converter?.GetPropertyCacheLevel(this) ?? PropertyCacheLevel.Snapshot; + _deliveryApiCacheLevel = _converter is IDeliveryApiPropertyValueConverter deliveryApiPropertyValueConverter + ? deliveryApiPropertyValueConverter.GetDeliveryApiPropertyCacheLevel(this) + : _cacheLevel; _modelClrType = _converter?.GetPropertyValueType(this) ?? typeof(object); } @@ -225,6 +230,20 @@ namespace Umbraco.Cms.Core.Models.PublishedContent } } + /// + public PropertyCacheLevel DeliveryApiCacheLevel + { + get + { + if (!_initialized) + { + Initialize(); + } + + return _deliveryApiCacheLevel; + } + } + /// public object? ConvertSourceToInter(IPublishedElement owner, object? source, bool preview) { @@ -281,6 +300,22 @@ namespace Umbraco.Cms.Core.Models.PublishedContent return inter.ToString()?.Trim(); } + /// + public object? ConvertInterToDeliveryApiObject(IPublishedElement owner, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + if (!_initialized) + { + Initialize(); + } + + // use the converter if any, else just return the inter value + return _converter != null + ? _converter is IDeliveryApiPropertyValueConverter deliveryApiPropertyValueConverter + ? deliveryApiPropertyValueConverter.ConvertIntermediateToDeliveryApiObject(owner, this, referenceCacheLevel, inter, preview) + : _converter.ConvertIntermediateToObject(owner, this, referenceCacheLevel, inter, preview) + : inter; + } + /// public Type ModelClrType { diff --git a/src/Umbraco.Core/Models/PublishedContent/RawValueProperty.cs b/src/Umbraco.Core/Models/PublishedContent/RawValueProperty.cs index 763006f8f1..cec2556342 100644 --- a/src/Umbraco.Core/Models/PublishedContent/RawValueProperty.cs +++ b/src/Umbraco.Core/Models/PublishedContent/RawValueProperty.cs @@ -22,6 +22,7 @@ public class RawValueProperty : PublishedPropertyBase private readonly Lazy _objectValue; private readonly object _sourceValue; // the value in the db private readonly Lazy _xpathValue; + private readonly Lazy _deliveryApiValue; public RawValueProperty(IPublishedPropertyType propertyType, IPublishedElement content, object sourceValue, bool isPreviewing = false) : base(propertyType, PropertyCacheLevel.Unknown) // cache level is ignored @@ -39,6 +40,8 @@ public class RawValueProperty : PublishedPropertyBase PropertyType.ConvertInterToObject(content, PropertyCacheLevel.Unknown, interValue?.Value, isPreviewing)); _xpathValue = new Lazy(() => PropertyType.ConvertInterToXPath(content, PropertyCacheLevel.Unknown, interValue?.Value, isPreviewing)); + _deliveryApiValue = new Lazy(() => + PropertyType.ConvertInterToDeliveryApiObject(content, PropertyCacheLevel.Unknown, interValue?.Value, isPreviewing)); } // RawValueProperty does not (yet?) support variants, @@ -57,4 +60,7 @@ public class RawValueProperty : PublishedPropertyBase public override object? GetXPathValue(string? culture = null, string? segment = null) => string.IsNullOrEmpty(culture) & string.IsNullOrEmpty(segment) ? _xpathValue.Value : null; + + public override object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null) + => string.IsNullOrEmpty(culture) & string.IsNullOrEmpty(segment) ? _deliveryApiValue.Value : null; } diff --git a/src/Umbraco.Core/PropertyEditors/DeliveryApi/IDeliveryApiPropertyValueConverter.cs b/src/Umbraco.Core/PropertyEditors/DeliveryApi/IDeliveryApiPropertyValueConverter.cs new file mode 100644 index 0000000000..7f4ce9558e --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/DeliveryApi/IDeliveryApiPropertyValueConverter.cs @@ -0,0 +1,47 @@ +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.PropertyEditors.DeliveryApi; + +public interface IDeliveryApiPropertyValueConverter : IPropertyValueConverter +{ + /// + /// Gets the property cache level for Delivery API representation. + /// + /// The property type. + /// The property cache level. + PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType); + + /// + /// Gets the type of values returned by the converter for Delivery API representation. + /// + /// The property type. + /// The CLR type of values returned by the converter. + /// + /// Some of the CLR types may be generated, therefore this method cannot directly return + /// a Type object (which may not exist yet). In which case it needs to return a ModelType instance. + /// + Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType); + + /// + /// Converts a property intermediate value to an Object value for Delivery API representation. + /// + /// The property set owning the property. + /// The property type. + /// The reference cache level. + /// The intermediate value. + /// A value indicating whether conversion should take place in preview mode. + /// The result of the conversion. + /// + /// + /// The converter should know how to convert a null intermediate value, or any intermediate value + /// indicating that no value has been assigned to the property. It is up to the converter to determine + /// what to return in that case: either null, or the default value... + /// + /// + /// The is passed to the converter so that it can be, in turn, + /// passed to eg a PublishedFragment constructor. It is used by the fragment and the properties to manage + /// the cache levels of property values. It is not meant to be used by the converter. + /// + /// + object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview); +} diff --git a/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs b/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs index 74de3fea8e..90b0574086 100644 --- a/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs @@ -1,10 +1,11 @@ using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.Templates; namespace Umbraco.Cms.Core.PropertyEditors; [DefaultPropertyValueConverter] -public class TextStringValueConverter : PropertyValueConverterBase +public class TextStringValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { private static readonly string[] PropertyTypeAliases = { @@ -54,4 +55,13 @@ public class TextStringValueConverter : PropertyValueConverterBase // source should come from ConvertSource and be a string (or null) already inter; + + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) + => PropertyCacheLevel.Element; + + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) + => GetPropertyValueType(propertyType); + + public object ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + => ConvertIntermediateToObject(owner, propertyType, referenceCacheLevel, inter, preview); } diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs index d66c76c905..05e8bc9019 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs @@ -1,11 +1,16 @@ using System.Globalization; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; -public class ContentPickerValueConverter : PropertyValueConverterBase +public class ContentPickerValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { private static readonly List PropertiesToExclude = new() { @@ -14,9 +19,23 @@ public class ContentPickerValueConverter : PropertyValueConverterBase }; private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly IApiContentBuilder _apiContentBuilder; - public ContentPickerValueConverter(IPublishedSnapshotAccessor publishedSnapshotAccessor) => + [Obsolete("Use constructor that takes all parameters, scheduled for removal in V14")] + public ContentPickerValueConverter(IPublishedSnapshotAccessor publishedSnapshotAccessor) + : this( + publishedSnapshotAccessor, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public ContentPickerValueConverter( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IApiContentBuilder apiContentBuilder) + { _publishedSnapshotAccessor = publishedSnapshotAccessor; + _apiContentBuilder = apiContentBuilder; + } public override bool IsConverter(IPublishedPropertyType propertyType) => propertyType.EditorAlias.Equals(Constants.PropertyEditors.Aliases.ContentPicker); @@ -62,6 +81,37 @@ public class ContentPickerValueConverter : PropertyValueConverterBase } public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + IPublishedContent? content = GetContent(propertyType, inter); + return content ?? inter; + } + + public override object? ConvertIntermediateToXPath(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + if (inter == null) + { + return null; + } + + return inter.ToString(); + } + + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(IApiContent); + + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + IPublishedContent? content = GetContent(propertyType, inter); + if (content == null) + { + return null; + } + + return _apiContentBuilder.Build(content); + } + + private IPublishedContent? GetContent(IPublishedPropertyType propertyType, object? inter) { if (inter == null) { @@ -96,16 +146,6 @@ public class ContentPickerValueConverter : PropertyValueConverterBase } } - return inter; - } - - public override object? ConvertIntermediateToXPath(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) - { - if (inter == null) - { - return null; - } - - return inter.ToString(); + return null; } } diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs index 5eac35e67d..1d6792f185 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs @@ -1,6 +1,12 @@ using System.Collections; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; @@ -10,21 +16,35 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; /// [DefaultPropertyValueConverter] [Obsolete("Please use the MediaPicker3 instead, will be removed in V13")] -public class MediaPickerValueConverter : PropertyValueConverterBase +public class MediaPickerValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { // hard-coding "image" here but that's how it works at UI level too private const string ImageTypeAlias = "image"; - private readonly IPublishedModelFactory _publishedModelFactory; private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly IApiMediaBuilder _apiMediaBuilder; + [Obsolete("Use constructor that takes all parameters, scheduled for removal in V13")] public MediaPickerValueConverter( IPublishedSnapshotAccessor publishedSnapshotAccessor, IPublishedModelFactory publishedModelFactory) + : this( + publishedSnapshotAccessor, + publishedModelFactory, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public MediaPickerValueConverter( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + // annoyingly not even ActivatorUtilitiesConstructor can fix ambiguous constructor exceptions. + // we need to keep this unused parameter, at least until the other constructor is removed + IPublishedModelFactory publishedModelFactory, + IApiMediaBuilder apiMediaBuilder) { _publishedSnapshotAccessor = publishedSnapshotAccessor ?? throw new ArgumentNullException(nameof(publishedSnapshotAccessor)); - _publishedModelFactory = publishedModelFactory; + _apiMediaBuilder = apiMediaBuilder; } public override bool IsConverter(IPublishedPropertyType propertyType) => @@ -102,5 +122,29 @@ public class MediaPickerValueConverter : PropertyValueConverterBase return source; } + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Element; + + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(IEnumerable); + + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + var isMultiple = IsMultipleDataType(propertyType.DataType); + + // NOTE: eventually we might implement this explicitly instead of piggybacking on the default object conversion. however, this only happens once per cache rebuild, + // and the performance gain from an explicit implementation is negligible, so... at least for the time being this will do just fine. + var converted = ConvertIntermediateToObject(owner, propertyType, referenceCacheLevel, inter, preview); + if (isMultiple && converted is IEnumerable items) + { + return items.Select(_apiMediaBuilder.Build).ToArray(); + } + + if (isMultiple == false && converted is IPublishedContent item) + { + return new[] { _apiMediaBuilder.Build(item) }; + } + + return Array.Empty(); + } + private object? FirstOrDefault(IList mediaItems) => mediaItems.Count == 0 ? null : mediaItems[0]; } diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/MemberGroupPickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/MemberGroupPickerValueConverter.cs index a94da59c36..12c849b8a3 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/MemberGroupPickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/MemberGroupPickerValueConverter.cs @@ -1,11 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; [DefaultPropertyValueConverter] -public class MemberGroupPickerValueConverter : PropertyValueConverterBase +public class MemberGroupPickerValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { + private readonly IMemberGroupService _memberGroupService; + + [Obsolete("Use constructor that takes all parameters, scheduled for removal in V14")] + public MemberGroupPickerValueConverter() : this(StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public MemberGroupPickerValueConverter(IMemberGroupService memberGroupService) => _memberGroupService = memberGroupService; + public override bool IsConverter(IPublishedPropertyType propertyType) => propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.MemberGroupPicker); @@ -16,4 +30,25 @@ public class MemberGroupPickerValueConverter : PropertyValueConverterBase => PropertyCacheLevel.Element; public override object ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object? source, bool preview) => source?.ToString() ?? string.Empty; + + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(string[]); + + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + var memberGroupIds = inter? + .ToString()? + .Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries) + .Select(id => int.TryParse(id, out var memberGroupId) ? memberGroupId : 0) + .Where(id => id > 0) + .ToArray(); + if (memberGroupIds == null || memberGroupIds.Length == 0) + { + return null; + } + + IEnumerable memberGroups = _memberGroupService.GetByIds(memberGroupIds); + return memberGroups.Select(m => m.Name).ToArray(); + } } diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/MemberPickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/MemberPickerValueConverter.cs index 8c12264198..4a7c65de31 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/MemberPickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/MemberPickerValueConverter.cs @@ -1,5 +1,6 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; @@ -8,7 +9,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; [DefaultPropertyValueConverter] -public class MemberPickerValueConverter : PropertyValueConverterBase +public class MemberPickerValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { private readonly IMemberService _memberService; private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; @@ -101,4 +102,12 @@ public class MemberPickerValueConverter : PropertyValueConverterBase return source; } + + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(string); + + // member picker is unsupported for Delivery API output to avoid leaking member data by accident. + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + => "(unsupported)"; } diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/MultiNodeTreePickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/MultiNodeTreePickerValueConverter.cs index de8965ef3b..f863c96151 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/MultiNodeTreePickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/MultiNodeTreePickerValueConverter.cs @@ -1,9 +1,14 @@ using System.Globalization; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; @@ -12,7 +17,7 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; /// The multi node tree picker property editor value converter. /// [DefaultPropertyValueConverter(typeof(MustBeStringValueConverter))] -public class MultiNodeTreePickerValueConverter : PropertyValueConverterBase +public class MultiNodeTreePickerValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { private static readonly List PropertiesToExclude = new() { @@ -23,16 +28,36 @@ public class MultiNodeTreePickerValueConverter : PropertyValueConverterBase private readonly IMemberService _memberService; private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly IApiContentBuilder _apiContentBuilder; + private readonly IApiMediaBuilder _apiMediaBuilder; + [Obsolete("Use constructor that takes all parameters, scheduled for removal in V14")] public MultiNodeTreePickerValueConverter( IPublishedSnapshotAccessor publishedSnapshotAccessor, IUmbracoContextAccessor umbracoContextAccessor, IMemberService memberService) + : this( + publishedSnapshotAccessor, + umbracoContextAccessor, + memberService, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public MultiNodeTreePickerValueConverter( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IUmbracoContextAccessor umbracoContextAccessor, + IMemberService memberService, + IApiContentBuilder apiContentBuilder, + IApiMediaBuilder apiMediaBuilder) { _publishedSnapshotAccessor = publishedSnapshotAccessor ?? throw new ArgumentNullException(nameof(publishedSnapshotAccessor)); _umbracoContextAccessor = umbracoContextAccessor; _memberService = memberService; + _apiContentBuilder = apiContentBuilder; + _apiMediaBuilder = apiMediaBuilder; } public override bool IsConverter(IPublishedPropertyType propertyType) => @@ -156,9 +181,57 @@ public class MultiNodeTreePickerValueConverter : PropertyValueConverterBase return source; } + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Elements; + + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) + => GetEntityType(propertyType) switch + { + Constants.UdiEntityType.Media => typeof(IEnumerable), + Constants.UdiEntityType.Member => typeof(string), // unsupported + _ => typeof(IEnumerable) + }; + + + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + IEnumerable DefaultValue() => Array.Empty(); + + if (inter is not IEnumerable udis) + { + return DefaultValue(); + } + + IPublishedSnapshot publishedSnapshot = _publishedSnapshotAccessor.GetRequiredPublishedSnapshot(); + + var entityType = GetEntityType(propertyType); + GuidUdi[] entityTypeUdis = udis.Where(udi => udi.EntityType == entityType).OfType().ToArray(); + return entityType switch + { + Constants.UdiEntityType.Document => entityTypeUdis.Select(udi => + { + IPublishedContent? content = publishedSnapshot.Content?.GetById(udi.Guid); + return content != null + ? _apiContentBuilder.Build(content) + : null; + }).WhereNotNull().ToArray(), + Constants.UdiEntityType.Media => entityTypeUdis.Select(udi => + { + IPublishedContent? media = publishedSnapshot.Media?.GetById(udi.Guid); + return media != null + ? _apiMediaBuilder.Build(media) + : null; + }).WhereNotNull().ToArray(), + Constants.UdiEntityType.Member => "(unsupported)", + _ => DefaultValue() + }; + } + private static bool IsSingleNodePicker(IPublishedPropertyType propertyType) => propertyType.DataType.ConfigurationAs()?.MaxNumber == 1; + private static string GetEntityType(IPublishedPropertyType propertyType) => + propertyType.DataType.ConfigurationAs()?.TreeSource?.ObjectType ?? Constants.UdiEntityType.Document; + /// /// Attempt to get an IPublishedContent instance based on ID and content type /// diff --git a/src/Umbraco.Core/PublishedCache/Internal/InternalPublishedProperty.cs b/src/Umbraco.Core/PublishedCache/Internal/InternalPublishedProperty.cs index d9437e6b8c..a90897e221 100644 --- a/src/Umbraco.Core/PublishedCache/Internal/InternalPublishedProperty.cs +++ b/src/Umbraco.Core/PublishedCache/Internal/InternalPublishedProperty.cs @@ -15,6 +15,8 @@ public class InternalPublishedProperty : IPublishedProperty public object? SolidXPathValue { get; set; } + public object? SolidDeliveryApiValue { get; set; } + public IPublishedPropertyType PropertyType { get; set; } = null!; public string Alias { get; set; } = string.Empty; @@ -25,5 +27,7 @@ public class InternalPublishedProperty : IPublishedProperty public virtual object? GetXPathValue(string? culture = null, string? segment = null) => SolidXPathValue; + public virtual object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null) => SolidDeliveryApiValue; + public virtual bool HasValue(string? culture = null, string? segment = null) => SolidHasValue; } diff --git a/src/Umbraco.Core/PublishedCache/PublishedElementPropertyBase.cs b/src/Umbraco.Core/PublishedCache/PublishedElementPropertyBase.cs index 6beb094bef..13d096f846 100644 --- a/src/Umbraco.Core/PublishedCache/PublishedElementPropertyBase.cs +++ b/src/Umbraco.Core/PublishedCache/PublishedElementPropertyBase.cs @@ -84,6 +84,12 @@ internal class PublishedElementPropertyBase : PublishedPropertyBase public override object? GetSourceValue(string? culture = null, string? segment = null) => _sourceValue; private void GetCacheLevels(out PropertyCacheLevel cacheLevel, out PropertyCacheLevel referenceCacheLevel) + => GetCacheLevels(PropertyType.CacheLevel, out cacheLevel, out referenceCacheLevel); + + private void GetDeliveryApiCacheLevels(out PropertyCacheLevel cacheLevel, out PropertyCacheLevel referenceCacheLevel) + => GetCacheLevels(PropertyType.DeliveryApiCacheLevel, out cacheLevel, out referenceCacheLevel); + + private void GetCacheLevels(PropertyCacheLevel propertyTypeCacheLevel, out PropertyCacheLevel cacheLevel, out PropertyCacheLevel referenceCacheLevel) { // based upon the current reference cache level (ReferenceCacheLevel) and this property // cache level (PropertyType.CacheLevel), determines both the actual cache level for the @@ -97,9 +103,9 @@ internal class PublishedElementPropertyBase : PublishedPropertyBase // currently (reference) caching at published snapshot, property specifies // elements, ok to use element. OTOH, currently caching at elements, // property specifies snapshot, need to use snapshot. - if (PropertyType.CacheLevel > ReferenceCacheLevel || PropertyType.CacheLevel == PropertyCacheLevel.None) + if (propertyTypeCacheLevel > ReferenceCacheLevel || propertyTypeCacheLevel == PropertyCacheLevel.None) { - cacheLevel = PropertyType.CacheLevel; + cacheLevel = propertyTypeCacheLevel; referenceCacheLevel = cacheLevel; } else @@ -214,11 +220,52 @@ internal class PublishedElementPropertyBase : PublishedPropertyBase } } + public override object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null) + { + GetDeliveryApiCacheLevels(out PropertyCacheLevel cacheLevel, out PropertyCacheLevel referenceCacheLevel); + + lock (_locko) + { + CacheValues cacheValues = GetCacheValues(cacheLevel); + + object? GetDeliveryApiObject() => PropertyType.ConvertInterToDeliveryApiObject(Element, referenceCacheLevel, GetInterValue(), IsPreviewing); + return expanding + ? GetDeliveryApiExpandedObject(cacheValues, GetDeliveryApiObject) + : GetDeliveryApiDefaultObject(cacheValues, GetDeliveryApiObject); + } + } + + private object? GetDeliveryApiDefaultObject(CacheValues cacheValues, Func getValue) + { + if (cacheValues.DeliveryApiDefaultObjectInitialized == false) + { + cacheValues.DeliveryApiDefaultObjectValue = getValue(); + cacheValues.DeliveryApiDefaultObjectInitialized = true; + } + + return cacheValues.DeliveryApiDefaultObjectValue; + } + + private object? GetDeliveryApiExpandedObject(CacheValues cacheValues, Func getValue) + { + if (cacheValues.DeliveryApiExpandedObjectInitialized == false) + { + cacheValues.DeliveryApiExpandedObjectValue = getValue(); + cacheValues.DeliveryApiExpandedObjectInitialized = true; + } + + return cacheValues.DeliveryApiExpandedObjectValue; + } + protected class CacheValues { public bool ObjectInitialized; public object? ObjectValue; public bool XPathInitialized; public object? XPathValue; + public bool DeliveryApiDefaultObjectInitialized; + public object? DeliveryApiDefaultObjectValue; + public bool DeliveryApiExpandedObjectInitialized; + public object? DeliveryApiExpandedObjectValue; } } diff --git a/src/Umbraco.Examine.Lucene/DeliveryApiContentIndex.cs b/src/Umbraco.Examine.Lucene/DeliveryApiContentIndex.cs new file mode 100644 index 0000000000..a9b4ae3bd6 --- /dev/null +++ b/src/Umbraco.Examine.Lucene/DeliveryApiContentIndex.cs @@ -0,0 +1,20 @@ +using Examine.Lucene; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.Examine; + +public class DeliveryApiContentIndex : UmbracoExamineIndex +{ + public DeliveryApiContentIndex( + ILoggerFactory loggerFactory, + string name, + IOptionsMonitor indexOptions, + IHostingEnvironment hostingEnvironment, + IRuntimeState runtimeState) + : base(loggerFactory, name, indexOptions, hostingEnvironment, runtimeState) + { + } +} diff --git a/src/Umbraco.Examine.Lucene/DependencyInjection/ConfigureIndexOptions.cs b/src/Umbraco.Examine.Lucene/DependencyInjection/ConfigureIndexOptions.cs index f7c3cf9a3e..1f26ef24cd 100644 --- a/src/Umbraco.Examine.Lucene/DependencyInjection/ConfigureIndexOptions.cs +++ b/src/Umbraco.Examine.Lucene/DependencyInjection/ConfigureIndexOptions.cs @@ -44,6 +44,12 @@ public sealed class ConfigureIndexOptions : IConfigureNamedOptions(Constants.UmbracoIndexes .MembersIndexName) + .AddExamineLuceneIndex(Constants.UmbracoIndexes + .DeliveryApiContentIndexName) .ConfigureOptions(); services.AddSingleton(); diff --git a/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml b/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml index feff36d669..653a936b61 100644 --- a/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml +++ b/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml @@ -1,4 +1,5 @@  + CP0001 @@ -28,6 +29,13 @@ lib/net7.0/Umbraco.Infrastructure.dll true + + CP0002 + M:Umbraco.Cms.Core.PropertyEditors.ValueConverters.BlockGridPropertyValueConverter.#ctor(Umbraco.Cms.Core.Logging.IProfilingLogger,Umbraco.Cms.Core.PropertyEditors.ValueConverters.BlockEditorConverter,Umbraco.Cms.Core.Serialization.IJsonSerializer) + lib/net7.0/Umbraco.Infrastructure.dll + lib/net7.0/Umbraco.Infrastructure.dll + true + CP0002 M:Umbraco.Cms.Infrastructure.Migrations.IMigrationContext.AddPostMigration``1 diff --git a/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextParser.cs b/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextParser.cs new file mode 100644 index 0000000000..ea4ef80eda --- /dev/null +++ b/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextParser.cs @@ -0,0 +1,172 @@ +using System.Text.RegularExpressions; +using HtmlAgilityPack; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Infrastructure.Models.DeliveryApi; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.DeliveryApi; + +public partial class ApiRichTextParser : IApiRichTextParser +{ + private readonly IApiContentRouteBuilder _apiContentRouteBuilder; + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly IPublishedUrlProvider _publishedUrlProvider; + private readonly ILogger _logger; + + public ApiRichTextParser( + IApiContentRouteBuilder apiContentRouteBuilder, + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IPublishedUrlProvider publishedUrlProvider, + ILogger logger) + { + _apiContentRouteBuilder = apiContentRouteBuilder; + _publishedSnapshotAccessor = publishedSnapshotAccessor; + _publishedUrlProvider = publishedUrlProvider; + _logger = logger; + } + + public RichTextElement? Parse(string html) + { + try + { + IPublishedSnapshot publishedSnapshot = _publishedSnapshotAccessor.GetRequiredPublishedSnapshot(); + var doc = new HtmlDocument(); + doc.LoadHtml(html); + return ParseRecursively(doc.DocumentNode, publishedSnapshot); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not parse rich text HTML, see exception for details"); + return null; + } + } + + private RichTextElement ParseRecursively(HtmlNode current, IPublishedSnapshot publishedSnapshot) + { + // if a HtmlNode contains only #text elements, the entire node contents will be contained + // within the innerText declaration later in this method; otherwise accept all non-#text + // nodes + all non-empty #text nodes as valid node children + HtmlNode[]? childNodes = current.ChildNodes.All(c => c.Name == "#text") + ? null + : current.ChildNodes + .Where(c => c.Name != "#text" || string.IsNullOrWhiteSpace(c.InnerText) is false) + .ToArray(); + + // the resulting element can only have an inner text value if the node has no (valid) children + var innerText = childNodes is null ? current.InnerText : string.Empty; + + var tag = TagName(current); + var attributes = current.Attributes.ToDictionary(a => a.Name, a => a.Value as object); + + ReplaceLocalLinks(publishedSnapshot, attributes); + + ReplaceLocalImages(publishedSnapshot, tag, attributes); + + SanitizeAttributes(attributes); + + IEnumerable childElements = childNodes?.Any() is true + ? childNodes.Select(child => ParseRecursively(child, publishedSnapshot)) + : Enumerable.Empty(); + + return new RichTextElement(tag, innerText, attributes, childElements); + } + + private string TagName(HtmlNode htmlNode) => htmlNode.Name == "#document" ? "#root" : htmlNode.Name; + + private void ReplaceLocalLinks(IPublishedSnapshot publishedSnapshot, Dictionary attributes) + { + if (attributes.ContainsKey("href") is false || attributes["href"] is not string href) + { + return; + } + + Match match = LocalLinkRegex().Match(href); + if (match.Success is false) + { + return; + } + + attributes.Remove("href"); + + if (UdiParser.TryParse(match.Groups["udi"].Value, out Udi? udi) is false) + { + return; + } + + switch (udi.EntityType) + { + case Constants.UdiEntityType.Document: + IPublishedContent? content = publishedSnapshot.Content?.GetById(udi); + if (content != null) + { + attributes["route"] = _apiContentRouteBuilder.Build(content); + } + + break; + case Constants.UdiEntityType.Media: + IPublishedContent? media = publishedSnapshot.Media?.GetById(udi); + if (media != null) + { + attributes["href"] = _publishedUrlProvider.GetMediaUrl(media, UrlMode.Absolute); + } + + break; + } + } + + private void ReplaceLocalImages(IPublishedSnapshot publishedSnapshot, string tag, Dictionary attributes) + { + if (tag is not "img" || attributes.ContainsKey("data-udi") is false) + { + return; + } + + var dataUdiValue = attributes["data-udi"]; + attributes.Remove("data-udi"); + + if (dataUdiValue is not string dataUdi || UdiParser.TryParse(dataUdi, out Udi? udi) is false) + { + return; + } + + IPublishedContent? media = publishedSnapshot.Media?.GetById(udi); + if (media is not null) + { + // var currentSrc = attributes.ContainsKey("src") ? attributes["src"] as string : null; + attributes["src"] = _publishedUrlProvider.GetMediaUrl(media, UrlMode.Absolute); + + // this may be relevant if we can't find width and height in the attributes ... for now we seem quite able to, though + // if (currentSrc != null) + // { + // NameValueCollection queryString = HttpUtility.ParseQueryString(HttpUtility.HtmlDecode(currentSrc)); + // attributes["params"] = queryString.AllKeys.WhereNotNull().ToDictionary(key => key, key => queryString[key]); + // } + } + } + + private static void SanitizeAttributes(Dictionary attributes) + { + KeyValuePair[] dataAttributes = attributes + .Where(kvp => kvp.Key.StartsWith("data-")) + .ToArray(); + + foreach (KeyValuePair dataAttribute in dataAttributes) + { + var actualKey = dataAttribute.Key.TrimStart("data-"); + if (attributes.ContainsKey(actualKey) is false) + { + attributes[actualKey] = dataAttribute.Value; + } + + attributes.Remove(dataAttribute.Key); + } + } + + [GeneratedRegex("{localLink:(?umb:.+)}")] + private static partial Regex LocalLinkRegex(); +} diff --git a/src/Umbraco.Infrastructure/DeliveryApi/IApiRichTextParser.cs b/src/Umbraco.Infrastructure/DeliveryApi/IApiRichTextParser.cs new file mode 100644 index 0000000000..259fc8baed --- /dev/null +++ b/src/Umbraco.Infrastructure/DeliveryApi/IApiRichTextParser.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Infrastructure.Models.DeliveryApi; + +namespace Umbraco.Cms.Infrastructure.DeliveryApi; + +public interface IApiRichTextParser +{ + RichTextElement? Parse(string html); +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index dfe0c34826..d285cbbcbd 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -7,6 +7,8 @@ using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.DeliveryApi.Accessors; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.DistributedLocking; using Umbraco.Cms.Core.Events; @@ -36,6 +38,7 @@ using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Templates; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Infrastructure.DeliveryApi; using Umbraco.Cms.Infrastructure.DistributedLocking; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Infrastructure.HealthChecks; @@ -55,6 +58,7 @@ using Umbraco.Cms.Infrastructure.Search; using Umbraco.Cms.Infrastructure.Serialization; using Umbraco.Cms.Infrastructure.Services.Implement; using Umbraco.Extensions; +using ApiRichTextParser = Umbraco.Cms.Infrastructure.DeliveryApi.ApiRichTextParser; using IScopeProvider = Umbraco.Cms.Infrastructure.Scoping.IScopeProvider; namespace Umbraco.Cms.Infrastructure.DependencyInjection; @@ -219,9 +223,10 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddTransient(); - builder.AddPropertyIndexValueFactories(); + builder.AddDeliveryApiCoreServices(); + return builder; } @@ -412,4 +417,27 @@ public static partial class UmbracoBuilderExtensions return builder; } + + private static IUmbracoBuilder AddDeliveryApiCoreServices(this IUmbracoBuilder builder) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + return builder; + } } diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Examine.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Examine.cs index aabadc5197..fd00a1d759 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Examine.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Examine.cs @@ -25,6 +25,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -48,6 +49,7 @@ public static partial class UmbracoBuilderExtensions false)); builder.Services.AddUnique, MediaValueSetBuilder>(); builder.Services.AddUnique, MemberValueSetBuilder>(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.AddNotificationHandler(); diff --git a/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexFieldDefinitionCollection.cs b/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexFieldDefinitionCollection.cs new file mode 100644 index 0000000000..1debb49a81 --- /dev/null +++ b/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexFieldDefinitionCollection.cs @@ -0,0 +1,22 @@ +using Examine; + +namespace Umbraco.Cms.Infrastructure.Examine; + +public class DeliveryApiContentIndexFieldDefinitionCollection : FieldDefinitionCollection +{ + public static readonly FieldDefinition[] DeliveryApiIndexFieldDefinitions = + { + new("id", FieldDefinitionTypes.FullText), + new("parentKey", FieldDefinitionTypes.FullText), + new("ancestorKeys", FieldDefinitionTypes.FullText), + new("name", FieldDefinitionTypes.FullTextSortable), + new("level", FieldDefinitionTypes.Integer), + new("path", FieldDefinitionTypes.Raw), // TODO: should be sortable + new("sortOrder", FieldDefinitionTypes.Integer) + }; + + public DeliveryApiContentIndexFieldDefinitionCollection() + : base(DeliveryApiIndexFieldDefinitions) + { + } +} diff --git a/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexPopulator.cs b/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexPopulator.cs new file mode 100644 index 0000000000..a380a7881d --- /dev/null +++ b/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexPopulator.cs @@ -0,0 +1,37 @@ +using Examine; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.Examine; + +public class DeliveryApiContentIndexPopulator : IndexPopulator +{ + private readonly IContentService _contentService; + private readonly IDeliveryApiContentIndexValueSetBuilder _deliveryContentIndexValueSetBuilder; + + public DeliveryApiContentIndexPopulator(IContentService contentService, IDeliveryApiContentIndexValueSetBuilder deliveryContentIndexValueSetBuilder) + { + _contentService = contentService; + _deliveryContentIndexValueSetBuilder = deliveryContentIndexValueSetBuilder; + RegisterIndex(Constants.UmbracoIndexes.DeliveryApiContentIndexName); + } + + protected override void PopulateIndexes(IReadOnlyList indexes) + { + foreach (IIndex index in indexes) + { + IEnumerable rootNodes = _contentService.GetRootContent(); + + index.IndexItems(_deliveryContentIndexValueSetBuilder.GetValueSets(rootNodes.ToArray())); + + foreach (IContent root in rootNodes) + { + IEnumerable valueSets = _deliveryContentIndexValueSetBuilder.GetValueSets( + _contentService.GetPagedDescendants(root.Id, 0, int.MaxValue, out _).ToArray()); + + index.IndexItems(valueSets); + } + } + } +} diff --git a/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexValueSetBuilder.cs b/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexValueSetBuilder.cs new file mode 100644 index 0000000000..3c58692d0c --- /dev/null +++ b/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexValueSetBuilder.cs @@ -0,0 +1,51 @@ +using Examine; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Examine; + +public class DeliveryApiContentIndexValueSetBuilder : IDeliveryApiContentIndexValueSetBuilder +{ + private readonly IEntityService _entityService; + + public DeliveryApiContentIndexValueSetBuilder(IEntityService entityService) + => _entityService = entityService; + + /// + public IEnumerable GetValueSets(params IContent[] contents) + { + foreach (IContent content in contents) + { + IEnumerable? ancestors = content.GetAncestorIds(); + IEnumerable ancestorKeys = Enumerable.Empty(); + if (ancestors is not null) + { + ancestorKeys = ancestors.Select(GetContentKey); + } + + var indexValues = new Dictionary + { + ["id"] = content.Key, + ["parentKey"] = ancestorKeys.LastOrDefault(), + ["ancestorKeys"] = ancestorKeys.Any() ? string.Join(" ", ancestorKeys) : default(Guid), // ToDo: Store as array if it is faster to search + ["name"] = content.Name ?? string.Empty, + ["level"] = content.Level, + ["path"] = content.Path, // CSV of int ids + ["sortOrder"] = content.SortOrder + }; + + yield return new ValueSet(content.Id.ToString(), IndexTypes.Content, content.ContentType.Alias, + indexValues); + } + } + + private Guid GetContentKey(int id) + { + Attempt guidAttempt = _entityService.GetKey(id, UmbracoObjectTypes.Document); + Guid key = guidAttempt.Success ? guidAttempt.Result : Guid.Empty; + + return key; + } +} diff --git a/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs b/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs index fb3d7e0720..0bd6a56c3d 100644 --- a/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs +++ b/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs @@ -273,6 +273,7 @@ internal class ExamineUmbracoIndexingHandler : IUmbracoIndexingHandler public override void Execute() => Execute(_backgroundTaskQueue, _examineUmbracoIndexingHandler, _content, _isPublished); + // TODO: Delivery API index needs updating here public static void Execute(IBackgroundTaskQueue backgroundTaskQueue, ExamineUmbracoIndexingHandler examineUmbracoIndexingHandler, IContent content, bool isPublished) => backgroundTaskQueue.QueueBackgroundWorkItem(cancellationToken => diff --git a/src/Umbraco.Infrastructure/Examine/IDeliveryApiContentIndexValueSetBuilder.cs b/src/Umbraco.Infrastructure/Examine/IDeliveryApiContentIndexValueSetBuilder.cs new file mode 100644 index 0000000000..865cda2ca1 --- /dev/null +++ b/src/Umbraco.Infrastructure/Examine/IDeliveryApiContentIndexValueSetBuilder.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Infrastructure.Examine; + +public interface IDeliveryApiContentIndexValueSetBuilder : IValueSetBuilder +{ +} diff --git a/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiImageCropperValue.cs b/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiImageCropperValue.cs new file mode 100644 index 0000000000..7e64f41665 --- /dev/null +++ b/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiImageCropperValue.cs @@ -0,0 +1,19 @@ +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public class ApiImageCropperValue +{ + public ApiImageCropperValue(string url, ImageCropperValue.ImageCropperFocalPoint? focalPoint, IEnumerable? crops) + { + Url = url; + FocalPoint = focalPoint; + Crops = crops; + } + + public string Url { get; } + + public ImageCropperValue.ImageCropperFocalPoint? FocalPoint { get; } + + public IEnumerable? Crops { get; } +} diff --git a/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCrops.cs b/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCrops.cs new file mode 100644 index 0000000000..239108f416 --- /dev/null +++ b/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCrops.cs @@ -0,0 +1,32 @@ +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public class ApiMediaWithCrops : IApiMedia +{ + private readonly IApiMedia _inner; + + public ApiMediaWithCrops( + IApiMedia inner, + ImageCropperValue.ImageCropperFocalPoint? focalPoint, + IEnumerable? crops) + { + _inner = inner; + FocalPoint = focalPoint; + Crops = crops; + } + + public Guid Id => _inner.Id; + + public string Name => _inner.Name; + + public string MediaType => _inner.MediaType; + + public string Url => _inner.Url; + + public IDictionary Properties => _inner.Properties; + + public ImageCropperValue.ImageCropperFocalPoint? FocalPoint { get; } + + public IEnumerable? Crops { get; } +} diff --git a/src/Umbraco.Infrastructure/Models/DeliveryApi/RichTextElement.cs b/src/Umbraco.Infrastructure/Models/DeliveryApi/RichTextElement.cs new file mode 100644 index 0000000000..580bb2a323 --- /dev/null +++ b/src/Umbraco.Infrastructure/Models/DeliveryApi/RichTextElement.cs @@ -0,0 +1,20 @@ +namespace Umbraco.Cms.Infrastructure.Models.DeliveryApi; + +public class RichTextElement +{ + public RichTextElement(string tag, string text, Dictionary attributes, IEnumerable elements) + { + Tag = tag; + Text = text; + Attributes = attributes; + Elements = elements; + } + + public string Tag { get; } + + public string Text { get; } + + public Dictionary Attributes { get; } + + public IEnumerable Elements { get; } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs index ae330870fa..a68eebf0bc 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs @@ -1,9 +1,12 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Logging; using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.Serialization; using Umbraco.Extensions; using static Umbraco.Cms.Core.PropertyEditors.BlockGridConfiguration; @@ -11,16 +14,22 @@ using static Umbraco.Cms.Core.PropertyEditors.BlockGridConfiguration; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters { [DefaultPropertyValueConverter(typeof(JsonValueConverter))] - public class BlockGridPropertyValueConverter : BlockPropertyValueConverterBase + public class BlockGridPropertyValueConverter : BlockPropertyValueConverterBase, IDeliveryApiPropertyValueConverter { private readonly IProfilingLogger _proflog; private readonly IJsonSerializer _jsonSerializer; + private readonly IApiElementBuilder _apiElementBuilder; // Niels, Change: I would love if this could be general, so we don't need a specific one for each block property editor.... - public BlockGridPropertyValueConverter(IProfilingLogger proflog, BlockEditorConverter blockConverter, IJsonSerializer jsonSerializer) : base(blockConverter) + public BlockGridPropertyValueConverter( + IProfilingLogger proflog, BlockEditorConverter blockConverter, + IJsonSerializer jsonSerializer, + IApiElementBuilder apiElementBuilder) + : base(blockConverter) { _proflog = proflog; _jsonSerializer = jsonSerializer; + _apiElementBuilder = apiElementBuilder; } /// @@ -28,6 +37,49 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters => propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.BlockGrid); public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + => ConvertIntermediateToBlockGridModel(propertyType, referenceCacheLevel, inter, preview); + + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) + => typeof(ApiBlockGridModel); + + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + const int defaultColumns = 12; + + BlockGridModel? blockGridModel = ConvertIntermediateToBlockGridModel(propertyType, referenceCacheLevel, inter, preview); + if (blockGridModel == null) + { + return new ApiBlockGridModel(defaultColumns, Array.Empty()); + } + + ApiBlockGridItem CreateApiBlockGridItem(BlockGridItem item) + => new ApiBlockGridItem( + _apiElementBuilder.Build(item.Content), + item.Settings != null + ? _apiElementBuilder.Build(item.Settings) + : null, + item.RowSpan, + item.ColumnSpan, + item.AreaGridColumns ?? blockGridModel.GridColumns ?? defaultColumns, + item.Areas.Select(CreateApiBlockGridArea).ToArray()); + + ApiBlockGridArea CreateApiBlockGridArea(BlockGridArea area) + => new ApiBlockGridArea( + area.Alias, + area.RowSpan, + area.ColumnSpan, + area.Select(CreateApiBlockGridItem).ToArray()); + + var model = new ApiBlockGridModel( + blockGridModel.GridColumns ?? defaultColumns, + blockGridModel.Select(CreateApiBlockGridItem).ToArray()); + + return model; + } + + private BlockGridModel? ConvertIntermediateToBlockGridModel(IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) { using (_proflog.DebugDuration($"ConvertPropertyToBlockGrid ({propertyType.DataType.Id})")) { diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs index b56f43e0ef..ae4ef24672 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs @@ -3,10 +3,13 @@ using System.Reflection; using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Logging; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; @@ -15,23 +18,30 @@ using static Umbraco.Cms.Core.PropertyEditors.BlockListConfiguration; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; [DefaultPropertyValueConverter(typeof(JsonValueConverter))] -public class BlockListPropertyValueConverter : BlockPropertyValueConverterBase +public class BlockListPropertyValueConverter : BlockPropertyValueConverterBase, IDeliveryApiPropertyValueConverter { private readonly IContentTypeService _contentTypeService; - private readonly BlockEditorConverter _blockConverter; - private readonly BlockListEditorDataConverter _blockListEditorDataConverter; private readonly IProfilingLogger _proflog; + private readonly IApiElementBuilder _apiElementBuilder; - [Obsolete("Use the constructor with the IContentTypeService")] - public BlockListPropertyValueConverter(IProfilingLogger proflog, BlockEditorConverter blockConverter) : this(proflog, blockConverter, StaticServiceProvider.Instance.GetRequiredService()) { } + [Obsolete("Use the constructor that takes all parameters, scheduled for removal in V14")] + public BlockListPropertyValueConverter(IProfilingLogger proflog, BlockEditorConverter blockConverter) + : this(proflog, blockConverter, StaticServiceProvider.Instance.GetRequiredService()) + { + } + [Obsolete("Use the constructor that takes all parameters, scheduled for removal in V14")] public BlockListPropertyValueConverter(IProfilingLogger proflog, BlockEditorConverter blockConverter, IContentTypeService contentTypeService) + : this(proflog, blockConverter, contentTypeService, StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public BlockListPropertyValueConverter(IProfilingLogger proflog, BlockEditorConverter blockConverter, IContentTypeService contentTypeService, IApiElementBuilder apiElementBuilder) : base(blockConverter) { _proflog = proflog; - _blockConverter = blockConverter; - _blockListEditorDataConverter = new BlockListEditorDataConverter(); _contentTypeService = contentTypeService; + _apiElementBuilder = apiElementBuilder; } /// @@ -83,6 +93,44 @@ public class BlockListPropertyValueConverter : BlockPropertyValueConverterBase public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + // NOTE: The intermediate object is just a JSON string, we don't actually convert from source -> intermediate since source is always just a JSON string + using (_proflog.DebugDuration( + $"ConvertPropertyToBlockList ({propertyType.DataType.Id})")) + { + BlockListModel? blockListModel = ConvertIntermediateToBlockListModel(owner, propertyType, referenceCacheLevel, inter, preview); + if (blockListModel == null) + { + return null; + } + + return IsSingleBlockMode(propertyType.DataType) ? blockListModel.FirstOrDefault() : blockListModel; + } + } + + /// + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + + /// + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) + => typeof(IEnumerable); + + /// + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + BlockListModel? model = ConvertIntermediateToBlockListModel(owner, propertyType, referenceCacheLevel, inter, preview); + + return new ApiBlockListModel( + model != null + ? model + .Select(item => new ApiBlockItem( + _apiElementBuilder.Build(item.Content), + item.Settings != null ? _apiElementBuilder.Build(item.Settings) : null)) + .ToArray() + : Array.Empty()); + } + + private BlockListModel? ConvertIntermediateToBlockListModel(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) { // NOTE: The intermediate object is just a JSON string, we don't actually convert from source -> intermediate since source is always just a JSON string using (_proflog.DebugDuration( @@ -101,7 +149,7 @@ public class BlockListPropertyValueConverter : BlockPropertyValueConverterBase [DefaultPropertyValueConverter(typeof(JsonValueConverter))] -public class ImageCropperValueConverter : PropertyValueConverterBase +public class ImageCropperValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { private static readonly JsonSerializerSettings _imageCropperValueJsonSerializerSettings = new() { @@ -65,4 +67,13 @@ public class ImageCropperValueConverter : PropertyValueConverterBase return value; } + + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(ApiImageCropperValue); + + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + => inter is ImageCropperValue {Src: { }} imageCropperValue + ? new ApiImageCropperValue(imageCropperValue.Src, imageCropperValue.FocalPoint, imageCropperValue.Crops) + : null; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs index eebd5cbfe6..ff1554563a 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs @@ -3,13 +3,15 @@ using HeyRed.MarkdownSharp; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Templates; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; [DefaultPropertyValueConverter] -public class MarkdownEditorValueConverter : PropertyValueConverterBase +public class MarkdownEditorValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { private readonly HtmlLocalLinkParser _localLinkParser; private readonly HtmlUrlParser _urlParser; @@ -58,4 +60,19 @@ public class MarkdownEditorValueConverter : PropertyValueConverterBase // source should come from ConvertSource and be a string (or null) already inter?.ToString() ?? string.Empty; + + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Element; + + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(string); + + public object ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + if (inter is not string markdownString || markdownString.IsNullOrWhiteSpace()) + { + return string.Empty; + } + + var mark = new Markdown(); + return mark.Transform(markdownString); + } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs index ee21c4599c..ef183089e9 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs @@ -1,31 +1,55 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; [DefaultPropertyValueConverter] -public class MediaPickerWithCropsValueConverter : PropertyValueConverterBase +public class MediaPickerWithCropsValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { private readonly IJsonSerializer _jsonSerializer; private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; private readonly IPublishedUrlProvider _publishedUrlProvider; private readonly IPublishedValueFallback _publishedValueFallback; + private readonly IApiMediaBuilder _apiMediaBuilder; + [Obsolete("Use constructor that takes all parameters, scheduled for removal in V14")] public MediaPickerWithCropsValueConverter( IPublishedSnapshotAccessor publishedSnapshotAccessor, IPublishedUrlProvider publishedUrlProvider, IPublishedValueFallback publishedValueFallback, IJsonSerializer jsonSerializer) + : this( + publishedSnapshotAccessor, + publishedUrlProvider, + publishedValueFallback, + jsonSerializer, + StaticServiceProvider.Instance.GetRequiredService() + ) + { + } + + public MediaPickerWithCropsValueConverter( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IPublishedUrlProvider publishedUrlProvider, + IPublishedValueFallback publishedValueFallback, + IJsonSerializer jsonSerializer, + IApiMediaBuilder apiMediaBuilder) { _publishedSnapshotAccessor = publishedSnapshotAccessor ?? throw new ArgumentNullException(nameof(publishedSnapshotAccessor)); _publishedUrlProvider = publishedUrlProvider; _publishedValueFallback = publishedValueFallback; _jsonSerializer = jsonSerializer; + _apiMediaBuilder = apiMediaBuilder; } public override bool IsConverter(IPublishedPropertyType propertyType) => @@ -96,6 +120,35 @@ public class MediaPickerWithCropsValueConverter : PropertyValueConverterBase return isMultiple ? mediaItems : mediaItems.FirstOrDefault(); } + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Element; + + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(IEnumerable); + + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + var isMultiple = IsMultipleDataType(propertyType.DataType); + + ApiMediaWithCrops ToApiMedia(MediaWithCrops media) + { + IApiMedia inner = _apiMediaBuilder.Build(media.Content); + return new ApiMediaWithCrops(inner, media.LocalCrops.FocalPoint, media.LocalCrops.Crops); + } + + // NOTE: eventually we might implement this explicitly instead of piggybacking on the default object conversion. however, this only happens once per cache rebuild, + // and the performance gain from an explicit implementation is negligible, so... at least for the time being this will do just fine. + var converted = ConvertIntermediateToObject(owner, propertyType, referenceCacheLevel, inter, preview); + if (isMultiple && converted is IEnumerable mediasWithCrops) + { + return mediasWithCrops.Select(ToApiMedia).ToArray(); + } + if (isMultiple == false && converted is MediaWithCrops mediaWithCrops) + { + return new [] { ToApiMedia(mediaWithCrops) }; + } + + return Array.Empty(); + } + private bool IsMultipleDataType(PublishedDataType dataType) => dataType.ConfigurationAs()?.Multiple ?? false; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MultiUrlPickerValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MultiUrlPickerValueConverter.cs index 3dd212418a..605c348992 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MultiUrlPickerValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MultiUrlPickerValueConverter.cs @@ -1,45 +1,76 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Logging; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; -public class MultiUrlPickerValueConverter : PropertyValueConverterBase +public class MultiUrlPickerValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { private readonly IJsonSerializer _jsonSerializer; private readonly IProfilingLogger _proflog; private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; private readonly IPublishedUrlProvider _publishedUrlProvider; - private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly IApiContentNameProvider _apiContentNameProvider; + private readonly IApiMediaUrlProvider _apiMediaUrlProvider; + private readonly IApiContentRouteBuilder _apiContentRouteBuilder; + [Obsolete("Use constructor that takes all parameters, scheduled for removal in V14")] public MultiUrlPickerValueConverter( IPublishedSnapshotAccessor publishedSnapshotAccessor, IProfilingLogger proflog, IJsonSerializer jsonSerializer, IUmbracoContextAccessor umbracoContextAccessor, IPublishedUrlProvider publishedUrlProvider) + : this( + publishedSnapshotAccessor, + proflog, + jsonSerializer, + umbracoContextAccessor, + publishedUrlProvider, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public MultiUrlPickerValueConverter( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IProfilingLogger proflog, + IJsonSerializer jsonSerializer, + IUmbracoContextAccessor umbracoContextAccessor, + IPublishedUrlProvider publishedUrlProvider, + IApiContentNameProvider apiContentNameProvider, + IApiMediaUrlProvider apiMediaUrlProvider, + IApiContentRouteBuilder apiContentRouteBuilder) { _publishedSnapshotAccessor = publishedSnapshotAccessor ?? throw new ArgumentNullException(nameof(publishedSnapshotAccessor)); _proflog = proflog ?? throw new ArgumentNullException(nameof(proflog)); _jsonSerializer = jsonSerializer; - _umbracoContextAccessor = umbracoContextAccessor; _publishedUrlProvider = publishedUrlProvider; + _apiContentNameProvider = apiContentNameProvider; + _apiMediaUrlProvider = apiMediaUrlProvider; + _apiContentRouteBuilder = apiContentRouteBuilder; } public override bool IsConverter(IPublishedPropertyType propertyType) => Constants.PropertyEditors.Aliases.MultiUrlPicker.Equals(propertyType.EditorAlias); public override Type GetPropertyValueType(IPublishedPropertyType propertyType) => - propertyType.DataType.ConfigurationAs()!.MaxNumber == 1 + IsSingleUrlPicker(propertyType) ? typeof(Link) : typeof(IEnumerable); @@ -64,8 +95,7 @@ public class MultiUrlPickerValueConverter : PropertyValueConverterBase } var links = new List(); - IEnumerable? dtos = - _jsonSerializer.Deserialize>(inter.ToString()!); + IEnumerable? dtos = ParseLinkDtos(inter.ToString()!); IPublishedSnapshot publishedSnapshot = _publishedSnapshotAccessor.GetRequiredPublishedSnapshot(); if (dtos is null) { @@ -119,4 +149,63 @@ public class MultiUrlPickerValueConverter : PropertyValueConverterBase return links; } } + + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Elements; + + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(IEnumerable); + + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + IEnumerable DefaultValue() => Array.Empty(); + + if (inter is not string value || value.IsNullOrWhiteSpace()) + { + return DefaultValue(); + } + + MultiUrlPickerValueEditor.LinkDto[]? dtos = ParseLinkDtos(value)?.ToArray(); + if (dtos == null || dtos.Any() == false) + { + return DefaultValue(); + } + + IPublishedSnapshot publishedSnapshot = _publishedSnapshotAccessor.GetRequiredPublishedSnapshot(); + + ApiLink? ToLink(MultiUrlPickerValueEditor.LinkDto item) + { + switch (item.Udi?.EntityType) + { + case Constants.UdiEntityType.Document: + IPublishedContent? content = publishedSnapshot.Content?.GetById(item.Udi.Guid); + return content == null + ? null + : ApiLink.Content( + item.Name.IfNullOrWhiteSpace(_apiContentNameProvider.GetName(content)), + item.Target, + content.Key, + content.ContentType.Alias, + _apiContentRouteBuilder.Build(content)); + case Constants.UdiEntityType.Media: + IPublishedContent? media = publishedSnapshot.Media?.GetById(item.Udi.Guid); + return media == null + ? null + : ApiLink.Media( + item.Name.IfNullOrWhiteSpace(_apiContentNameProvider.GetName(media)), + _apiMediaUrlProvider.GetUrl(media), + item.Target, + media.Key, + media.ContentType.Alias); + default: + return ApiLink.External(item.Name, $"{item.Url}{item.QueryString}", item.Target); + } + } + + return dtos.Select(ToLink).WhereNotNull().ToArray(); + } + + private static bool IsSingleUrlPicker(IPublishedPropertyType propertyType) + => propertyType.DataType.ConfigurationAs()!.MaxNumber == 1; + + private IEnumerable? ParseLinkDtos(string inter) + => inter.DetectIsJson() ? _jsonSerializer.Deserialize>(inter) : null; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/NestedContentManyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/NestedContentManyValueConverter.cs index bbe1c92892..c2a3d8330e 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/NestedContentManyValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/NestedContentManyValueConverter.cs @@ -2,11 +2,16 @@ // See LICENSE for more details. using System.Collections; +using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Web.Common.DependencyInjection; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; @@ -17,9 +22,23 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; /// [DefaultPropertyValueConverter(typeof(JsonValueConverter))] [Obsolete("Nested content is obsolete, will be removed in V13")] -public class NestedContentManyValueConverter : NestedContentValueConverterBase +public class NestedContentManyValueConverter : NestedContentValueConverterBase, IDeliveryApiPropertyValueConverter { private readonly IProfilingLogger _proflog; + private readonly IApiElementBuilder _apiElementBuilder; + + [Obsolete("Use constructor that takes all parameters, scheduled for removal in V13")] + public NestedContentManyValueConverter( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IPublishedModelFactory publishedModelFactory, + IProfilingLogger proflog) + : this( + publishedSnapshotAccessor, + publishedModelFactory, + proflog, + StaticServiceProvider.Instance.GetRequiredService()) + { + } /// /// Initializes a new instance of the class. @@ -27,9 +46,13 @@ public class NestedContentManyValueConverter : NestedContentValueConverterBase public NestedContentManyValueConverter( IPublishedSnapshotAccessor publishedSnapshotAccessor, IPublishedModelFactory publishedModelFactory, - IProfilingLogger proflog) + IProfilingLogger proflog, + IApiElementBuilder apiElementBuilder) : base(publishedSnapshotAccessor, publishedModelFactory) - => _proflog = proflog; + { + _proflog = proflog; + _apiElementBuilder = apiElementBuilder; + } /// public override bool IsConverter(IPublishedPropertyType propertyType) @@ -91,4 +114,19 @@ public class NestedContentManyValueConverter : NestedContentValueConverterBase return elements; } } + + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(IEnumerable); + + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + var converted = ConvertIntermediateToObject(owner, propertyType, referenceCacheLevel, inter, preview); + if (converted is not IEnumerable elements) + { + return null; + } + + return elements.Select(element => _apiElementBuilder.Build(element)); + } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/NestedContentSingleValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/NestedContentSingleValueConverter.cs index 1ee2759932..7110d448ee 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/NestedContentSingleValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/NestedContentSingleValueConverter.cs @@ -1,11 +1,16 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Web.Common.DependencyInjection; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; @@ -16,9 +21,23 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; /// [DefaultPropertyValueConverter(typeof(JsonValueConverter))] [Obsolete("Nested content is obsolete, will be removed in V13")] -public class NestedContentSingleValueConverter : NestedContentValueConverterBase +public class NestedContentSingleValueConverter : NestedContentValueConverterBase, IDeliveryApiPropertyValueConverter { private readonly IProfilingLogger _proflog; + private readonly IApiElementBuilder _apiElementBuilder; + + [Obsolete("Use constructor that takes all parameters, scheduled for removal in V13")] + public NestedContentSingleValueConverter( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IPublishedModelFactory publishedModelFactory, + IProfilingLogger proflog) + : this( + publishedSnapshotAccessor, + publishedModelFactory, + proflog, + StaticServiceProvider.Instance.GetRequiredService()) + { + } /// /// Initializes a new instance of the class. @@ -26,9 +45,13 @@ public class NestedContentSingleValueConverter : NestedContentValueConverterBase public NestedContentSingleValueConverter( IPublishedSnapshotAccessor publishedSnapshotAccessor, IPublishedModelFactory publishedModelFactory, - IProfilingLogger proflog) + IProfilingLogger proflog, + IApiElementBuilder apiElementBuilder) : base(publishedSnapshotAccessor, publishedModelFactory) - => _proflog = proflog; + { + _proflog = proflog; + _apiElementBuilder = apiElementBuilder; + } /// public override bool IsConverter(IPublishedPropertyType propertyType) @@ -75,4 +98,19 @@ public class NestedContentSingleValueConverter : NestedContentValueConverterBase return ConvertToElement(objects[0], referenceCacheLevel, preview); } } + + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(IEnumerable); + + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + var converted = ConvertIntermediateToObject(owner, propertyType, referenceCacheLevel, inter, preview); + if (converted is not IPublishedElement element) + { + return Array.Empty(); + } + + return new [] { _apiElementBuilder.Build(element) }; + } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs index 4c9f35d9a2..6b598a8506 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs @@ -4,12 +4,19 @@ using System.Globalization; using System.Text; using HtmlAgilityPack; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Macros; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Templates; using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Infrastructure.DeliveryApi; using Umbraco.Cms.Infrastructure.Macros; +using Umbraco.Cms.Infrastructure.Models.DeliveryApi; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; @@ -19,22 +26,41 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; /// used dynamically. /// [DefaultPropertyValueConverter] -public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter +public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDeliveryApiPropertyValueConverter { private readonly HtmlImageSourceParser _imageSourceParser; private readonly HtmlLocalLinkParser _linkParser; private readonly IMacroRenderer _macroRenderer; private readonly IUmbracoContextAccessor _umbracoContextAccessor; private readonly HtmlUrlParser _urlParser; + private readonly IApiRichTextParser _apiRichTextParser; + private DeliveryApiSettings _deliveryApiSettings; + [Obsolete("Please use the constructor that takes all arguments. Will be removed in V14.")] public RteMacroRenderingValueConverter(IUmbracoContextAccessor umbracoContextAccessor, IMacroRenderer macroRenderer, HtmlLocalLinkParser linkParser, HtmlUrlParser urlParser, HtmlImageSourceParser imageSourceParser) + : this( + umbracoContextAccessor, + macroRenderer, + linkParser, + urlParser, + imageSourceParser, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + public RteMacroRenderingValueConverter(IUmbracoContextAccessor umbracoContextAccessor, IMacroRenderer macroRenderer, + HtmlLocalLinkParser linkParser, HtmlUrlParser urlParser, HtmlImageSourceParser imageSourceParser, IApiRichTextParser apiRichTextParser, IOptionsMonitor deliveryApiSettingsMonitor) { _umbracoContextAccessor = umbracoContextAccessor; _macroRenderer = macroRenderer; _linkParser = linkParser; _urlParser = urlParser; _imageSourceParser = imageSourceParser; + _apiRichTextParser = apiRichTextParser; + _deliveryApiSettings = deliveryApiSettingsMonitor.CurrentValue; + deliveryApiSettingsMonitor.OnChange(settings => _deliveryApiSettings = settings); } public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) => @@ -51,6 +77,26 @@ public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter return new HtmlEncodedString(converted ?? string.Empty); } + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Elements; + + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) + => _deliveryApiSettings.RichTextOutputAsJson + ? typeof(RichTextElement) + : typeof(string); + + public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) + { + if (_deliveryApiSettings.RichTextOutputAsJson is false) + { + return Convert(inter, preview) ?? string.Empty; + } + + var sourceString = inter?.ToString(); + return sourceString != null + ? _apiRichTextParser.Parse(sourceString) + : null; + } + // NOT thread-safe over a request because it modifies the // global UmbracoContext.Current.InPreviewMode status. So it // should never execute in // over the same UmbracoContext with diff --git a/src/Umbraco.PublishedCache.NuCache/Property.cs b/src/Umbraco.PublishedCache.NuCache/Property.cs index dd9297515f..8377d369a7 100644 --- a/src/Umbraco.PublishedCache.NuCache/Property.cs +++ b/src/Umbraco.PublishedCache.NuCache/Property.cs @@ -310,6 +310,49 @@ internal class Property : PublishedPropertyBase } } + public override object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null) + { + _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment); + + object? value; + lock (_locko) + { + CacheValue cacheValues = GetCacheValues(PropertyType.DeliveryApiCacheLevel).For(culture, segment); + + // initial reference cache level always is .Content + const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element; + + object? GetDeliveryApiObject() => PropertyType.ConvertInterToDeliveryApiObject(_content, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing); + value = expanding + ? GetDeliveryApiExpandedObject(cacheValues, GetDeliveryApiObject) + : GetDeliveryApiDefaultObject(cacheValues, GetDeliveryApiObject); + } + + return value; + } + + private object? GetDeliveryApiDefaultObject(CacheValue cacheValues, Func getValue) + { + if (cacheValues.DeliveryApiDefaultObjectInitialized == false) + { + cacheValues.DeliveryApiDefaultObjectValue = getValue(); + cacheValues.DeliveryApiDefaultObjectInitialized = true; + } + + return cacheValues.DeliveryApiDefaultObjectValue; + } + + private object? GetDeliveryApiExpandedObject(CacheValue cacheValues, Func getValue) + { + if (cacheValues.DeliveryApiExpandedObjectInitialized == false) + { + cacheValues.DeliveryApiExpandedObjectValue = getValue(); + cacheValues.DeliveryApiExpandedObjectInitialized = true; + } + + return cacheValues.DeliveryApiExpandedObjectValue; + } + #region Classes private class CacheValue @@ -321,6 +364,14 @@ internal class Property : PublishedPropertyBase public bool XPathInitialized { get; set; } public object? XPathValue { get; set; } + + public bool DeliveryApiDefaultObjectInitialized { get; set; } + + public object? DeliveryApiDefaultObjectValue { get; set; } + + public bool DeliveryApiExpandedObjectInitialized { get; set; } + + public object? DeliveryApiExpandedObjectValue { get; set; } } private class CacheValues : CacheValue diff --git a/src/Umbraco.Web.UI/Startup.cs b/src/Umbraco.Web.UI/Startup.cs index eba72b6924..1d7f49b1e5 100644 --- a/src/Umbraco.Web.UI/Startup.cs +++ b/src/Umbraco.Web.UI/Startup.cs @@ -32,6 +32,7 @@ namespace Umbraco.Cms.Web.UI services.AddUmbraco(_env, _config) .AddBackOffice() .AddWebsite() + .AddDeliveryApi() .AddComposers() .Build(); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/CacheTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/CacheTests.cs new file mode 100644 index 0000000000..56f6dec0df --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/CacheTests.cs @@ -0,0 +1,74 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.PublishedCache; +using Umbraco.Cms.Infrastructure.PublishedCache.DataSource; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class CacheTests +{ + [TestCase(PropertyCacheLevel.Snapshot, false, 1)] + [TestCase(PropertyCacheLevel.Snapshot, true, 1)] + [TestCase(PropertyCacheLevel.Elements, false, 1)] + [TestCase(PropertyCacheLevel.Elements, true, 1)] + [TestCase(PropertyCacheLevel.Element, false, 1)] + [TestCase(PropertyCacheLevel.Element, true, 1)] + [TestCase(PropertyCacheLevel.None, false, 4)] + [TestCase(PropertyCacheLevel.None, true, 4)] + public void PublishedElementProperty_CachesDeliveryApiValueConversion(PropertyCacheLevel cacheLevel, bool expanding, int expectedConverterHits) + { + var contentType = new Mock(); + contentType.SetupGet(c => c.PropertyTypes).Returns(Array.Empty()); + + var contentNode = new ContentNode(123, Guid.NewGuid(), contentType.Object, 1, string.Empty, 1, 1, DateTime.Now, 1); + var contentData = new ContentData("bla", "bla", 1, DateTime.Now, 1, 1, true, new Dictionary(), null); + + var elementCache = new FastDictionaryAppCache(); + var snapshotCache = new FastDictionaryAppCache(); + var publishedSnapshotMock = new Mock(); + publishedSnapshotMock.SetupGet(p => p.ElementsCache).Returns(elementCache); + publishedSnapshotMock.SetupGet(p => p.SnapshotCache).Returns(snapshotCache); + + var publishedSnapshot = publishedSnapshotMock.Object; + var publishedSnapshotAccessor = new Mock(); + publishedSnapshotAccessor.Setup(p => p.TryGetPublishedSnapshot(out publishedSnapshot)).Returns(true); + + var content = new PublishedContent( + contentNode, + contentData, + publishedSnapshotAccessor.Object, + Mock.Of(), + Mock.Of()); + + var propertyType = new Mock(); + var invocationCount = 0; + propertyType.SetupGet(p => p.CacheLevel).Returns(cacheLevel); + propertyType.SetupGet(p => p.DeliveryApiCacheLevel).Returns(cacheLevel); + propertyType + .Setup(p => p.ConvertInterToDeliveryApiObject(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(() => $"Delivery API value: {++invocationCount}"); + + var prop1 = new Property(propertyType.Object, content, publishedSnapshotAccessor.Object); + var results = new List(); + results.Add(prop1.GetDeliveryApiValue(expanding)!.ToString()); + results.Add(prop1.GetDeliveryApiValue(expanding)!.ToString()); + results.Add(prop1.GetDeliveryApiValue(expanding)!.ToString()); + results.Add(prop1.GetDeliveryApiValue(expanding)!.ToString()); + + Assert.AreEqual("Delivery API value: 1", results.First()); + Assert.AreEqual(expectedConverterHits, results.Distinct().Count()); + + propertyType.Verify( + p => p.ConvertInterToDeliveryApiObject( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Exactly(expectedConverterHits)); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ApiMediaUrlProviderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ApiMediaUrlProviderTests.cs new file mode 100644 index 0000000000..e1900d203f --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ApiMediaUrlProviderTests.cs @@ -0,0 +1,49 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Routing; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class ApiMediaUrlProviderTests : PropertyValueConverterTests +{ + [TestCase("/some/url/for/the/media.jpg")] + [TestCase("/some/media.url")] + [TestCase("/root/some/media.url")] + [TestCase("/root-two/some/media.url")] + [TestCase("/media.url")] + public void Media_Url_Provider_Returns_Relative_Published_Media_Url(string publishedUrl) + { + var content = new Mock(); + content.SetupGet(c => c.ItemType).Returns(PublishedItemType.Media); + + var publishedUrlProvider = new Mock(); + publishedUrlProvider + .Setup(p => p.GetMediaUrl(content.Object, UrlMode.Relative, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(publishedUrl); + + var apiMediaUrlProvider = new ApiMediaUrlProvider(publishedUrlProvider.Object); + var result = apiMediaUrlProvider.GetUrl(content.Object); + Assert.AreEqual(publishedUrl, result); + } + + [TestCase(PublishedItemType.Content)] + [TestCase(PublishedItemType.Element)] + [TestCase(PublishedItemType.Member)] + [TestCase(PublishedItemType.Unknown)] + public void Does_Not_Support_Non_Media_Types(PublishedItemType itemType) + { + var content = new Mock(); + content.SetupGet(c => c.ItemType).Returns(itemType); + + var publishedUrlProvider = new Mock(); + publishedUrlProvider + .Setup(p => p.GetUrl(content.Object, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("somewhere.else"); + + var apiUrlProvider = new ApiMediaUrlProvider(publishedUrlProvider.Object); + Assert.Throws(() => apiUrlProvider.GetUrl(content.Object)); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/CacheTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/CacheTests.cs new file mode 100644 index 0000000000..f75b4cb90f --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/CacheTests.cs @@ -0,0 +1,60 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; +using Umbraco.Cms.Core.PublishedCache; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class CacheTests : DeliveryApiTests +{ + [TestCase(PropertyCacheLevel.Snapshot, false, 1)] + [TestCase(PropertyCacheLevel.Snapshot, true, 1)] + [TestCase(PropertyCacheLevel.Elements, false, 1)] + [TestCase(PropertyCacheLevel.Elements, true, 1)] + [TestCase(PropertyCacheLevel.Element, false, 1)] + [TestCase(PropertyCacheLevel.Element, true, 1)] + [TestCase(PropertyCacheLevel.None, false, 4)] + [TestCase(PropertyCacheLevel.None, true, 4)] + public void PublishedElementProperty_CachesDeliveryApiValueConversion(PropertyCacheLevel cacheLevel, bool expanding, int expectedConverterHits) + { + var propertyValueConverter = new Mock(); + var invocationCount = 0; + propertyValueConverter.Setup(p => p.ConvertIntermediateToDeliveryApiObject( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()) + ).Returns(() => $"Delivery API value: {++invocationCount}"); + propertyValueConverter.Setup(p => p.IsConverter(It.IsAny())).Returns(true); + propertyValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny())).Returns(cacheLevel); + propertyValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(cacheLevel); + + var propertyType = SetupPublishedPropertyType(propertyValueConverter.Object, "something", "Some.Thing"); + + var element = new Mock(); + + var prop1 = new PublishedElementPropertyBase(propertyType, element.Object, false, cacheLevel); + + var results = new List(); + results.Add(prop1.GetDeliveryApiValue(expanding)!.ToString()); + results.Add(prop1.GetDeliveryApiValue(expanding)!.ToString()); + results.Add(prop1.GetDeliveryApiValue(expanding)!.ToString()); + results.Add(prop1.GetDeliveryApiValue(expanding)!.ToString()); + + Assert.AreEqual("Delivery API value: 1", results.First()); + Assert.AreEqual(expectedConverterHits, results.Distinct().Count()); + + propertyValueConverter.Verify( + converter => converter.ConvertIntermediateToDeliveryApiObject( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Exactly(expectedConverterHits)); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentBuilderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentBuilderTests.cs new file mode 100644 index 0000000000..bd1dfdbcf7 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentBuilderTests.cs @@ -0,0 +1,69 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class ContentBuilderTests : DeliveryApiTests +{ + [Test] + public void ContentBuilder_MapsContentDataAndPropertiesCorrectly() + { + var content = new Mock(); + + var prop1 = new PublishedElementPropertyBase(DeliveryApiPropertyType, content.Object, false, PropertyCacheLevel.None); + var prop2 = new PublishedElementPropertyBase(DefaultPropertyType, content.Object, false, PropertyCacheLevel.None); + + var contentType = new Mock(); + contentType.SetupGet(c => c.Alias).Returns("thePageType"); + + var key = Guid.NewGuid(); + var urlSegment = "url-segment"; + var name = "The page"; + ConfigurePublishedContentMock(content, key, name, urlSegment, contentType.Object, new[] { prop1, prop2 }); + + var publishedUrlProvider = new Mock(); + publishedUrlProvider + .Setup(p => p.GetUrl(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((IPublishedContent content, UrlMode mode, string? culture, Uri? current) => $"url:{content.UrlSegment}"); + + var routeBuilder = new ApiContentRouteBuilder(publishedUrlProvider.Object, CreateGlobalSettings(), Mock.Of()); + + var builder = new ApiContentBuilder(new ApiContentNameProvider(), routeBuilder, CreateOutputExpansionStrategyAccessor()); + var result = builder.Build(content.Object); + + Assert.NotNull(result); + Assert.AreEqual("The page", result.Name); + Assert.AreEqual("thePageType", result.ContentType); + Assert.AreEqual("/url:url-segment", result.Route.Path); + Assert.AreEqual(key, result.Id); + Assert.AreEqual(2, result.Properties.Count); + Assert.AreEqual("Delivery API value", result.Properties["deliveryApi"]); + Assert.AreEqual("Default value", result.Properties["default"]); + } + + [Test] + public void ContentBuilder_CanCustomizeContentNameInDeliveryApiOutput() + { + var content = new Mock(); + + var contentType = new Mock(); + contentType.SetupGet(c => c.Alias).Returns("thePageType"); + + ConfigurePublishedContentMock(content, Guid.NewGuid(), "The page", "the-page", contentType.Object, Array.Empty()); + + var customNameProvider = new Mock(); + customNameProvider.Setup(n => n.GetName(content.Object)).Returns($"Custom name for: {content.Object.Name}"); + + var builder = new ApiContentBuilder(customNameProvider.Object, Mock.Of(), CreateOutputExpansionStrategyAccessor()); + var result = builder.Build(content.Object); + + Assert.NotNull(result); + Assert.AreEqual("Custom name for: The page", result.Name); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentPickerValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentPickerValueConverterTests.cs new file mode 100644 index 0000000000..29447f2e82 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentPickerValueConverterTests.cs @@ -0,0 +1,109 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.PublishedCache; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class ContentPickerValueConverterTests : PropertyValueConverterTests +{ + private ContentPickerValueConverter CreateValueConverter(IApiContentNameProvider? nameProvider = null) + => new ContentPickerValueConverter( + PublishedSnapshotAccessor, + new ApiContentBuilder( + nameProvider ?? new ApiContentNameProvider(), + new ApiContentRouteBuilder(PublishedUrlProvider, CreateGlobalSettings(), Mock.Of()), + CreateOutputExpansionStrategyAccessor())); + + [Test] + public void ContentPickerValueConverter_BuildsDeliveryApiOutput() + { + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.Alias).Returns("test"); + + var valueConverter = CreateValueConverter(); + Assert.AreEqual(typeof(IApiContent), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType.Object)); + var result = valueConverter.ConvertIntermediateToDeliveryApiObject( + Mock.Of(), + publishedPropertyType.Object, + PropertyCacheLevel.Element, + new GuidUdi(Constants.UdiEntityType.Document, PublishedContent.Key), + false) as IApiContent; + + Assert.NotNull(result); + Assert.AreEqual("The page", result.Name); + Assert.AreEqual(PublishedContent.Key, result.Id); + Assert.AreEqual("/the-page-url", result.Route.Path); + Assert.AreEqual("TheContentType", result.ContentType); + Assert.IsEmpty(result.Properties); + } + + [Test] + public void ContentPickerValueConverter_CanCustomizeContentNameInDeliveryApiOutput() + { + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.Alias).Returns("test"); + + var customNameProvider = new Mock(); + customNameProvider.Setup(n => n.GetName(PublishedContent)).Returns($"Custom name for: {PublishedContent.Name}"); + + var valueConverter = CreateValueConverter(customNameProvider.Object); + var result = valueConverter.ConvertIntermediateToDeliveryApiObject( + Mock.Of(), + publishedPropertyType.Object, + PropertyCacheLevel.Element, + new GuidUdi(Constants.UdiEntityType.Document, PublishedContent.Key), + false) as IApiContent; + + Assert.NotNull(result); + Assert.AreEqual("Custom name for: The page", result.Name); + } + + [Test] + public void ContentPickerValueConverter_RendersContentProperties() + { + var content = new Mock(); + + var prop1 = new PublishedElementPropertyBase(DeliveryApiPropertyType, content.Object, false, PropertyCacheLevel.None); + var prop2 = new PublishedElementPropertyBase(DefaultPropertyType, content.Object, false, PropertyCacheLevel.None); + + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.Alias).Returns("test"); + + var key = Guid.NewGuid(); + var urlSegment = "page-url-segment"; + var name = "The page"; + ConfigurePublishedContentMock(content, key, name, urlSegment, PublishedContentType, new[] { prop1, prop2 }); + + PublishedUrlProviderMock + .Setup(p => p.GetUrl(content.Object, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(content.Object.UrlSegment); + PublishedContentCacheMock + .Setup(pcc => pcc.GetById(key)) + .Returns(content.Object); + + var valueConverter = CreateValueConverter(); + Assert.AreEqual(typeof(IApiContent), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType.Object)); + var result = valueConverter.ConvertIntermediateToDeliveryApiObject( + Mock.Of(), + publishedPropertyType.Object, + PropertyCacheLevel.Element, + new GuidUdi(Constants.UdiEntityType.Document, key), + false) as IApiContent; + + Assert.NotNull(result); + Assert.AreEqual("The page", result.Name); + Assert.AreEqual(content.Object.Key, result.Id); + Assert.AreEqual("/page-url-segment", result.Route.Path); + Assert.AreEqual("TheContentType", result.ContentType); + Assert.AreEqual(2, result.Properties.Count); + Assert.AreEqual("Delivery API value", result.Properties[DeliveryApiPropertyType.Alias]); + Assert.AreEqual("Default value", result.Properties[DefaultPropertyType.Alias]); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentRouteBuilderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentRouteBuilderTests.cs new file mode 100644 index 0000000000..043c137a10 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentRouteBuilderTests.cs @@ -0,0 +1,210 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class ContentRouteBuilderTests : DeliveryApiTests +{ + [TestCase(true)] + [TestCase(false)] + public void CanBuildForRoot(bool hideTopLevelNodeFromPath) + { + var rootKey = Guid.NewGuid(); + var root = SetupInvariantPublishedContent("The Root", rootKey); + + // yes... actually testing the mock setup here. but it's important! + var publishedUrlProvider = SetupPublishedUrlProvider(hideTopLevelNodeFromPath); + Assert.AreEqual(hideTopLevelNodeFromPath ? "/" : "/the-root", publishedUrlProvider.GetUrl(root)); + + var builder = new ApiContentRouteBuilder(publishedUrlProvider, CreateGlobalSettings(hideTopLevelNodeFromPath), Mock.Of()); + var result = builder.Build(root); + Assert.AreEqual("/", result.Path); + Assert.AreEqual(rootKey, result.StartItem.Id); + Assert.AreEqual("the-root", result.StartItem.Path); + } + + [TestCase(true)] + [TestCase(false)] + public void CanBuildForChild(bool hideTopLevelNodeFromPath) + { + var rootKey = Guid.NewGuid(); + var root = SetupInvariantPublishedContent("The Root", rootKey); + + var childKey = Guid.NewGuid(); + var child = SetupInvariantPublishedContent("The Child", childKey, root); + + // yes... actually testing the mock setup here. but it's important! + var publishedUrlProvider = SetupPublishedUrlProvider(hideTopLevelNodeFromPath); + Assert.AreEqual(hideTopLevelNodeFromPath ? "/the-child" : "/the-root/the-child", publishedUrlProvider.GetUrl(child)); + + var builder = new ApiContentRouteBuilder(publishedUrlProvider, CreateGlobalSettings(hideTopLevelNodeFromPath), Mock.Of()); + var result = builder.Build(child); + Assert.AreEqual("/the-child", result.Path); + Assert.AreEqual(rootKey, result.StartItem.Id); + Assert.AreEqual("the-root", result.StartItem.Path); + } + + [TestCase(true)] + [TestCase(false)] + public void CanBuildForGrandchild(bool hideTopLevelNodeFromPath) + { + var rootKey = Guid.NewGuid(); + var root = SetupInvariantPublishedContent("The Root", rootKey); + + var childKey = Guid.NewGuid(); + var child = SetupInvariantPublishedContent("The Child", childKey, root); + + var grandchildKey = Guid.NewGuid(); + var grandchild = SetupInvariantPublishedContent("The Grandchild", grandchildKey, child); + + // yes... actually testing the mock setup here. but it's important! + var publishedUrlProvider = SetupPublishedUrlProvider(hideTopLevelNodeFromPath); + Assert.AreEqual(hideTopLevelNodeFromPath ? "/the-child/the-grandchild" : "/the-root/the-child/the-grandchild", publishedUrlProvider.GetUrl(grandchild)); + + var builder = new ApiContentRouteBuilder(publishedUrlProvider, CreateGlobalSettings(hideTopLevelNodeFromPath), Mock.Of()); + var result = builder.Build(grandchild); + Assert.AreEqual("/the-child/the-grandchild", result.Path); + Assert.AreEqual(rootKey, result.StartItem.Id); + Assert.AreEqual("the-root", result.StartItem.Path); + } + + [Test] + public void CanBuildForCultureVariantRootAndChild() + { + var rootKey = Guid.NewGuid(); + var root = SetupVariantPublishedContent("The Root", rootKey); + + var childKey = Guid.NewGuid(); + var child = SetupVariantPublishedContent("The Child", childKey, root); + + var publishedUrlProvider = SetupPublishedUrlProvider(false); + + var builder = new ApiContentRouteBuilder(publishedUrlProvider, CreateGlobalSettings(false), Mock.Of()); + var result = builder.Build(child, "en-us"); + Assert.AreEqual("/the-child-en-us", result.Path); + Assert.AreEqual(rootKey, result.StartItem.Id); + Assert.AreEqual("the-root-en-us", result.StartItem.Path); + + result = builder.Build(child, "da-dk"); + Assert.AreEqual("/the-child-da-dk", result.Path); + Assert.AreEqual(rootKey, result.StartItem.Id); + Assert.AreEqual("the-root-da-dk", result.StartItem.Path); + } + + [Test] + public void CanBuildForCultureVariantRootAndCultureInvariantChild() + { + var rootKey = Guid.NewGuid(); + var root = SetupVariantPublishedContent("The Root", rootKey); + + var childKey = Guid.NewGuid(); + var child = SetupInvariantPublishedContent("The Child", childKey, root); + + var publishedUrlProvider = SetupPublishedUrlProvider(false); + + var builder = new ApiContentRouteBuilder(publishedUrlProvider, CreateGlobalSettings(false), Mock.Of()); + var result = builder.Build(child, "en-us"); + Assert.AreEqual("/the-child", result.Path); + Assert.AreEqual(rootKey, result.StartItem.Id); + Assert.AreEqual("the-root-en-us", result.StartItem.Path); + + result = builder.Build(child, "da-dk"); + Assert.AreEqual("/the-child", result.Path); + Assert.AreEqual(rootKey, result.StartItem.Id); + Assert.AreEqual("the-root-da-dk", result.StartItem.Path); + } + + [Test] + public void CanBuildForCultureInvariantRootAndCultureVariantChild() + { + var rootKey = Guid.NewGuid(); + var root = SetupInvariantPublishedContent("The Root", rootKey); + + var childKey = Guid.NewGuid(); + var child = SetupVariantPublishedContent("The Child", childKey, root); + + var publishedUrlProvider = SetupPublishedUrlProvider(false); + + var builder = new ApiContentRouteBuilder(publishedUrlProvider, CreateGlobalSettings(false), Mock.Of()); + var result = builder.Build(child, "en-us"); + Assert.AreEqual("/the-child-en-us", result.Path); + Assert.AreEqual(rootKey, result.StartItem.Id); + Assert.AreEqual("the-root", result.StartItem.Path); + + result = builder.Build(child, "da-dk"); + Assert.AreEqual("/the-child-da-dk", result.Path); + Assert.AreEqual(rootKey, result.StartItem.Id); + Assert.AreEqual("the-root", result.StartItem.Path); + } + + [TestCase(PublishedItemType.Media)] + [TestCase(PublishedItemType.Element)] + [TestCase(PublishedItemType.Member)] + [TestCase(PublishedItemType.Unknown)] + public void DoesNotSupportNonContentTypes(PublishedItemType itemType) + { + var content = new Mock(); + content.SetupGet(c => c.ItemType).Returns(itemType); + + var builder = new ApiContentRouteBuilder(SetupPublishedUrlProvider(true), CreateGlobalSettings(), Mock.Of()); + Assert.Throws(() => builder.Build(content.Object)); + } + + private IPublishedContent SetupInvariantPublishedContent(string name, Guid key, IPublishedContent? parent = null) + { + var publishedContentType = CreatePublishedContentType(); + var content = CreatePublishedContentMock(publishedContentType.Object, name, key, parent); + return content.Object; + } + + private IPublishedContent SetupVariantPublishedContent(string name, Guid key, IPublishedContent? parent = null) + { + var publishedContentType = CreatePublishedContentType(); + publishedContentType.SetupGet(m => m.Variations).Returns(ContentVariation.Culture); + var content = CreatePublishedContentMock(publishedContentType.Object, name, key, parent); + var cultures = new[] { "en-us", "da-dk" }; + content + .SetupGet(m => m.Cultures) + .Returns(cultures.ToDictionary( + c => c, + c => new PublishedCultureInfo(c, $"{name}-{c}", DefaultUrlSegment(name, c), DateTime.Now))); + return content.Object; + } + + private Mock CreatePublishedContentMock(IPublishedContentType publishedContentType, string name, Guid key, IPublishedContent? parent) + { + var content = new Mock(); + ConfigurePublishedContentMock(content, key, name, DefaultUrlSegment(name), publishedContentType, Array.Empty()); + content.SetupGet(c => c.Parent).Returns(parent); + content.SetupGet(c => c.Level).Returns((parent?.Level ?? 0) + 1); + return content; + } + + private static Mock CreatePublishedContentType() + { + var publishedContentType = new Mock(); + publishedContentType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Content); + publishedContentType.SetupGet(c => c.Alias).Returns("TheContentType"); + return publishedContentType; + } + + private IPublishedUrlProvider SetupPublishedUrlProvider(bool hideTopLevelNodeFromPath) + { + var variantContextAccessor = Mock.Of(); + string Url(IPublishedContent content, string? culture) + => string.Join("/", content.AncestorsOrSelf().Reverse().Skip(hideTopLevelNodeFromPath ? 1 : 0).Select(c => c.UrlSegment(variantContextAccessor, culture))).EnsureStartsWith("/"); + + var publishedUrlProvider = new Mock(); + publishedUrlProvider + .Setup(p => p.GetUrl(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((IPublishedContent content, UrlMode mode, string? culture, Uri? current) => Url(content, culture)); + return publishedUrlProvider.Object; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs new file mode 100644 index 0000000000..5398e22bc3 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs @@ -0,0 +1,108 @@ +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.DeliveryApi.Accessors; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +public class DeliveryApiTests +{ + protected IPublishedPropertyType DeliveryApiPropertyType { get; private set; } + + protected IPublishedPropertyType DefaultPropertyType { get; private set; } + + [SetUp] + public virtual void Setup() + { + var deliveryApiPropertyValueConverter = new Mock(); + deliveryApiPropertyValueConverter.Setup(p => p.ConvertIntermediateToDeliveryApiObject( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()) + ).Returns("Delivery API value"); + deliveryApiPropertyValueConverter.Setup(p => p.ConvertIntermediateToObject( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()) + ).Returns("Default value"); + deliveryApiPropertyValueConverter.Setup(p => p.IsConverter(It.IsAny())).Returns(true); + deliveryApiPropertyValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + deliveryApiPropertyValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + + DeliveryApiPropertyType = SetupPublishedPropertyType(deliveryApiPropertyValueConverter.Object, "deliveryApi", "Delivery.Api.Editor"); + + var defaultPropertyValueConverter = new Mock(); + defaultPropertyValueConverter.Setup(p => p.ConvertIntermediateToObject( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()) + ).Returns("Default value"); + defaultPropertyValueConverter.Setup(p => p.IsConverter(It.IsAny())).Returns(true); + defaultPropertyValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + + DefaultPropertyType = SetupPublishedPropertyType(defaultPropertyValueConverter.Object, "default", "Default.Editor"); + } + + protected IPublishedPropertyType SetupPublishedPropertyType(IPropertyValueConverter valueConverter, string propertyTypeAlias, string editorAlias) + { + var mockPublishedContentTypeFactory = new Mock(); + mockPublishedContentTypeFactory.Setup(x => x.GetDataType(It.IsAny())) + .Returns(new PublishedDataType(123, editorAlias, new Lazy())); + + var publishedPropType = new PublishedPropertyType( + propertyTypeAlias, + 123, + true, + ContentVariation.Nothing, + new PropertyValueConverterCollection(() => new[] { valueConverter }), + Mock.Of(), + mockPublishedContentTypeFactory.Object); + + return publishedPropType; + } + + protected IOutputExpansionStrategyAccessor CreateOutputExpansionStrategyAccessor() => new NoopOutputExpansionStrategyAccessor(); + + protected IOptions CreateGlobalSettings(bool hideTopLevelNodeFromPath = true) + { + var globalSettings = new GlobalSettings { HideTopLevelNodeFromPath = hideTopLevelNodeFromPath }; + var globalSettingsOptionsMock = new Mock>(); + globalSettingsOptionsMock.SetupGet(s => s.Value).Returns(globalSettings); + return globalSettingsOptionsMock.Object; + } + + protected void ConfigurePublishedContentMock(Mock content, Guid key, string name, string urlSegment, IPublishedContentType contentType, IEnumerable properties) + { + content.SetupGet(c => c.Key).Returns(key); + content.SetupGet(c => c.Name).Returns(name); + content.SetupGet(c => c.UrlSegment).Returns(urlSegment); + content + .SetupGet(m => m.Cultures) + .Returns(new Dictionary() + { + { + string.Empty, + new PublishedCultureInfo(string.Empty, name, urlSegment, DateTime.Now) + } + }); + content.SetupGet(c => c.ContentType).Returns(contentType); + content.SetupGet(c => c.Properties).Returns(properties); + content.SetupGet(c => c.ItemType).Returns(PublishedItemType.Content); + } + + protected string DefaultUrlSegment(string name, string? culture = null) + => $"{name.ToLowerInvariant().Replace(" ", "-")}{(culture.IsNullOrWhiteSpace() ? string.Empty : $"-{culture}")}"; +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ImageCropperValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ImageCropperValueConverterTests.cs new file mode 100644 index 0000000000..0daa6df884 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ImageCropperValueConverterTests.cs @@ -0,0 +1,69 @@ +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class ImageCropperValueConverterTests : PropertyValueConverterTests +{ + [Test] + public void ImageCropperValueConverter_ConvertsValueToImageCropperValue() + { + var publishedDataType = new PublishedDataType(123, "test", new Lazy(() => new ImageCropperConfiguration + { + Crops = new ImageCropperConfiguration.Crop[] + { + new ImageCropperConfiguration.Crop + { + Alias = "one", Width = 200, Height = 100 + } + } + })); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = new ImageCropperValueConverter(Mock.Of>()); + Assert.AreEqual(typeof(ApiImageCropperValue), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType.Object)); + + var serializer = new JsonNetSerializer(); + var source = serializer.Serialize(new ImageCropperValue + { + Src = "/some/file.jpg", + Crops = new[] + { + new ImageCropperValue.ImageCropperCrop + { + Alias = "one", + Coordinates = new ImageCropperValue.ImageCropperCropCoordinates { X1 = 1m, X2 = 2m, Y1 = 10m, Y2 = 20m } + } + }, + FocalPoint = new ImageCropperValue.ImageCropperFocalPoint { Left = .2m, Top = .4m } + } + ); + var inter = valueConverter.ConvertSourceToIntermediate(Mock.Of(), publishedPropertyType.Object, source, false); + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as ApiImageCropperValue; + Assert.NotNull(result); + Assert.AreEqual("/some/file.jpg", result.Url); + Assert.NotNull(result.FocalPoint); + Assert.AreEqual(.2m, result.FocalPoint.Left); + Assert.AreEqual(.4m, result.FocalPoint.Top); + Assert.NotNull(result.Crops); + Assert.AreEqual(1, result.Crops.Count()); + Assert.AreEqual("one", result.Crops.First().Alias); + Assert.AreEqual(100, result.Crops.First().Height); + Assert.AreEqual(200, result.Crops.First().Width); + Assert.NotNull(result.Crops.First().Coordinates); + Assert.AreEqual(1m, result.Crops.First().Coordinates.X1); + Assert.AreEqual(2m, result.Crops.First().Coordinates.X2); + Assert.AreEqual(10m, result.Crops.First().Coordinates.Y1); + Assert.AreEqual(20m, result.Crops.First().Coordinates.Y2); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MarkdownEditorValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MarkdownEditorValueConverterTests.cs new file mode 100644 index 0000000000..1540df68bb --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MarkdownEditorValueConverterTests.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Templates; +using Umbraco.Cms.Core.Web; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class MarkdownEditorValueConverterTests : PropertyValueConverterTests +{ + [TestCase("hello world", "

hello world

")] + [TestCase("hello *world*", "

hello world

")] + [TestCase("", "")] + [TestCase(null, "")] + [TestCase(123, "")] + public void MarkdownEditorValueConverter_ConvertsValueToMarkdownString(object inter, string expected) + { + var linkParser = new HtmlLocalLinkParser(Mock.Of(), Mock.Of()); + var urlParser = new HtmlUrlParser(Mock.Of>(), Mock.Of>(), Mock.Of(), Mock.Of()); + var valueConverter = new MarkdownEditorValueConverter(linkParser, urlParser); + + Assert.AreEqual(typeof(string), valueConverter.GetDeliveryApiPropertyValueType(Mock.Of())); + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), Mock.Of(), PropertyCacheLevel.Element, inter, false); + Assert.AreEqual(expected, result); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaBuilderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaBuilderTests.cs new file mode 100644 index 0000000000..80bc65d2a6 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaBuilderTests.cs @@ -0,0 +1,108 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class MediaBuilderTests : DeliveryApiTests +{ + [Test] + public void MediaBuilder_MapsMediaDataAndDefaultProperties() + { + var key = Guid.NewGuid(); + var media = SetupMedia( + key, + "The media", + "media-url-segment", + new Dictionary + { + { Constants.Conventions.Media.Width, 111 }, + { Constants.Conventions.Media.Height, 222 }, + { Constants.Conventions.Media.Extension, ".my-ext" } + }); + + var builder = new ApiMediaBuilder(new ApiContentNameProvider(), SetupMediaUrlProvider(), CreateOutputExpansionStrategyAccessor()); + var result = builder.Build(media); + Assert.NotNull(result); + Assert.AreEqual("The media", result.Name); + Assert.AreEqual("theMediaType", result.MediaType); + Assert.AreEqual("media-url:media-url-segment", result.Url); + Assert.AreEqual(3, result.Properties.Count); + Assert.AreEqual(111, result.Properties[Constants.Conventions.Media.Width]); + Assert.AreEqual(222, result.Properties[Constants.Conventions.Media.Height]); + Assert.AreEqual(".my-ext", result.Properties[Constants.Conventions.Media.Extension]); + } + + [Test] + public void MediaBuilder_HandlesMissingDefaultProperties() + { + var media = SetupMedia( + Guid.NewGuid(), + "The media", + "media-url-segment", + new Dictionary() + ); + + var builder = new ApiMediaBuilder(new ApiContentNameProvider(), SetupMediaUrlProvider(), CreateOutputExpansionStrategyAccessor()); + var result = builder.Build(media); + Assert.NotNull(result); + Assert.IsEmpty(result.Properties); + } + + [Test] + public void MediaBuilder_IncludesNonDefaultProperties() + { + var media = SetupMedia( + Guid.NewGuid(), + "The media", + "media-url-segment", + new Dictionary { { "myProperty", 123 }, { "anotherProperty", "A value goes here" } }); + + var builder = new ApiMediaBuilder(new ApiContentNameProvider(), SetupMediaUrlProvider(), CreateOutputExpansionStrategyAccessor()); + var result = builder.Build(media); + Assert.NotNull(result); + Assert.AreEqual(2, result.Properties.Count); + Assert.AreEqual(123, result.Properties["myProperty"]); + Assert.AreEqual("A value goes here", result.Properties["anotherProperty"]); + } + + private IPublishedContent SetupMedia(Guid key, string name, string urlSegment, Dictionary properties) + { + var media = new Mock(); + + var mediaType = new Mock(); + mediaType.SetupGet(c => c.Alias).Returns("theMediaType"); + + var mediaProperties = properties.Select(kvp => SetupProperty(kvp.Key, kvp.Value, media.Object)).ToArray(); + + media.SetupGet(c => c.Properties).Returns(mediaProperties); + media.SetupGet(c => c.UrlSegment).Returns(urlSegment); + media.SetupGet(c => c.Name).Returns(name); + media.SetupGet(c => c.Key).Returns(key); + media.SetupGet(c => c.ContentType).Returns(mediaType.Object); + media.SetupGet(c => c.ItemType).Returns(PublishedItemType.Media); + media.Setup(m => m.GetProperty(It.IsAny())).Returns((string alias) => mediaProperties.FirstOrDefault(p => p.Alias == alias)); + + return media.Object; + } + + private IPublishedProperty SetupProperty(string alias, T value, IPublishedContent media) + { + var propertyMock = new Mock(); + propertyMock.SetupGet(p => p.Alias).Returns(alias); + propertyMock.Setup(p => p.GetValue(It.IsAny(), It.IsAny())).Returns(value); + // needed for NoopOutputExpansionStrategyAccessor + propertyMock.Setup(p => p.GetDeliveryApiValue(It.IsAny(), It.IsAny(), It.IsAny())).Returns(value); + return propertyMock.Object; + } + + private IApiMediaUrlProvider SetupMediaUrlProvider() + { + var mock = new Mock(); + mock.Setup(m => m.GetUrl(It.IsAny())).Returns((IPublishedContent media) => $"media-url:{media.UrlSegment}"); + return mock.Object; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerValueConverterTests.cs new file mode 100644 index 0000000000..b629dccdb7 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerValueConverterTests.cs @@ -0,0 +1,102 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class MediaPickerValueConverterTests : PropertyValueConverterTests +{ + [Test] + public void MediaPickerValueConverter_InSingleMode_HasMultipleContentAsDeliveryApiType() + { + var publishedPropertyType = SetupMediaPropertyType(false); + var valueConverter = CreateMediaPickerValueConverter(); + + Assert.AreEqual(typeof(IEnumerable), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType)); + } + + [Test] + public void MediaPickerValueConverter_InSingleMode_ConvertsValueToDeliveryApiContent() + { + var publishedPropertyType = SetupMediaPropertyType(false); + var valueConverter = CreateMediaPickerValueConverter(); + + var inter = new[] {new GuidUdi(Constants.UdiEntityType.MediaType, PublishedMedia.Key)}; + + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType, PropertyCacheLevel.Element, inter, false) as IEnumerable; + + Assert.NotNull(result); + Assert.AreEqual(1, result.Count()); + Assert.AreEqual("The media", result.First().Name); + Assert.AreEqual(PublishedMedia.Key, result.First().Id); + Assert.AreEqual("the-media-url", result.First().Url); + Assert.AreEqual("TheMediaType", result.First().MediaType); + } + + [Test] + public void MediaPickerValueConverter_InMultiMode_HasMultipleContentAsDeliveryApiType() + { + var publishedPropertyType = SetupMediaPropertyType(true); + var valueConverter = CreateMediaPickerValueConverter(); + + Assert.AreEqual(typeof(IEnumerable), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType)); + } + + [Test] + public void MediaPickerValueConverter_InMultiMode_ConvertsValuesToDeliveryApiContent() + { + var publishedPropertyType = SetupMediaPropertyType(true); + var valueConverter = CreateMediaPickerValueConverter(); + + var otherMediaKey = Guid.NewGuid(); + var otherMedia = SetupPublishedContent("The other media", otherMediaKey, PublishedItemType.Media, PublishedMediaType); + PublishedUrlProviderMock + .Setup(p => p.GetMediaUrl(otherMedia.Object, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("the-other-media-url"); + PublishedMediaCacheMock + .Setup(pcc => pcc.GetById(otherMediaKey)) + .Returns(otherMedia.Object); + + var inter = new[] { new GuidUdi(Constants.UdiEntityType.MediaType, PublishedMedia.Key), new GuidUdi(Constants.UdiEntityType.MediaType, otherMediaKey) }; + + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType, PropertyCacheLevel.Element, inter, false) as IEnumerable; + + Assert.NotNull(result); + Assert.AreEqual(2, result.Count()); + + Assert.AreEqual("The media", result.First().Name); + Assert.AreEqual(PublishedMedia.Key, result.First().Id); + Assert.AreEqual("the-media-url", result.First().Url); + Assert.AreEqual("TheMediaType", result.First().MediaType); + + Assert.AreEqual("The other media", result.Last().Name); + Assert.AreEqual(otherMediaKey, result.Last().Id); + Assert.AreEqual("the-other-media-url", result.Last().Url); + Assert.AreEqual("TheMediaType", result.Last().MediaType); + } + + private IPublishedPropertyType SetupMediaPropertyType(bool multiSelect) + { + var publishedDataType = new PublishedDataType(123, "test", new Lazy(() => + new MediaPickerConfiguration {Multiple = multiSelect} + )); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + return publishedPropertyType.Object; + } + + private MediaPickerValueConverter CreateMediaPickerValueConverter() => new( + PublishedSnapshotAccessor, + Mock.Of(), + new ApiMediaBuilder( + new ApiContentNameProvider(), + new ApiMediaUrlProvider(PublishedUrlProvider), + CreateOutputExpansionStrategyAccessor())); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs new file mode 100644 index 0000000000..957075092e --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs @@ -0,0 +1,275 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.DeliveryApi.Accessors; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class MediaPickerWithCropsValueConverterTests : PropertyValueConverterTests +{ + private MediaPickerWithCropsValueConverter MediaPickerWithCropsValueConverter() + { + var serializer = new JsonNetSerializer(); + var publishedValueFallback = Mock.Of(); + var apiUrlProvider = new ApiMediaUrlProvider(PublishedUrlProvider); + return new MediaPickerWithCropsValueConverter( + PublishedSnapshotAccessor, + PublishedUrlProvider, + publishedValueFallback, + serializer, + new ApiMediaBuilder( + new ApiContentNameProvider(), + apiUrlProvider, + CreateOutputExpansionStrategyAccessor())); + } + + [Test] + public void MediaPickerWithCropsValueConverter_InSingleMode_ConvertsValueToCollectionOfApiMedia() + { + var publishedPropertyType = SetupMediaPropertyType(false); + var mediaKey = SetupMedia("My media", ".jpg", 200, 400, "My alt text"); + + var serializer = new JsonNetSerializer(); + + var valueConverter = MediaPickerWithCropsValueConverter(); + Assert.AreEqual(typeof(IEnumerable), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType)); + + var inter = serializer.Serialize(new[] + { + new MediaPicker3PropertyEditor.MediaPicker3PropertyValueEditor.MediaWithCropsDto + { + Key = Guid.NewGuid(), + MediaKey = mediaKey, + Crops = new [] + { + new ImageCropperValue.ImageCropperCrop + { + Alias = "one", + Coordinates = new ImageCropperValue.ImageCropperCropCoordinates { X1 = 1m, X2 = 2m, Y1 = 10m, Y2 = 20m } + } + }, + FocalPoint = new ImageCropperValue.ImageCropperFocalPoint { Left = .2m, Top = .4m } + } + }); + + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(1, result.Count()); + Assert.AreEqual("My media", result.First().Name); + Assert.AreEqual("my-media", result.First().Url); + Assert.NotNull(result.First().FocalPoint); + Assert.AreEqual(.2m, result.First().FocalPoint.Left); + Assert.AreEqual(.4m, result.First().FocalPoint.Top); + Assert.NotNull(result.First().Crops); + Assert.AreEqual(1, result.First().Crops.Count()); + Assert.AreEqual("one", result.First().Crops.First().Alias); + Assert.AreEqual(100, result.First().Crops.First().Height); + Assert.AreEqual(200, result.First().Crops.First().Width); + Assert.NotNull(result.First().Crops.First().Coordinates); + Assert.AreEqual(1m, result.First().Crops.First().Coordinates.X1); + Assert.AreEqual(2m, result.First().Crops.First().Coordinates.X2); + Assert.AreEqual(10m, result.First().Crops.First().Coordinates.Y1); + Assert.AreEqual(20m, result.First().Crops.First().Coordinates.Y2); + Assert.NotNull(result.First().Properties); + Assert.AreEqual(4, result.First().Properties.Count); + Assert.AreEqual("My alt text", result.First().Properties["altText"]); + Assert.AreEqual(".jpg", result.First().Properties[Constants.Conventions.Media.Extension]); + Assert.AreEqual(200, result.First().Properties[Constants.Conventions.Media.Width]); + Assert.AreEqual(400, result.First().Properties[Constants.Conventions.Media.Height]); + } + + [Test] + public void MediaPickerWithCropsValueConverter_InMultiMode_ConvertsValueToMedias() + { + var publishedPropertyType = SetupMediaPropertyType(true); + var mediaKey1 = SetupMedia("My media", ".jpg", 200, 400, "My alt text"); + var mediaKey2 = SetupMedia("My other media", ".png", 800, 600, "My other alt text"); + + var serializer = new JsonNetSerializer(); + + var valueConverter = MediaPickerWithCropsValueConverter(); + Assert.AreEqual(typeof(IEnumerable), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType)); + + var inter = serializer.Serialize(new[] + { + new MediaPicker3PropertyEditor.MediaPicker3PropertyValueEditor.MediaWithCropsDto + { + Key = Guid.NewGuid(), + MediaKey = mediaKey1, + Crops = new [] + { + new ImageCropperValue.ImageCropperCrop + { + Alias = "one", + Coordinates = new ImageCropperValue.ImageCropperCropCoordinates { X1 = 1m, X2 = 2m, Y1 = 10m, Y2 = 20m } + } + }, + FocalPoint = new ImageCropperValue.ImageCropperFocalPoint { Left = .2m, Top = .4m } + }, + new MediaPicker3PropertyEditor.MediaPicker3PropertyValueEditor.MediaWithCropsDto + { + Key = Guid.NewGuid(), + MediaKey = mediaKey2, + Crops = new [] + { + new ImageCropperValue.ImageCropperCrop + { + Alias = "one", + Coordinates = new ImageCropperValue.ImageCropperCropCoordinates { X1 = 40m, X2 = 20m, Y1 = 2m, Y2 = 1m } + } + }, + FocalPoint = new ImageCropperValue.ImageCropperFocalPoint { Left = .8m, Top = .6m } + } + }); + + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(2, result.Count()); + + Assert.AreEqual("My media", result.First().Name); + Assert.AreEqual("my-media", result.First().Url); + Assert.NotNull(result.First().FocalPoint); + Assert.AreEqual(.2m, result.First().FocalPoint.Left); + Assert.AreEqual(.4m, result.First().FocalPoint.Top); + Assert.NotNull(result.First().Crops); + Assert.AreEqual(1, result.First().Crops.Count()); + Assert.AreEqual("one", result.First().Crops.First().Alias); + Assert.AreEqual(100, result.First().Crops.First().Height); + Assert.AreEqual(200, result.First().Crops.First().Width); + Assert.NotNull(result.First().Crops.First().Coordinates); + Assert.AreEqual(1m, result.First().Crops.First().Coordinates.X1); + Assert.AreEqual(2m, result.First().Crops.First().Coordinates.X2); + Assert.AreEqual(10m, result.First().Crops.First().Coordinates.Y1); + Assert.AreEqual(20m, result.First().Crops.First().Coordinates.Y2); + Assert.NotNull(result.First().Properties); + Assert.AreEqual(4, result.First().Properties.Count); + Assert.AreEqual("My alt text", result.First().Properties["altText"]); + Assert.AreEqual(".jpg", result.First().Properties[Constants.Conventions.Media.Extension]); + Assert.AreEqual(200, result.First().Properties[Constants.Conventions.Media.Width]); + Assert.AreEqual(400, result.First().Properties[Constants.Conventions.Media.Height]); + + Assert.AreEqual("My other media", result.Last().Name); + Assert.AreEqual("my-other-media", result.Last().Url); + Assert.NotNull(result.Last().FocalPoint); + Assert.AreEqual(.8m, result.Last().FocalPoint.Left); + Assert.AreEqual(.6m, result.Last().FocalPoint.Top); + Assert.NotNull(result.Last().Crops); + Assert.AreEqual(1, result.Last().Crops.Count()); + Assert.AreEqual("one", result.Last().Crops.Last().Alias); + Assert.AreEqual(100, result.Last().Crops.Last().Height); + Assert.AreEqual(200, result.Last().Crops.Last().Width); + Assert.NotNull(result.Last().Crops.Last().Coordinates); + Assert.AreEqual(40m, result.Last().Crops.Last().Coordinates.X1); + Assert.AreEqual(20m, result.Last().Crops.Last().Coordinates.X2); + Assert.AreEqual(2m, result.Last().Crops.Last().Coordinates.Y1); + Assert.AreEqual(1m, result.Last().Crops.Last().Coordinates.Y2); + Assert.NotNull(result.Last().Properties); + Assert.AreEqual(4, result.Last().Properties.Count); + Assert.AreEqual("My other alt text", result.Last().Properties["altText"]); + Assert.AreEqual(".png", result.Last().Properties[Constants.Conventions.Media.Extension]); + Assert.AreEqual(800, result.Last().Properties[Constants.Conventions.Media.Width]); + Assert.AreEqual(600, result.Last().Properties[Constants.Conventions.Media.Height]); + } + + [TestCase("")] + [TestCase(null)] + [TestCase(123)] + [TestCase("123")] + public void MediaPickerWithCropsValueConverter_InSingleMode_ConvertsInvalidValueToEmptyCollection(object inter) + { + var publishedPropertyType = SetupMediaPropertyType(false); + + var serializer = new JsonNetSerializer(); + + var valueConverter = MediaPickerWithCropsValueConverter(); + + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.IsEmpty(result); + } + + [TestCase("")] + [TestCase(null)] + [TestCase(123)] + [TestCase("123")] + public void MediaPickerWithCropsValueConverter_InMultiMode_ConvertsInvalidValueToEmptyCollection(object inter) + { + var publishedPropertyType = SetupMediaPropertyType(true); + + var serializer = new JsonNetSerializer(); + + var valueConverter = MediaPickerWithCropsValueConverter(); + + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.IsEmpty(result); + } + + + private IPublishedPropertyType SetupMediaPropertyType(bool multiSelect) + { + var publishedDataType = new PublishedDataType(123, "test", new Lazy(() => new MediaPicker3Configuration + { + Multiple = multiSelect, + EnableLocalFocalPoint = true, + Crops = new MediaPicker3Configuration.CropConfiguration[] + { + new MediaPicker3Configuration.CropConfiguration + { + Alias = "one", Width = 200, Height = 100 + } + } + })); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + return publishedPropertyType.Object; + } + + private Guid SetupMedia(string name, string extension, int width, int height, string altText) + { + var publishedMediaType = new Mock(); + publishedMediaType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Media); + + var mediaKey = Guid.NewGuid(); + var media = SetupPublishedContent(name, mediaKey, PublishedItemType.Media, publishedMediaType.Object); + var mediaProperties = new List(); + media.SetupGet(m => m.Properties).Returns(mediaProperties); + + void AddProperty(string alias, object value) + { + var property = new Mock(); + property.SetupGet(p => p.Alias).Returns(alias); + property.Setup(p => p.HasValue(It.IsAny(), It.IsAny())).Returns(true); + property.Setup(p => p.GetValue(It.IsAny(), It.IsAny())).Returns(value); + property.Setup(p => p.GetDeliveryApiValue(It.IsAny(), It.IsAny(), It.IsAny())).Returns(value); + media.Setup(m => m.GetProperty(alias)).Returns(property.Object); + mediaProperties.Add(property.Object); + } + + AddProperty(Constants.Conventions.Media.Extension, extension); + AddProperty(Constants.Conventions.Media.Width, width); + AddProperty(Constants.Conventions.Media.Height, height); + AddProperty("altText", altText); + + PublishedMediaCacheMock + .Setup(pcc => pcc.GetById(mediaKey)) + .Returns(media.Object); + PublishedMediaCacheMock + .Setup(pcc => pcc.GetById(It.IsAny(), mediaKey)) + .Returns(media.Object); + + PublishedUrlProviderMock + .Setup(p => p.GetMediaUrl(media.Object, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(name.ToLowerInvariant().Replace(" ", "-")); + + return mediaKey; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiNodeTreePickerValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiNodeTreePickerValueConverterTests.cs new file mode 100644 index 0000000000..bb7fdb8b75 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiNodeTreePickerValueConverterTests.cs @@ -0,0 +1,277 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class MultiNodeTreePickerValueConverterTests : PropertyValueConverterTests +{ + private MultiNodeTreePickerValueConverter MultiNodeTreePickerValueConverter() + { + var expansionStrategyAccessor = CreateOutputExpansionStrategyAccessor(); + + var contentNameProvider = new ApiContentNameProvider(); + var apiUrProvider = new ApiMediaUrlProvider(PublishedUrlProvider); + var routeBuilder = new ApiContentRouteBuilder(PublishedUrlProvider, CreateGlobalSettings(), Mock.Of()); + return new MultiNodeTreePickerValueConverter( + PublishedSnapshotAccessor, + Mock.Of(), + Mock.Of(), + new ApiContentBuilder(contentNameProvider, routeBuilder, expansionStrategyAccessor), + new ApiMediaBuilder(contentNameProvider, apiUrProvider, expansionStrategyAccessor)); + } + + private PublishedDataType MultiNodePickerPublishedDataType(bool multiSelect, string entityType) => + new PublishedDataType(123, "test", new Lazy(() => new MultiNodePickerConfiguration + { + MaxNumber = multiSelect ? 10 : 1, + TreeSource = new MultiNodePickerConfigurationTreeSource + { + ObjectType = entityType + } + })); + + [Test] + public void MultiNodeTreePickerValueConverter_InSingleMode_ConvertsValueToListOfContent() + { + var publishedDataType = MultiNodePickerPublishedDataType(false, Constants.UdiEntityType.Document); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiNodeTreePickerValueConverter(); + + Assert.AreEqual(typeof(IEnumerable), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType.Object)); + + var inter = new Udi[] { new GuidUdi(Constants.UdiEntityType.Document, PublishedContent.Key) }; + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(1, result.Count()); + Assert.AreEqual(PublishedContent.Name, result.First().Name); + Assert.AreEqual(PublishedContent.Key, result.First().Id); + Assert.AreEqual("/the-page-url", result.First().Route.Path); + Assert.AreEqual("TheContentType", result.First().ContentType); + Assert.IsEmpty(result.First().Properties); + } + + [Test] + public void MultiNodeTreePickerValueConverter_InMultiMode_ConvertsValueToListOfContent() + { + var publishedDataType = MultiNodePickerPublishedDataType(true, Constants.UdiEntityType.Document); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var otherContentKey = Guid.NewGuid(); + var otherContent = SetupPublishedContent("The other page", otherContentKey, PublishedItemType.Content, PublishedContentType); + RegisterContentWithProviders(otherContent.Object); + + var valueConverter = MultiNodeTreePickerValueConverter(); + + Assert.AreEqual(typeof(IEnumerable), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType.Object)); + + var inter = new Udi[] { new GuidUdi(Constants.UdiEntityType.Document, PublishedContent.Key), new GuidUdi(Constants.UdiEntityType.Document, otherContentKey) }; + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(2, result.Count()); + + Assert.AreEqual(PublishedContent.Name, result.First().Name); + Assert.AreEqual(PublishedContent.Key, result.First().Id); + Assert.AreEqual("/the-page-url", result.First().Route.Path); + Assert.AreEqual("TheContentType", result.First().ContentType); + + Assert.AreEqual("The other page", result.Last().Name); + Assert.AreEqual(otherContentKey, result.Last().Id); + Assert.AreEqual("TheContentType", result.Last().ContentType); + } + + [Test] + public void MultiNodeTreePickerValueConverter_RendersContentProperties() + { + var content = new Mock(); + + var prop1 = new PublishedElementPropertyBase(DeliveryApiPropertyType, content.Object, false, PropertyCacheLevel.None); + var prop2 = new PublishedElementPropertyBase(DefaultPropertyType, content.Object, false, PropertyCacheLevel.None); + + var key = Guid.NewGuid(); + var urlSegment = "page-url-segment"; + var name = "The page"; + ConfigurePublishedContentMock(content, key, name, urlSegment, PublishedContentType, new[] { prop1, prop2 }); + + PublishedUrlProviderMock + .Setup(p => p.GetUrl(content.Object, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(content.Object.UrlSegment); + PublishedContentCacheMock + .Setup(pcc => pcc.GetById(key)) + .Returns(content.Object); + + var publishedDataType = MultiNodePickerPublishedDataType(false, Constants.UdiEntityType.Document); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiNodeTreePickerValueConverter(); + + Assert.AreEqual(typeof(IEnumerable), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType.Object)); + + var inter = new Udi[] { new GuidUdi(Constants.UdiEntityType.Document, key) }; + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(1, result.Count()); + Assert.AreEqual("The page", result.First().Name); + Assert.AreEqual(key, result.First().Id); + Assert.AreEqual("/page-url-segment", result.First().Route.Path); + Assert.AreEqual("TheContentType", result.First().ContentType); + Assert.AreEqual(2, result.First().Properties.Count); + Assert.AreEqual("Delivery API value", result.First().Properties[DeliveryApiPropertyType.Alias]); + Assert.AreEqual("Default value", result.First().Properties[DefaultPropertyType.Alias]); + } + + [Test] + public void MultiNodeTreePickerValueConverter_InSingleMediaMode_ConvertsValueToListOfMedia() + { + var publishedDataType = MultiNodePickerPublishedDataType(false, Constants.UdiEntityType.Media); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiNodeTreePickerValueConverter(); + + Assert.AreEqual(typeof(IEnumerable), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType.Object)); + + var inter = new Udi[] { new GuidUdi(Constants.UdiEntityType.Media, PublishedMedia.Key) }; + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(1, result.Count()); + Assert.AreEqual(PublishedMedia.Name, result.First().Name); + Assert.AreEqual(PublishedMedia.Key, result.First().Id); + Assert.AreEqual("the-media-url", result.First().Url); + Assert.AreEqual("TheMediaType", result.First().MediaType); + } + + [Test] + public void MultiNodeTreePickerValueConverter_InMultiMediaMode_ConvertsValueToListOfMedia() + { + var publishedDataType = MultiNodePickerPublishedDataType(true, Constants.UdiEntityType.Media); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var otherMediaKey = Guid.NewGuid(); + var otherMedia = SetupPublishedContent("The other media", otherMediaKey, PublishedItemType.Media, PublishedMediaType); + RegisterMediaWithProviders(otherMedia.Object); + + var valueConverter = MultiNodeTreePickerValueConverter(); + + Assert.AreEqual(typeof(IEnumerable), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType.Object)); + + var inter = new Udi[] { new GuidUdi(Constants.UdiEntityType.Media, PublishedMedia.Key), new GuidUdi(Constants.UdiEntityType.Media, otherMediaKey) }; + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(2, result.Count()); + Assert.AreEqual(PublishedMedia.Name, result.First().Name); + Assert.AreEqual(PublishedMedia.Key, result.First().Id); + Assert.AreEqual("the-media-url", result.First().Url); + Assert.AreEqual("TheMediaType", result.First().MediaType); + + Assert.AreEqual("The other media", result.Last().Name); + Assert.AreEqual(otherMediaKey, result.Last().Id); + Assert.AreEqual("TheMediaType", result.Last().MediaType); + } + + [Test] + public void MultiNodeTreePickerValueConverter_InMultiMode_WithMixedEntityTypes_OnlyConvertsConfiguredEntityType() + { + var publishedDataType = MultiNodePickerPublishedDataType(true, Constants.UdiEntityType.Document); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiNodeTreePickerValueConverter(); + + Assert.AreEqual(typeof(IEnumerable), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType.Object)); + + var inter = new Udi[] { new GuidUdi(Constants.UdiEntityType.Media, PublishedMedia.Key), new GuidUdi(Constants.UdiEntityType.Document, PublishedContent.Key) }; + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(1, result.Count()); + Assert.AreEqual(PublishedContent.Name, result.First().Name); + Assert.AreEqual(PublishedContent.Key, result.First().Id); + Assert.AreEqual("/the-page-url", result.First().Route.Path); + Assert.AreEqual("TheContentType", result.First().ContentType); + } + + [Test] + public void MultiNodeTreePickerValueConverter_InMultiMediaMode_WithMixedEntityTypes_OnlyConvertsConfiguredEntityType() + { + var publishedDataType = MultiNodePickerPublishedDataType(true, Constants.UdiEntityType.Media); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiNodeTreePickerValueConverter(); + + Assert.AreEqual(typeof(IEnumerable), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType.Object)); + + var inter = new Udi[] { new GuidUdi(Constants.UdiEntityType.Media, PublishedMedia.Key), new GuidUdi(Constants.UdiEntityType.Document, PublishedContent.Key) }; + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(1, result.Count()); + Assert.AreEqual(PublishedMedia.Name, result.First().Name); + Assert.AreEqual(PublishedMedia.Key, result.First().Id); + Assert.AreEqual("the-media-url", result.First().Url); + Assert.AreEqual("TheMediaType", result.First().MediaType); + } + + [TestCase(true)] + [TestCase(false)] + public void MultiNodeTreePickerValueConverter_InMemberMode_IsUnsupported(bool multiSelect) + { + var publishedDataType = MultiNodePickerPublishedDataType(multiSelect, Constants.UdiEntityType.Member); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiNodeTreePickerValueConverter(); + + Assert.AreEqual(typeof(string), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType.Object)); + + var inter = new Udi[] { new GuidUdi(Constants.UdiEntityType.Media, PublishedMedia.Key), new GuidUdi(Constants.UdiEntityType.Document, PublishedContent.Key) }; + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as string; + Assert.NotNull(result); + Assert.AreEqual("(unsupported)", result); + } + + [TestCase(123)] + [TestCase("123")] + [TestCase(null)] + public void MultiNodeTreePickerValueConverter_InSingleMode_ConvertsInvalidValueToEmptyArray(object? inter) + { + var publishedDataType = MultiNodePickerPublishedDataType(false, Constants.UdiEntityType.Document); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiNodeTreePickerValueConverter(); + + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.IsEmpty(result); + } + + [TestCase(123)] + [TestCase("123")] + [TestCase(null)] + public void MultiNodeTreePickerValueConverter_InMultiMode_ConvertsInvalidValueToEmptyArray(object? inter) + { + var publishedDataType = MultiNodePickerPublishedDataType(true, Constants.UdiEntityType.Document); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiNodeTreePickerValueConverter(); + + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.IsEmpty(result); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiUrlPickerValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiUrlPickerValueConverterTests.cs new file mode 100644 index 0000000000..0210a2cd3a --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MultiUrlPickerValueConverterTests.cs @@ -0,0 +1,275 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class MultiUrlPickerValueConverterTests : PropertyValueConverterTests +{ + [Test] + public void MultiUrlPickerValueConverter_InSingleMode_ConvertsContentToLinksWithContentInfo() + { + var publishedDataType = new PublishedDataType(123, "test", new Lazy(() => new MultiUrlPickerConfiguration { MaxNumber = 1 })); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiUrlPickerValueConverter(); + Assert.AreEqual(typeof(IEnumerable), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType.Object)); + + var inter = Serializer().Serialize(new[] + { + new MultiUrlPickerValueEditor.LinkDto + { + Udi = new GuidUdi(Constants.UdiEntityType.Document, PublishedContent.Key) + } + }); + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(1, result.Count()); + var link = result.First(); + Assert.AreEqual(PublishedContent.Name, link.Title); + Assert.AreEqual(LinkType.Content, link.LinkType); + Assert.AreEqual(PublishedContent.Key, link.DestinationId); + Assert.AreEqual("TheContentType", link.DestinationType); + Assert.Null(link.Url); + Assert.Null(link.Target); + var route = link.Route; + Assert.NotNull(route); + Assert.AreEqual("/the-page-url", route.Path); + } + + [Test] + public void MultiUrlPickerValueConverter_InSingleMode_ConvertsMediaToLinksWithoutContentInfo() + { + var publishedDataType = new PublishedDataType(123, "test", new Lazy(() => new MultiUrlPickerConfiguration { MaxNumber = 1 })); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiUrlPickerValueConverter(); + Assert.AreEqual(typeof(IEnumerable), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType.Object)); + + var inter = Serializer().Serialize(new[] + { + new MultiUrlPickerValueEditor.LinkDto + { + Udi = new GuidUdi(Constants.UdiEntityType.Media, PublishedMedia.Key) + } + }); + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(1, result.Count()); + var link = result.First(); + Assert.AreEqual(PublishedMedia.Name, link.Title); + Assert.AreEqual(PublishedMedia.Key, link.DestinationId); + Assert.AreEqual("TheMediaType", link.DestinationType); + Assert.AreEqual("the-media-url", link.Url); + Assert.AreEqual(LinkType.Media, link.LinkType); + Assert.AreEqual(null, link.Target); + Assert.AreEqual(null, link.Route); + } + + [Test] + public void MultiUrlPickerValueConverter_InMultiMode_CanHandleMixedLinkTypes() + { + var publishedDataType = new PublishedDataType(123, "test", new Lazy(() => new MultiUrlPickerConfiguration { MaxNumber = 10 })); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiUrlPickerValueConverter(); + Assert.AreEqual(typeof(IEnumerable), valueConverter.GetDeliveryApiPropertyValueType(publishedPropertyType.Object)); + + var inter = Serializer().Serialize(new[] + { + new MultiUrlPickerValueEditor.LinkDto + { + Udi = new GuidUdi(Constants.UdiEntityType.Document, PublishedContent.Key) + }, + new MultiUrlPickerValueEditor.LinkDto + { + Udi = new GuidUdi(Constants.UdiEntityType.Media, PublishedMedia.Key) + }, + new MultiUrlPickerValueEditor.LinkDto + { + Name = "The link", + QueryString = "?something=true", + Target = "_blank", + Url = "https://umbraco.com/" + } + }); + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(3, result.Count()); + + var first = result.First(); + var second = result.Skip(1).First(); + var last = result.Last(); + + Assert.AreEqual(PublishedContent.Name, first.Title); + Assert.AreEqual(LinkType.Content, first.LinkType); + Assert.AreEqual(PublishedContent.Key, first.DestinationId); + Assert.NotNull(first.Route); + + Assert.AreEqual(PublishedMedia.Name, second.Title); + Assert.AreEqual(PublishedMedia.Key, second.DestinationId); + Assert.AreEqual("TheMediaType", second.DestinationType); + Assert.Null(second.Route); + + Assert.AreEqual("The link", last.Title); + Assert.AreEqual("https://umbraco.com/?something=true", last.Url); + Assert.AreEqual(LinkType.External, last.LinkType); + Assert.AreEqual("_blank", last.Target); + Assert.Null(last.Route); + } + + [Test] + public void MultiUrlPickerValueConverter_ConvertsExternalUrlToLinks() + { + var publishedDataType = new PublishedDataType(123, "test", new Lazy(() => new MultiUrlPickerConfiguration { MaxNumber = 1 })); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiUrlPickerValueConverter(); + + var inter = Serializer().Serialize(new[] + { + new MultiUrlPickerValueEditor.LinkDto + { + Name = "The link", + QueryString = "?something=true", + Target = "_blank", + Url = "https://umbraco.com/" + } + }); + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(1, result.Count()); + var link = result.First(); + Assert.AreEqual("The link", link.Title); + Assert.AreEqual("https://umbraco.com/?something=true", link.Url); + Assert.AreEqual(LinkType.External, link.LinkType); + Assert.AreEqual("_blank", link.Target); + Assert.Null(link.Route); + } + + [Test] + public void MultiUrlPickerValueConverter_AppliesExplicitConfigurationToContentLink() + { + var publishedDataType = new PublishedDataType(123, "test", new Lazy(() => new MultiUrlPickerConfiguration { MaxNumber = 1 })); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiUrlPickerValueConverter(); + + var inter = Serializer().Serialize(new[] + { + new MultiUrlPickerValueEditor.LinkDto + { + Udi = new GuidUdi(Constants.UdiEntityType.Document, PublishedContent.Key), + Name = "Custom link name", + QueryString = "?something=true", + Target = "_blank" + } + }); + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(1, result.Count()); + var link = result.First(); + Assert.AreEqual("Custom link name", link.Title); + Assert.AreEqual(PublishedContent.Key, link.DestinationId); + Assert.AreEqual("/the-page-url", link.Route!.Path); + Assert.AreEqual(LinkType.Content, link.LinkType); + Assert.AreEqual("_blank", link.Target); + Assert.Null(link.Url); + } + + [Test] + public void MultiUrlPickerValueConverter_PrioritizesContentUrlOverConfiguredUrl() + { + var publishedDataType = new PublishedDataType(123, "test", new Lazy(() => new MultiUrlPickerConfiguration { MaxNumber = 1 })); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiUrlPickerValueConverter(); + + var inter = Serializer().Serialize(new[] + { + new MultiUrlPickerValueEditor.LinkDto + { + Udi = new GuidUdi(Constants.UdiEntityType.Document, PublishedContent.Key), + Url = "https://umbraco.com/", + QueryString = "?something=true" + } + }); + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.AreEqual(1, result.Count()); + var link = result.First(); + Assert.AreEqual(PublishedContent.Name, link.Title); + Assert.AreEqual(PublishedContent.Key, link.DestinationId); + Assert.AreEqual("/the-page-url", link.Route!.Path); + Assert.AreEqual(LinkType.Content, link.LinkType); + Assert.Null(link.Target); + Assert.Null(link.Url); + } + + [TestCase(123)] + [TestCase("123")] + [TestCase(null)] + public void MultiUrlPickerValueConverter_InSingleMode_ConvertsInvalidValueToEmptyArray(object? inter) + { + var publishedDataType = new PublishedDataType(123, "test", new Lazy(() => new MultiUrlPickerConfiguration { MaxNumber = 1 })); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiUrlPickerValueConverter(); + + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.IsEmpty(result); + } + + [TestCase(123)] + [TestCase("123")] + [TestCase(null)] + public void MultiUrlPickerValueConverter_InMultiMode_ConvertsInvalidValueToEmptyArray(object? inter) + { + var publishedDataType = new PublishedDataType(123, "test", new Lazy(() => new MultiUrlPickerConfiguration { MaxNumber = 10 })); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + + var valueConverter = MultiUrlPickerValueConverter(); + + var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), publishedPropertyType.Object, PropertyCacheLevel.Element, inter, false) as IEnumerable; + Assert.NotNull(result); + Assert.IsEmpty(result); + } + + private IApiMediaUrlProvider ApiMediaUrlProvider() => new ApiMediaUrlProvider(PublishedUrlProvider); + + private MultiUrlPickerValueConverter MultiUrlPickerValueConverter() + { + var routeBuilder = new ApiContentRouteBuilder(PublishedUrlProvider, CreateGlobalSettings(), Mock.Of()); + return new MultiUrlPickerValueConverter( + PublishedSnapshotAccessor, + Mock.Of(), + Serializer(), + Mock.Of(), + PublishedUrlProvider, + new ApiContentNameProvider(), + ApiMediaUrlProvider(), + routeBuilder); + } + + private IJsonSerializer Serializer() => new JsonNetSerializer(); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/NestedContentValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/NestedContentValueConverterTests.cs new file mode 100644 index 0000000000..a6c6f91997 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/NestedContentValueConverterTests.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class NestedContentValueConverterTests : PropertyValueConverterTests +{ + private IPublishedModelFactory _publishedModelFactory; + private IApiElementBuilder _apiElementBuilder; + private NestedContentSingleValueConverter _nestedContentSingleValueConverter; + private NestedContentManyValueConverter _nestedContentManyValueConverter; + private IPublishedPropertyType _publishedPropertyType; + + [SetUp] + public void SetupThis() + { + var publishedModelFactoryMock = new Mock(); + publishedModelFactoryMock + .Setup(m => m.CreateModel(It.IsAny())) + .Returns((IPublishedElement element) => element); + _publishedModelFactory = publishedModelFactoryMock.Object; + + _apiElementBuilder = new ApiElementBuilder(CreateOutputExpansionStrategyAccessor()); + + var profilingLogger = new ProfilingLogger(Mock.Of>(), Mock.Of()); + + var publishedDataType = new PublishedDataType(123, "test", new Lazy(() => new NestedContentConfiguration { MaxItems = 1 })); + var publishedPropertyType = new Mock(); + publishedPropertyType.SetupGet(p => p.DataType).Returns(publishedDataType); + publishedPropertyType.SetupGet(p => p.Alias).Returns("prop1"); + publishedPropertyType.SetupGet(p => p.CacheLevel).Returns(PropertyCacheLevel.Element); + publishedPropertyType.SetupGet(p => p.DeliveryApiCacheLevel).Returns(PropertyCacheLevel.Element); + publishedPropertyType + .Setup(p => p.ConvertSourceToInter(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((IPublishedElement owner, object? source, bool preview) => source); + publishedPropertyType + .Setup(p => p.ConvertInterToDeliveryApiObject(It.IsAny(), PropertyCacheLevel.Element, It.IsAny(), It.IsAny())) + .Returns((IPublishedElement owner, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) => inter?.ToString()); + _publishedPropertyType = publishedPropertyType.Object; + + var publishedContentType = new Mock(); + publishedContentType.SetupGet(c => c.IsElement).Returns(true); + publishedContentType.SetupGet(c => c.Alias).Returns("contentType1"); + publishedContentType.SetupGet(c => c.PropertyTypes).Returns(new[] { publishedPropertyType.Object }); + + PublishedContentCacheMock + .Setup(m => m.GetContentType("contentType1")) + .Returns(publishedContentType.Object); + + _nestedContentSingleValueConverter = new NestedContentSingleValueConverter(PublishedSnapshotAccessor, _publishedModelFactory, profilingLogger, _apiElementBuilder); + _nestedContentManyValueConverter = new NestedContentManyValueConverter(PublishedSnapshotAccessor, _publishedModelFactory, profilingLogger, _apiElementBuilder); + } + + [Test] + public void NestedContentSingleValueConverter_HasMultipleElementsAsDeliveryApiType() + => Assert.AreEqual(typeof(IEnumerable), _nestedContentSingleValueConverter.GetDeliveryApiPropertyValueType(Mock.Of())); + + [Test] + public void NestedContentSingleValueConverter_WithOneItem_ConvertsItemToListOfElements() + { + var nestedContentValue = "[{\"ncContentTypeAlias\": \"contentType1\",\"key\": \"1E68FB92-727A-4473-B10C-FA108ADCF16F\",\"prop1\": \"Hello, world\"}]"; + var result = _nestedContentSingleValueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), _publishedPropertyType, PropertyCacheLevel.Element, nestedContentValue, false) as IEnumerable; + + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Count()); + Assert.AreEqual("contentType1", result.First().ContentType); + Assert.AreEqual(Guid.Parse("1E68FB92-727A-4473-B10C-FA108ADCF16F"), result.First().Id); + Assert.AreEqual(1, result.First().Properties.Count); + Assert.AreEqual("Hello, world", result.First().Properties["prop1"]); + } + + [Test] + public void NestedContentSingleValueConverter_WithMultipleItems_ConvertsFirstItemToListOfElements() + { + var nestedContentValue = "[{\"ncContentTypeAlias\": \"contentType1\",\"key\": \"1E68FB92-727A-4473-B10C-FA108ADCF16F\",\"prop1\": \"Hello, world\"},{\"ncContentTypeAlias\": \"contentType1\",\"key\": \"40F59DD9-7E9F-4053-BD32-89FB086D18C9\",\"prop1\": \"One more\"}]"; + var result = _nestedContentSingleValueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), _publishedPropertyType, PropertyCacheLevel.Element, nestedContentValue, false) as IEnumerable; + + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Count()); + Assert.AreEqual("contentType1", result.First().ContentType); + Assert.AreEqual(Guid.Parse("1E68FB92-727A-4473-B10C-FA108ADCF16F"), result.First().Id); + Assert.AreEqual(1, result.First().Properties.Count); + Assert.AreEqual("Hello, world", result.First().Properties["prop1"]); + } + + [Test] + public void NestedContentSingleValueConverter_WithNoData_ReturnsEmptyArray() + { + var result = _nestedContentSingleValueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), _publishedPropertyType, PropertyCacheLevel.Element, null, false) as IEnumerable; + + Assert.IsNotNull(result); + Assert.IsEmpty(result); + } + + [Test] + public void NestedContentManyValueConverter_HasMultipleElementsAsDeliveryApiType() + => Assert.AreEqual(typeof(IEnumerable), _nestedContentManyValueConverter.GetDeliveryApiPropertyValueType(Mock.Of())); + + + [Test] + public void NestedContentManyValueConverter_WithOneItem_ConvertsItemToListOfElements() + { + var nestedContentValue = "[{\"ncContentTypeAlias\": \"contentType1\",\"key\": \"1E68FB92-727A-4473-B10C-FA108ADCF16F\",\"prop1\": \"Hello, world\"}]"; + var result = _nestedContentManyValueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), _publishedPropertyType, PropertyCacheLevel.Element, nestedContentValue, false) as IEnumerable; + + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Count()); + + Assert.AreEqual("contentType1", result.First().ContentType); + Assert.AreEqual(Guid.Parse("1E68FB92-727A-4473-B10C-FA108ADCF16F"), result.First().Id); + Assert.AreEqual(1, result.First().Properties.Count); + Assert.AreEqual("Hello, world", result.First().Properties["prop1"]); + } + + [Test] + public void NestedContentManyValueConverter_WithMultipleItems_ConvertsAllItemsToElements() + { + var nestedContentValue = "[{\"ncContentTypeAlias\": \"contentType1\",\"key\": \"1E68FB92-727A-4473-B10C-FA108ADCF16F\",\"prop1\": \"Hello, world\"},{\"ncContentTypeAlias\": \"contentType1\",\"key\": \"40F59DD9-7E9F-4053-BD32-89FB086D18C9\",\"prop1\": \"One more\"}]"; + var result = _nestedContentManyValueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), _publishedPropertyType, PropertyCacheLevel.Element, nestedContentValue, false) as IEnumerable; + + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Count()); + + Assert.AreEqual("contentType1", result.First().ContentType); + Assert.AreEqual(Guid.Parse("1E68FB92-727A-4473-B10C-FA108ADCF16F"), result.First().Id); + Assert.AreEqual(1, result.First().Properties.Count); + Assert.AreEqual("Hello, world", result.First().Properties["prop1"]); + + Assert.AreEqual("contentType1", result.Last().ContentType); + Assert.AreEqual(Guid.Parse("40F59DD9-7E9F-4053-BD32-89FB086D18C9"), result.Last().Id); + Assert.AreEqual(1, result.Last().Properties.Count); + Assert.AreEqual("One more", result.Last().Properties["prop1"]); + } + + [Test] + public void NestedContentManyValueConverter_WithNoData_ReturnsEmptyArray() + { + var result = _nestedContentManyValueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), _publishedPropertyType, PropertyCacheLevel.Element, null, false) as IEnumerable; + + Assert.IsNotNull(result); + Assert.IsEmpty(result); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs new file mode 100644 index 0000000000..dda07c3051 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs @@ -0,0 +1,444 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Delivery.Rendering; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.PublishedCache; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class OutputExpansionStrategyTests : PropertyValueConverterTests +{ + private IPublishedContentType _contentType; + private IPublishedContentType _elementType; + + [SetUp] + public void SetUp() + { + var contentType = new Mock(); + contentType.SetupGet(c => c.Alias).Returns("thePageType"); + contentType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Content); + _contentType = contentType.Object; + var elementType = new Mock(); + elementType.SetupGet(c => c.Alias).Returns("theElementType"); + elementType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Element); + _elementType = elementType.Object; + } + + [Test] + public void OutputExpansionStrategy_ExpandsNothingByDefault() + { + var accessor = CreateOutputExpansionStrategyAccessor(); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + var prop1 = new PublishedElementPropertyBase(DeliveryApiPropertyType, content.Object, false, PropertyCacheLevel.None); + var prop2 = new PublishedElementPropertyBase(DefaultPropertyType, content.Object, false, PropertyCacheLevel.None); + + var contentPickerContent = CreateSimplePickedContent(123, 456); + var contentPickerProperty = CreateContentPickerProperty(content.Object, contentPickerContent.Key, "contentPicker", apiContentBuilder); + + SetupContentMock(content, prop1, prop2, contentPickerProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(3, result.Properties.Count); + Assert.AreEqual("Delivery API value", result.Properties[DeliveryApiPropertyType.Alias]); + Assert.AreEqual("Default value", result.Properties[DefaultPropertyType.Alias]); + var contentPickerOutput = result.Properties["contentPicker"] as ApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(contentPickerContent.Key, contentPickerOutput.Id); + Assert.IsEmpty(contentPickerOutput.Properties); + } + + [Test] + public void OutputExpansionStrategy_CanExpandSpecificContent() + { + var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { "contentPickerTwo" }); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var contentPickerOneContent = CreateSimplePickedContent(12, 34); + var contentPickerOneProperty = CreateContentPickerProperty(content.Object, contentPickerOneContent.Key, "contentPickerOne", apiContentBuilder); + var contentPickerTwoContent = CreateSimplePickedContent(56, 78); + var contentPickerTwoProperty = CreateContentPickerProperty(content.Object, contentPickerTwoContent.Key, "contentPickerTwo", apiContentBuilder); + + SetupContentMock(content, contentPickerOneProperty, contentPickerTwoProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(2, result.Properties.Count); + + var contentPickerOneOutput = result.Properties["contentPickerOne"] as ApiContent; + Assert.IsNotNull(contentPickerOneOutput); + Assert.AreEqual(contentPickerOneContent.Key, contentPickerOneOutput.Id); + Assert.IsEmpty(contentPickerOneOutput.Properties); + + var contentPickerTwoOutput = result.Properties["contentPickerTwo"] as ApiContent; + Assert.IsNotNull(contentPickerTwoOutput); + Assert.AreEqual(contentPickerTwoContent.Key, contentPickerTwoOutput.Id); + Assert.AreEqual(2, contentPickerTwoOutput.Properties.Count); + Assert.AreEqual(56, contentPickerTwoOutput.Properties["numberOne"]); + Assert.AreEqual(78, contentPickerTwoOutput.Properties["numberTwo"]); + } + + [Test] + public void OutputExpansionStrategy_CanExpandAllContent() + { + var accessor = CreateOutputExpansionStrategyAccessor(true); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var contentPickerOneContent = CreateSimplePickedContent(12, 34); + var contentPickerOneProperty = CreateContentPickerProperty(content.Object, contentPickerOneContent.Key, "contentPickerOne", apiContentBuilder); + var contentPickerTwoContent = CreateSimplePickedContent(56, 78); + var contentPickerTwoProperty = CreateContentPickerProperty(content.Object, contentPickerTwoContent.Key, "contentPickerTwo", apiContentBuilder); + + SetupContentMock(content, contentPickerOneProperty, contentPickerTwoProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(2, result.Properties.Count); + + var contentPickerOneOutput = result.Properties["contentPickerOne"] as ApiContent; + Assert.IsNotNull(contentPickerOneOutput); + Assert.AreEqual(contentPickerOneContent.Key, contentPickerOneOutput.Id); + Assert.AreEqual(2, contentPickerOneOutput.Properties.Count); + Assert.AreEqual(12, contentPickerOneOutput.Properties["numberOne"]); + Assert.AreEqual(34, contentPickerOneOutput.Properties["numberTwo"]); + + var contentPickerTwoOutput = result.Properties["contentPickerTwo"] as ApiContent; + Assert.IsNotNull(contentPickerTwoOutput); + Assert.AreEqual(contentPickerTwoContent.Key, contentPickerTwoOutput.Id); + Assert.AreEqual(2, contentPickerTwoOutput.Properties.Count); + Assert.AreEqual(56, contentPickerTwoOutput.Properties["numberOne"]); + Assert.AreEqual(78, contentPickerTwoOutput.Properties["numberTwo"]); + } + + [TestCase("contentPicker", "contentPicker")] + [TestCase("rootPicker", "nestedPicker")] + public void OutputExpansionStrategy_DoesNotExpandNestedContentPicker(string rootPropertyTypeAlias, string nestedPropertyTypeAlias) + { + var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { rootPropertyTypeAlias, nestedPropertyTypeAlias }); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var nestedContentPickerContent = CreateSimplePickedContent(987, 654); + var contentPickerContent = CreateMultiLevelPickedContent(123, nestedContentPickerContent, nestedPropertyTypeAlias, apiContentBuilder); + var contentPickerContentProperty = CreateContentPickerProperty(content.Object, contentPickerContent.Key, rootPropertyTypeAlias, apiContentBuilder); + + SetupContentMock(content, contentPickerContentProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(1, result.Properties.Count); + + var contentPickerOneOutput = result.Properties[rootPropertyTypeAlias] as ApiContent; + Assert.IsNotNull(contentPickerOneOutput); + Assert.AreEqual(contentPickerContent.Key, contentPickerOneOutput.Id); + Assert.AreEqual(2, contentPickerOneOutput.Properties.Count); + Assert.AreEqual(123, contentPickerOneOutput.Properties["number"]); + + var nestedContentPickerOutput = contentPickerOneOutput.Properties[nestedPropertyTypeAlias] as ApiContent; + Assert.IsNotNull(nestedContentPickerOutput); + Assert.AreEqual(nestedContentPickerContent.Key, nestedContentPickerOutput.Id); + Assert.IsEmpty(nestedContentPickerOutput.Properties); + } + + [Test] + public void OutputExpansionStrategy_DoesNotExpandElementsByDefault() + { + var accessor = CreateOutputExpansionStrategyAccessor(); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + var apiElementBuilder = new ApiElementBuilder(accessor); + + var contentPickerValue = CreateSimplePickedContent(111, 222); + var contentPicker2Value = CreateSimplePickedContent(666, 777); + + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, 444, "number"), + CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder), + CreateElementProperty(content.Object, "element2", 555, contentPicker2Value.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(3, result.Properties.Count); + Assert.AreEqual(444, result.Properties["number"]); + + var expectedElementOutputs = new[] + { + new + { + PropertyAlias = "element", + ElementNumber = 333, + ElementContentPicker = contentPickerValue.Key + }, + new + { + PropertyAlias = "element2", + ElementNumber = 555, + ElementContentPicker = contentPicker2Value.Key + } + }; + + foreach (var expectedElementOutput in expectedElementOutputs) + { + var elementOutput = result.Properties[expectedElementOutput.PropertyAlias] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(expectedElementOutput.ElementNumber, elementOutput.Properties["number"]); + var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(expectedElementOutput.ElementContentPicker, contentPickerOutput.Id); + Assert.AreEqual(0, contentPickerOutput.Properties.Count); + } + } + + [Test] + public void OutputExpansionStrategy_CanExpandSpecifiedElement() + { + var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { "element" }); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + var apiElementBuilder = new ApiElementBuilder(accessor); + + var contentPickerValue = CreateSimplePickedContent(111, 222); + var contentPicker2Value = CreateSimplePickedContent(666, 777); + + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, 444, "number"), + CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder), + CreateElementProperty(content.Object, "element2", 555, contentPicker2Value.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(3, result.Properties.Count); + Assert.AreEqual(444, result.Properties["number"]); + + var elementOutput = result.Properties["element"] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(333, elementOutput.Properties["number"]); + var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(contentPickerValue.Key, contentPickerOutput.Id); + Assert.AreEqual(2, contentPickerOutput.Properties.Count); + Assert.AreEqual(111, contentPickerOutput.Properties["numberOne"]); + Assert.AreEqual(222, contentPickerOutput.Properties["numberTwo"]); + + elementOutput = result.Properties["element2"] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(555, elementOutput.Properties["number"]); + contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(contentPicker2Value.Key, contentPickerOutput.Id); + Assert.AreEqual(0, contentPickerOutput.Properties.Count); + } + + [Test] + public void OutputExpansionStrategy_CanExpandAllElements() + { + var accessor = CreateOutputExpansionStrategyAccessor(true ); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + var apiElementBuilder = new ApiElementBuilder(accessor); + + var contentPickerValue = CreateSimplePickedContent(111, 222); + var contentPicker2Value = CreateSimplePickedContent(666, 777); + + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, 444, "number"), + CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder), + CreateElementProperty(content.Object, "element2", 555, contentPicker2Value.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(3, result.Properties.Count); + Assert.AreEqual(444, result.Properties["number"]); + + var expectedElementOutputs = new[] + { + new + { + PropertyAlias = "element", + ElementNumber = 333, + ElementContentPicker = contentPickerValue.Key, + ContentNumberOne = 111, + ContentNumberTwo = 222 + }, + new + { + PropertyAlias = "element2", + ElementNumber = 555, + ElementContentPicker = contentPicker2Value.Key, + ContentNumberOne = 666, + ContentNumberTwo = 777 + } + }; + + foreach (var expectedElementOutput in expectedElementOutputs) + { + var elementOutput = result.Properties[expectedElementOutput.PropertyAlias] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(expectedElementOutput.ElementNumber, elementOutput.Properties["number"]); + var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(expectedElementOutput.ElementContentPicker, contentPickerOutput.Id); + Assert.AreEqual(2, contentPickerOutput.Properties.Count); + Assert.AreEqual(expectedElementOutput.ContentNumberOne, contentPickerOutput.Properties["numberOne"]); + Assert.AreEqual(expectedElementOutput.ContentNumberTwo, contentPickerOutput.Properties["numberTwo"]); + } + } + + [Test] + public void OutputExpansionStrategy_DoesNotExpandElementNestedContentPicker() + { + var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { "element" }); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + var apiElementBuilder = new ApiElementBuilder(accessor); + + var nestedContentPickerValue = CreateSimplePickedContent(111, 222); + var contentPickerValue = CreateMultiLevelPickedContent(987, nestedContentPickerValue, "contentPicker", apiContentBuilder); + + var content = new Mock(); + SetupContentMock(content, CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(1, result.Properties.Count); + + var elementOutput = result.Properties["element"] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(333, elementOutput.Properties["number"]); + var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(contentPickerValue.Key, contentPickerOutput.Id); + Assert.AreEqual(2, contentPickerOutput.Properties.Count); + Assert.AreEqual(987, contentPickerOutput.Properties["number"]); + var nestedContentPickerOutput = contentPickerOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(nestedContentPickerOutput); + Assert.AreEqual(nestedContentPickerValue.Key, nestedContentPickerOutput.Id); + Assert.AreEqual(0, nestedContentPickerOutput.Properties.Count); + } + + private IOutputExpansionStrategyAccessor CreateOutputExpansionStrategyAccessor(bool expandAll = false, string[]? expandPropertyAliases = null) + { + var httpContextMock = new Mock(); + var httpRequestMock = new Mock(); + var httpContextAccessorMock = new Mock(); + + var expand = expandAll ? "all" : expandPropertyAliases != null ? $"property:{string.Join(",", expandPropertyAliases)}" : null; + httpRequestMock + .SetupGet(r => r.Query) + .Returns(new QueryCollection(new Dictionary { { "expand", expand } })); + + httpContextMock.SetupGet(c => c.Request).Returns(httpRequestMock.Object); + httpContextAccessorMock.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); + + IOutputExpansionStrategy outputExpansionStrategy = new RequestContextOutputExpansionStrategy(httpContextAccessorMock.Object); + var outputExpansionStrategyAccessorMock = new Mock(); + outputExpansionStrategyAccessorMock.Setup(s => s.TryGetValue(out outputExpansionStrategy)).Returns(true); + + return outputExpansionStrategyAccessorMock.Object; + } + + private void SetupContentMock(Mock content, params IPublishedProperty[] properties) + { + var key = Guid.NewGuid(); + var name = "The page"; + var urlSegment = "url-segment"; + ConfigurePublishedContentMock(content, key, name, urlSegment, _contentType, properties); + + RegisterContentWithProviders(content.Object); + } + + private IPublishedContent CreateSimplePickedContent(int numberOneValue, int numberTwoValue) + { + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, numberOneValue, "numberOne"), + CreateNumberProperty(content.Object, numberTwoValue, "numberTwo")); + + return content.Object; + } + + private IPublishedContent CreateMultiLevelPickedContent(int numberValue, IPublishedContent nestedContentPickerValue, string nestedContentPickerPropertyTypeAlias, ApiContentBuilder apiContentBuilder) + { + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, numberValue, "number"), + CreateContentPickerProperty(content.Object, nestedContentPickerValue.Key, nestedContentPickerPropertyTypeAlias, apiContentBuilder)); + + return content.Object; + } + + private PublishedElementPropertyBase CreateContentPickerProperty(IPublishedElement parent, Guid pickedContentKey, string propertyTypeAlias, IApiContentBuilder contentBuilder) + { + ContentPickerValueConverter contentPickerValueConverter = new ContentPickerValueConverter(PublishedSnapshotAccessor, contentBuilder); + var contentPickerPropertyType = SetupPublishedPropertyType(contentPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.ContentPicker); + + return new PublishedElementPropertyBase(contentPickerPropertyType, parent, false, PropertyCacheLevel.None, new GuidUdi(Constants.UdiEntityType.Document, pickedContentKey).ToString()); + } + + private PublishedElementPropertyBase CreateNumberProperty(IPublishedElement parent, int propertyValue, string propertyTypeAlias) + { + var numberPropertyType = SetupPublishedPropertyType(new IntegerValueConverter(), propertyTypeAlias, Constants.PropertyEditors.Aliases.Label); + return new PublishedElementPropertyBase(numberPropertyType, parent, false, PropertyCacheLevel.None, propertyValue); + } + + private PublishedElementPropertyBase CreateElementProperty( + IPublishedElement parent, + string elementPropertyAlias, + int numberPropertyValue, + Guid contentPickerPropertyValue, + string contentPickerPropertyTypeAlias, + IApiContentBuilder apiContentBuilder, + IApiElementBuilder apiElementBuilder) + { + var element = new Mock(); + element.SetupGet(c => c.ContentType).Returns(_elementType); + element.SetupGet(c => c.Properties).Returns(new[] + { + CreateNumberProperty(element.Object, numberPropertyValue, "number"), + CreateContentPickerProperty(element.Object, contentPickerPropertyValue, contentPickerPropertyTypeAlias, apiContentBuilder) + }); + + var elementValueConverter = new Mock(); + elementValueConverter + .Setup(p => p.ConvertIntermediateToDeliveryApiObject( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(() => apiElementBuilder.Build(element.Object)); + elementValueConverter.Setup(p => p.IsConverter(It.IsAny())).Returns(true); + elementValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + elementValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + + var elementPropertyType = SetupPublishedPropertyType(elementValueConverter.Object, elementPropertyAlias, "My.Element.Property"); + return new PublishedElementPropertyBase(elementPropertyType, parent, false, PropertyCacheLevel.None); + } + + private IApiContentRouteBuilder ApiContentRouteBuilder() => new ApiContentRouteBuilder(PublishedUrlProvider, CreateGlobalSettings(), Mock.Of()); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PropertyValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PropertyValueConverterTests.cs new file mode 100644 index 0000000000..cb81be658a --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PropertyValueConverterTests.cs @@ -0,0 +1,110 @@ +using System; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +public class PropertyValueConverterTests : DeliveryApiTests +{ + protected IPublishedSnapshotAccessor PublishedSnapshotAccessor { get; private set; } + + protected IPublishedUrlProvider PublishedUrlProvider { get; private set; } + + protected IPublishedContent PublishedContent { get; private set; } + + protected IPublishedContent PublishedMedia { get; private set; } + + protected IPublishedContentType PublishedContentType { get; private set; } + + protected IPublishedContentType PublishedMediaType { get; private set; } + + protected Mock PublishedContentCacheMock { get; private set; } + + protected Mock PublishedMediaCacheMock { get; private set; } + + protected Mock PublishedUrlProviderMock { get; private set; } + + [SetUp] + public override void Setup() + { + base.Setup(); + + var publishedContentType = new Mock(); + publishedContentType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Content); + publishedContentType.SetupGet(c => c.Alias).Returns("TheContentType"); + PublishedContentType = publishedContentType.Object; + var publishedMediaType = new Mock(); + publishedMediaType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Media); + publishedMediaType.SetupGet(c => c.Alias).Returns("TheMediaType"); + PublishedMediaType = publishedMediaType.Object; + + var contentKey = Guid.NewGuid(); + var publishedContent = SetupPublishedContent("The page", contentKey, PublishedItemType.Content, publishedContentType.Object); + PublishedContent = publishedContent.Object; + + var mediaKey = Guid.NewGuid(); + var publishedMedia = SetupPublishedContent("The media", mediaKey, PublishedItemType.Media, publishedMediaType.Object); + PublishedMedia = publishedMedia.Object; + + PublishedContentCacheMock = new Mock(); + PublishedContentCacheMock + .Setup(pcc => pcc.GetById(contentKey)) + .Returns(publishedContent.Object); + PublishedMediaCacheMock = new Mock(); + PublishedMediaCacheMock + .Setup(pcc => pcc.GetById(mediaKey)) + .Returns(publishedMedia.Object); + + var publishedSnapshot = new Mock(); + publishedSnapshot.SetupGet(ps => ps.Content).Returns(PublishedContentCacheMock.Object); + publishedSnapshot.SetupGet(ps => ps.Media).Returns(PublishedMediaCacheMock.Object); + + PublishedUrlProviderMock = new Mock(); + PublishedUrlProviderMock + .Setup(p => p.GetUrl(publishedContent.Object, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("the-page-url"); + PublishedUrlProviderMock + .Setup(p => p.GetMediaUrl(publishedMedia.Object, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("the-media-url"); + PublishedUrlProvider = PublishedUrlProviderMock.Object; + + var publishedSnapshotAccessor = new Mock(); + var publishedSnapshotObject = publishedSnapshot.Object; + publishedSnapshotAccessor + .Setup(psa => psa.TryGetPublishedSnapshot(out publishedSnapshotObject)) + .Returns(true); + PublishedSnapshotAccessor = publishedSnapshotAccessor.Object; + } + + protected Mock SetupPublishedContent(string name, Guid key, PublishedItemType itemType, IPublishedContentType contentType) + { + var content = new Mock(); + var urlSegment = "url-segment"; + ConfigurePublishedContentMock(content, key, name, urlSegment, contentType, Array.Empty()); + content.SetupGet(c => c.ItemType).Returns(itemType); + return content; + } + + protected void RegisterContentWithProviders(IPublishedContent content) + { + PublishedUrlProviderMock + .Setup(p => p.GetUrl(content, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(content.UrlSegment); + PublishedContentCacheMock + .Setup(pcc => pcc.GetById(content.Key)) + .Returns(content); + } + + protected void RegisterMediaWithProviders(IPublishedContent media) + { + PublishedUrlProviderMock + .Setup(p => p.GetUrl(media, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(media.UrlSegment); + PublishedMediaCacheMock + .Setup(pcc => pcc.GetById(media.Key)) + .Returns(media); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PublishedContentCacheTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PublishedContentCacheTests.cs new file mode 100644 index 0000000000..9ccb285e53 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PublishedContentCacheTests.cs @@ -0,0 +1,200 @@ +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class PublishedContentCacheTests : DeliveryApiTests +{ + private readonly Guid _contentOneId = Guid.Parse("19AEAC73-DB4E-4CFC-AB06-0AD14A89A613"); + + private readonly Guid _contentTwoId = Guid.Parse("4EF11E1E-FB50-4627-8A86-E10ED6F4DCE4"); + + private IPublishedSnapshotAccessor _publishedSnapshotAccessor = null!; + + [SetUp] + public void Setup() + { + var contentTypeOneMock = new Mock(); + contentTypeOneMock.SetupGet(m => m.Alias).Returns("theContentType"); + var contentOneMock = new Mock(); + ConfigurePublishedContentMock(contentOneMock, _contentOneId, "Content One", "content-one", contentTypeOneMock.Object, Array.Empty()); + + var contentTypeTwoMock = new Mock(); + contentTypeTwoMock.SetupGet(m => m.Alias).Returns("theOtherContentType"); + var contentTwoMock = new Mock(); + ConfigurePublishedContentMock(contentTwoMock, _contentTwoId, "Content Two", "content-two", contentTypeTwoMock.Object, Array.Empty()); + + var contentCacheMock = new Mock(); + contentCacheMock + .Setup(m => m.GetByRoute(It.IsAny(), "content-one", null, null)) + .Returns(contentOneMock.Object); + contentCacheMock + .Setup(m => m.GetById(It.IsAny(), _contentOneId)) + .Returns(contentOneMock.Object); + contentCacheMock + .Setup(m => m.GetByRoute(It.IsAny(), "content-two", null, null)) + .Returns(contentTwoMock.Object); + contentCacheMock + .Setup(m => m.GetById(It.IsAny(), _contentTwoId)) + .Returns(contentTwoMock.Object); + + var publishedSnapshotMock = new Mock(); + publishedSnapshotMock.Setup(m => m.Content).Returns(contentCacheMock.Object); + + var publishedSnapshot = publishedSnapshotMock.Object; + var publishedSnapshotAccessorMock = new Mock(); + publishedSnapshotAccessorMock.Setup(m => m.TryGetPublishedSnapshot(out publishedSnapshot)).Returns(true); + + _publishedSnapshotAccessor = publishedSnapshotAccessorMock.Object; + } + + [Test] + public void PublishedContentCache_CanGetById() + { + var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings()); + var content = publishedContentCache.GetById(_contentOneId); + Assert.IsNotNull(content); + Assert.AreEqual(_contentOneId, content.Key); + Assert.AreEqual("content-one", content.UrlSegment); + Assert.AreEqual("theContentType", content.ContentType.Alias); + } + + [Test] + public void PublishedContentCache_CanGetByRoute() + { + var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings()); + var content = publishedContentCache.GetByRoute("content-two"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentTwoId, content.Key); + Assert.AreEqual("content-two", content.UrlSegment); + Assert.AreEqual("theOtherContentType", content.ContentType.Alias); + } + + [Test] + public void PublishedContentCache_CanGetByIds() + { + var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings()); + var content = publishedContentCache.GetByIds(new[] { _contentOneId, _contentTwoId }).ToArray(); + Assert.AreEqual(2, content.Length); + Assert.AreEqual(_contentOneId, content.First().Key); + Assert.AreEqual(_contentTwoId, content.Last().Key); + } + + [TestCase(true)] + [TestCase(false)] + public void PublishedContentCache_GetById_SupportsDenyList(bool denied) + { + var denyList = denied ? new[] { "theOtherContentType" } : null; + var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings(denyList)); + var content = publishedContentCache.GetById(_contentTwoId); + + if (denied) + { + Assert.IsNull(content); + } + else + { + Assert.IsNotNull(content); + } + } + + [TestCase(true)] + [TestCase(false)] + public void PublishedContentCache_GetByRoute_SupportsDenyList(bool denied) + { + var denyList = denied ? new[] { "theContentType" } : null; + var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings(denyList)); + var content = publishedContentCache.GetByRoute("content-one"); + + if (denied) + { + Assert.IsNull(content); + } + else + { + Assert.IsNotNull(content); + } + } + + [TestCase("theContentType")] + [TestCase("theOtherContentType")] + public void PublishedContentCache_GetByIds_SupportsDenyList(string deniedContentType) + { + var denyList = new[] { deniedContentType }; + var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings(denyList)); + var content = publishedContentCache.GetByIds(new[] { _contentOneId, _contentTwoId }).ToArray(); + + Assert.AreEqual(1, content.Length); + if (deniedContentType == "theContentType") + { + Assert.AreEqual(_contentTwoId, content.First().Key); + } + else + { + Assert.AreEqual(_contentOneId, content.First().Key); + } + } + + [Test] + public void PublishedContentCache_GetById_CanRetrieveContentTypesOutsideTheDenyList() + { + var denyList = new[] { "theContentType" }; + var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings(denyList)); + var content = publishedContentCache.GetById(_contentTwoId); + Assert.IsNotNull(content); + Assert.AreEqual(_contentTwoId, content.Key); + Assert.AreEqual("content-two", content.UrlSegment); + Assert.AreEqual("theOtherContentType", content.ContentType.Alias); + } + + [Test] + public void PublishedContentCache_GetByRoute_CanRetrieveContentTypesOutsideTheDenyList() + { + var denyList = new[] { "theOtherContentType" }; + var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings(denyList)); + var content = publishedContentCache.GetByRoute("content-one"); + Assert.IsNotNull(content); + Assert.AreEqual(_contentOneId, content.Key); + Assert.AreEqual("content-one", content.UrlSegment); + Assert.AreEqual("theContentType", content.ContentType.Alias); + } + + [Test] + public void PublishedContentCache_GetByIds_CanDenyAllRequestedContent() + { + var denyList = new[] { "theContentType", "theOtherContentType" }; + var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings(denyList)); + var content = publishedContentCache.GetByIds(new[] { _contentOneId, _contentTwoId }).ToArray(); + Assert.IsEmpty(content); + } + + [Test] + public void PublishedContentCache_DenyListIsCaseInsensitive() + { + var denyList = new[] { "THEcontentTYPE" }; + var publishedContentCache = new ApiPublishedContentCache(_publishedSnapshotAccessor, CreateRequestPreviewService(), CreateDeliveryApiSettings(denyList)); + var content = publishedContentCache.GetByRoute("content-one"); + Assert.IsNull(content); + } + + private IRequestPreviewService CreateRequestPreviewService(bool isPreview = false) + { + var previewServiceMock = new Mock(); + previewServiceMock.Setup(m => m.IsPreview()).Returns(isPreview); + return previewServiceMock.Object; + } + + private IOptionsMonitor CreateDeliveryApiSettings(string[]? disallowedContentTypeAliases = null) + { + var deliveryApiSettings = new DeliveryApiSettings { DisallowedContentTypeAliases = disallowedContentTypeAliases ?? Array.Empty() }; + var deliveryApiOptionsMonitorMock = new Mock>(); + deliveryApiOptionsMonitorMock.SetupGet(s => s.CurrentValue).Returns(deliveryApiSettings); + return deliveryApiOptionsMonitorMock.Object; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PublishedPropertyTypeTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PublishedPropertyTypeTests.cs new file mode 100644 index 0000000000..ebeb83a6a0 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/PublishedPropertyTypeTests.cs @@ -0,0 +1,34 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class PublishedPropertyTypeTests : DeliveryApiTests +{ + [Test] + public void PropertyDeliveryApiValue_UsesDeliveryApiValueForDeliveryApiOutput() + { + var result = DeliveryApiPropertyType.ConvertInterToDeliveryApiObject(new Mock().Object, PropertyCacheLevel.None, null, false); + Assert.NotNull(result); + Assert.AreEqual("Delivery API value", result); + } + + [Test] + public void DeliveryApiPropertyValue_UsesDefaultValueForDefaultOutput() + { + var result = DeliveryApiPropertyType.ConvertInterToObject(new Mock().Object, PropertyCacheLevel.None, null, false); + Assert.NotNull(result); + Assert.AreEqual("Default value", result); + } + + [Test] + public void NonDeliveryApiPropertyValueConverter_PerformsFallbackToDefaultValueForDeliveryApiOutput() + { + var result = DefaultPropertyType.ConvertInterToDeliveryApiObject(new Mock().Object, PropertyCacheLevel.None, null, false); + Assert.NotNull(result); + Assert.AreEqual("Default value", result); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/RichTextParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/RichTextParserTests.cs new file mode 100644 index 0000000000..cce5715bd6 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/RichTextParserTests.cs @@ -0,0 +1,250 @@ +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using ApiRichTextParser = Umbraco.Cms.Infrastructure.DeliveryApi.ApiRichTextParser; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +[TestFixture] +public class RichTextParserTests +{ + private readonly Guid _contentKey = Guid.NewGuid(); + private readonly Guid _contentRootKey = Guid.NewGuid(); + private readonly Guid _mediaKey = Guid.NewGuid(); + + [Test] + public void DocumentElementIsCalledRoot() + { + var parser = CreateRichTextParser(); + + var element = parser.Parse("

Hello

"); + Assert.IsNotNull(element); + Assert.AreEqual("#root", element.Tag); + } + + [Test] + public void SimpleParagraphHasNoChildElements() + { + var parser = CreateRichTextParser(); + + var element = parser.Parse("

Some text paragraph

"); + Assert.IsNotNull(element); + Assert.IsEmpty(element.Text); + Assert.AreEqual(1, element.Elements.Count()); + var paragraph = element.Elements.First(); + Assert.AreEqual("p", paragraph.Tag); + Assert.AreEqual("Some text paragraph", paragraph.Text); + Assert.IsEmpty(paragraph.Elements); + } + + [Test] + public void ParagraphWithLineBreaksWrapsTextInElements() + { + var parser = CreateRichTextParser(); + + var element = parser.Parse("

Some text
More text
Even more text

"); + Assert.IsNotNull(element); + Assert.IsEmpty(element.Text); + Assert.AreEqual(1, element.Elements.Count()); + var paragraph = element.Elements.First(); + Assert.AreEqual("p", paragraph.Tag); + Assert.IsEmpty(paragraph.Text); + var paragraphElements = paragraph.Elements.ToArray(); + Assert.AreEqual(5, paragraphElements.Length); + for (var i = 0; i < paragraphElements.Length; i++) + { + var paragraphElement = paragraphElements[i]; + Assert.IsEmpty(paragraphElement.Elements); + switch (i) + { + case 0: + Assert.AreEqual("#text", paragraphElement.Tag); + Assert.AreEqual("Some text", paragraphElement.Text); + break; + case 2: + Assert.AreEqual("#text", paragraphElement.Tag); + Assert.AreEqual("More text", paragraphElement.Text); + break; + case 4: + Assert.AreEqual("#text", paragraphElement.Tag); + Assert.AreEqual("Even more text", paragraphElement.Text); + break; + case 1: + case 3: + Assert.AreEqual("br", paragraphElement.Tag); + Assert.IsEmpty(paragraphElement.Text); + break; + } + } + } + + [Test] + public void DataAttributesAreSanitized() + { + var parser = CreateRichTextParser(); + + var element = parser.Parse("

Text in a data-something SPAN

"); + Assert.IsNotNull(element); + var span = element.Elements.FirstOrDefault()?.Elements.FirstOrDefault(); + Assert.IsNotNull(span); + Assert.AreEqual("span", span.Tag); + Assert.AreEqual("Text in a data-something SPAN", span.Text); + Assert.AreEqual(1, span.Attributes.Count); + Assert.AreEqual("something", span.Attributes.First().Key); + Assert.AreEqual("the data-something value", span.Attributes.First().Value); + } + + [Test] + public void DataAttributesDoNotOverwriteExistingAttributes() + { + var parser = CreateRichTextParser(); + + var element = parser.Parse("

Text in a data-something SPAN

"); + Assert.IsNotNull(element); + var span = element.Elements.FirstOrDefault()?.Elements.FirstOrDefault(); + Assert.IsNotNull(span); + Assert.AreEqual("span", span.Tag); + Assert.AreEqual(1, span.Attributes.Count); + Assert.AreEqual("something", span.Attributes.First().Key); + Assert.AreEqual("the original something", span.Attributes.First().Value); + } + + [Test] + public void CanParseContentLink() + { + var parser = CreateRichTextParser(); + + var element = parser.Parse($"

"); + Assert.IsNotNull(element); + var link = element.Elements.FirstOrDefault()?.Elements.FirstOrDefault(); + Assert.IsNotNull(link); + Assert.AreEqual("a", link.Tag); + Assert.AreEqual(1, link.Attributes.Count); + Assert.AreEqual("route", link.Attributes.First().Key); + var route = link.Attributes.First().Value as IApiContentRoute; + Assert.IsNotNull(route); + Assert.AreEqual("/some-content-path", route.Path); + Assert.AreEqual(_contentRootKey, route.StartItem.Id); + Assert.AreEqual("the-root-path", route.StartItem.Path); + } + + [Test] + public void CanParseMediaLink() + { + var parser = CreateRichTextParser(); + + var element = parser.Parse($"

"); + Assert.IsNotNull(element); + var link = element.Elements.FirstOrDefault()?.Elements.FirstOrDefault(); + Assert.IsNotNull(link); + Assert.AreEqual("a", link.Tag); + Assert.AreEqual(1, link.Attributes.Count); + Assert.AreEqual("href", link.Attributes.First().Key); + Assert.AreEqual("/some-media-url", link.Attributes.First().Value); + } + + [Test] + public void CanHandleNonLocalLink() + { + var parser = CreateRichTextParser(); + + var element = parser.Parse($"

"); + Assert.IsNotNull(element); + var link = element.Elements.FirstOrDefault()?.Elements.FirstOrDefault(); + Assert.IsNotNull(link); + Assert.AreEqual("a", link.Tag); + Assert.AreEqual(1, link.Attributes.Count); + Assert.AreEqual("href", link.Attributes.First().Key); + Assert.AreEqual("https://some.where/else/", link.Attributes.First().Value); + } + + [TestCase("{localLink:umb://document/fe5bf80d37db4373adb9b206896b4a3b}")] + [TestCase("{localLink:umb://media/03b9a8721c4749a9a7026033ec78d860}")] + public void InvalidLocalLinkYieldsEmptyLink(string href) + { + var parser = CreateRichTextParser(); + + var element = parser.Parse($"

"); + Assert.IsNotNull(element); + var link = element.Elements.FirstOrDefault()?.Elements.FirstOrDefault(); + Assert.IsNotNull(link); + Assert.AreEqual("a", link.Tag); + Assert.IsEmpty(link.Attributes); + } + + [Test] + public void CanParseMediaImage() + { + var parser = CreateRichTextParser(); + + var element = parser.Parse($"

"); + Assert.IsNotNull(element); + var link = element.Elements.FirstOrDefault()?.Elements.FirstOrDefault(); + Assert.IsNotNull(link); + Assert.AreEqual("img", link.Tag); + Assert.AreEqual(1, link.Attributes.Count); + Assert.AreEqual("src", link.Attributes.First().Key); + Assert.AreEqual("/some-media-url", link.Attributes.First().Value); + } + + [Test] + public void CanHandleNonLocalImage() + { + var parser = CreateRichTextParser(); + + var element = parser.Parse($"

"); + Assert.IsNotNull(element); + var link = element.Elements.FirstOrDefault()?.Elements.FirstOrDefault(); + Assert.IsNotNull(link); + Assert.AreEqual("img", link.Tag); + Assert.AreEqual(1, link.Attributes.Count); + Assert.AreEqual("src", link.Attributes.First().Key); + Assert.AreEqual("https://some.where/something.png?rmode=max&width=500", link.Attributes.First().Value); + } + + private ApiRichTextParser CreateRichTextParser() + { + var contentMock = new Mock(); + contentMock.SetupGet(m => m.Key).Returns(_contentKey); + contentMock.SetupGet(m => m.ItemType).Returns(PublishedItemType.Content); + + var mediaMock = new Mock(); + mediaMock.SetupGet(m => m.Key).Returns(_mediaKey); + mediaMock.SetupGet(m => m.ItemType).Returns(PublishedItemType.Media); + + var contentCacheMock = new Mock(); + contentCacheMock.Setup(m => m.GetById(new GuidUdi(Constants.UdiEntityType.Document, _contentKey))).Returns(contentMock.Object); + var mediaCacheMock = new Mock(); + mediaCacheMock.Setup(m => m.GetById(new GuidUdi(Constants.UdiEntityType.Media, _mediaKey))).Returns(mediaMock.Object); + + var snapshotMock = new Mock(); + snapshotMock.SetupGet(m => m.Content).Returns(contentCacheMock.Object); + snapshotMock.SetupGet(m => m.Media).Returns(mediaCacheMock.Object); + + var snapshot = snapshotMock.Object; + var snapshotAccessorMock = new Mock(); + snapshotAccessorMock.Setup(m => m.TryGetPublishedSnapshot(out snapshot)).Returns(true); + + var routeBuilderMock = new Mock(); + routeBuilderMock + .Setup(m => m.Build(contentMock.Object, null)) + .Returns(new ApiContentRoute("/some-content-path", new ApiContentStartItem(_contentRootKey, "the-root-path"))); + + var urlProviderMock = new Mock(); + urlProviderMock + .Setup(m => m.GetMediaUrl(mediaMock.Object, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("/some-media-url"); + + return new ApiRichTextParser( + routeBuilderMock.Object, + snapshotAccessorMock.Object, + urlProviderMock.Object, + Mock.Of>()); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListPropertyValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListPropertyValueConverterTests.cs index 9b59bbd167..9e84217ab7 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListPropertyValueConverterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListPropertyValueConverterTests.cs @@ -4,6 +4,8 @@ using Moq; using NUnit.Framework; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Logging; using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Models.PublishedContent; @@ -65,7 +67,8 @@ public class BlockListPropertyValueConverterTests var editor = new BlockListPropertyValueConverter( Mock.Of(), new BlockEditorConverter(publishedSnapshotAccessor, publishedModelFactory), - Mock.Of()); + Mock.Of(), + new ApiElementBuilder(Mock.Of())); return editor; } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/NestedContentTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/NestedContentTests.cs index 1fa01615ed..f6425607d3 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/NestedContentTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/NestedContentTests.cs @@ -8,6 +8,8 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using NUnit.Framework; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Logging; using Umbraco.Cms.Core.Models; @@ -36,7 +38,7 @@ public class NestedContentTests PropertyEditorCollection editors = null; var editor = new NestedContentPropertyEditor( Mock.Of(), - Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of()); editors = new PropertyEditorCollection(new DataEditorCollection(() => new DataEditor[] { editor })); @@ -121,8 +123,8 @@ public class NestedContentTests var converters = new PropertyValueConverterCollection(() => new IPropertyValueConverter[] { - new NestedContentSingleValueConverter(publishedSnapshotAccessor.Object, publishedModelFactory.Object, proflog), - new NestedContentManyValueConverter(publishedSnapshotAccessor.Object, publishedModelFactory.Object, proflog), + new NestedContentSingleValueConverter(publishedSnapshotAccessor.Object, publishedModelFactory.Object, proflog, Mock.Of()), + new NestedContentManyValueConverter(publishedSnapshotAccessor.Object, publishedModelFactory.Object, proflog, Mock.Of()), }); var factory = @@ -276,5 +278,8 @@ public class NestedContentTests public override object GetXPathValue(string culture = null, string? segment = null) => throw new InvalidOperationException("This method won't be implemented."); + + public override object GetDeliveryApiValue(bool expanding, string culture = null, string segment = null) => + PropertyType.ConvertInterToDeliveryApiObject(_owner, ReferenceCacheLevel, InterValue, _preview); } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj index 6e2b9771fc..293af7db2c 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj @@ -15,6 +15,7 @@ + diff --git a/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs b/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs index 86e64d2aa5..f3e64a8953 100644 --- a/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs +++ b/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs @@ -20,6 +20,8 @@ internal class UmbracoCmsSchema { public ContentSettings Content { get; set; } = null!; + public DeliveryApiSettings DeliveryApi { get; set; } = null!; + public CoreDebugSettings Debug { get; set; } = null!; public ExceptionFilterSettings ExceptionFilter { get; set; } = null!; diff --git a/umbraco.sln b/umbraco.sln index 7cbd12cee6..8a9c1ddecc 100644 --- a/umbraco.sln +++ b/umbraco.sln @@ -158,6 +158,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Umbraco.Cms.Imaging.ImageSh EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{05878304-40EB-4F84-B40B-91BDB70DE094}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbraco.Cms.Api.Delivery", "src\Umbraco.Cms.Api.Delivery\Umbraco.Cms.Api.Delivery.csproj", "{9AA3D21F-81A9-4F27-85D1-CE850B59DC2D}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbraco.Cms.Api.Common", "src\Umbraco.Cms.Api.Common\Umbraco.Cms.Api.Common.csproj", "{D48B5D6B-82FF-4235-986C-CDE646F41DEC}" EndProject Global @@ -318,6 +320,12 @@ Global {C280181E-597B-4AA5-82E7-D7017E928749}.Release|Any CPU.Build.0 = Release|Any CPU {C280181E-597B-4AA5-82E7-D7017E928749}.SkipTests|Any CPU.ActiveCfg = Debug|Any CPU {C280181E-597B-4AA5-82E7-D7017E928749}.SkipTests|Any CPU.Build.0 = Debug|Any CPU + {9AA3D21F-81A9-4F27-85D1-CE850B59DC2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9AA3D21F-81A9-4F27-85D1-CE850B59DC2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9AA3D21F-81A9-4F27-85D1-CE850B59DC2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9AA3D21F-81A9-4F27-85D1-CE850B59DC2D}.Release|Any CPU.Build.0 = Release|Any CPU + {9AA3D21F-81A9-4F27-85D1-CE850B59DC2D}.SkipTests|Any CPU.ActiveCfg = Debug|Any CPU + {9AA3D21F-81A9-4F27-85D1-CE850B59DC2D}.SkipTests|Any CPU.Build.0 = Debug|Any CPU {D48B5D6B-82FF-4235-986C-CDE646F41DEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D48B5D6B-82FF-4235-986C-CDE646F41DEC}.Debug|Any CPU.Build.0 = Debug|Any CPU {D48B5D6B-82FF-4235-986C-CDE646F41DEC}.Release|Any CPU.ActiveCfg = Release|Any CPU