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 <kja@umbraco.dk>
Co-authored-by: Elitsa <>
Co-authored-by: Zeegaan <nge@umbraco.dk>

* 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 <kja@umbraco.dk>

* 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 <kja@umbraco.dk>
Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com>
Co-authored-by: Zeegaan <nge@umbraco.dk>
This commit is contained in:
Bjarke Berg
2023-04-19 11:21:31 +02:00
committed by GitHub
parent 2af425fd90
commit c06e89af64
182 changed files with 6904 additions and 74 deletions

View File

@@ -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<ApiExplorerOptions>
{
private readonly IOptions<ApiVersioningOptions> _apiVersioningOptions;
public ConfigureApiExplorerOptions(IOptions<ApiVersioningOptions> 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;
}
}

View File

@@ -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<ApiVersioningOptions>
{
public void Configure(ApiVersioningOptions options)
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
options.AssumeDefaultVersionWhenUnspecified = true;
options.UseApiBehavior = false;
}
}

View File

@@ -14,6 +14,12 @@ public class ConfigureMvcOptions : IConfigureOptions<MvcOptions>
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);

View File

@@ -2,13 +2,15 @@
<PropertyGroup>
<Title>Umbraco CMS - API Common</Title>
<Description>Contains the bits and pieces that are shared between the Umbraco CMS APIs.</Description>
<IsPackable>false</IsPackable>
<IsPackable>true</IsPackable>
<EnablePackageValidation>false</EnablePackageValidation>
<AssemblyName>Umbraco.Cms.Api.Common</AssemblyName>
<RootNamespace>Umbraco.Cms.Api.Common</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.8" />
<!-- <PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.8" />-->
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Umbraco.Core\Umbraco.Core.csproj" />

View File

@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Http;
using Umbraco.Cms.Core.DeliveryApi;
namespace Umbraco.Cms.Api.Delivery.Accessors;
public class RequestContextOutputExpansionStrategyAccessor : RequestContextServiceAccessorBase<IOutputExpansionStrategy>, IOutputExpansionStrategyAccessor
{
public RequestContextOutputExpansionStrategyAccessor(IHttpContextAccessor httpContextAccessor)
: base(httpContextAccessor)
{
}
}

View File

@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Http;
using Umbraco.Cms.Core.DeliveryApi;
namespace Umbraco.Cms.Api.Delivery.Accessors;
public class RequestContextRequestStartItemProviderAccessor : RequestContextServiceAccessorBase<IRequestStartItemProvider>, IRequestStartItemProviderAccessor
{
public RequestContextRequestStartItemProviderAccessor(IHttpContextAccessor httpContextAccessor)
: base(httpContextAccessor)
{
}
}

View File

@@ -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<T>
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<T>();
return requestStartNodeService is not null;
}
}

View File

@@ -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)
{
}
/// <summary>
/// Gets a content item by id.
/// </summary>
/// <param name="id">The unique identifier of the content item.</param>
/// <returns>The content item or not found result.</returns>
[HttpGet("item/{id:guid}")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ById(Guid id)
{
IPublishedContent? contentItem = ApiPublishedContentCache.GetById(id);
if (contentItem is null)
{
return NotFound();
}
return await Task.FromResult(Ok(ApiContentResponseBuilder.Build(contentItem)));
}
}

View File

@@ -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;
}
/// <summary>
/// Gets a content item by route.
/// </summary>
/// <param name="path">The path to the content item.</param>
/// <remarks>
/// Optional URL segment for the root content item
/// can be added through the "start-item" header.
/// </remarks>
/// <returns>The content item or not found result.</returns>
[HttpGet("item/{*path}")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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
{
}

View File

@@ -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;
/// <summary>
/// Gets a paginated list of content item(s) from query.
/// </summary>
/// <param name="fetch">Optional fetch query parameter value.</param>
/// <param name="filter">Optional filter query parameters values.</param>
/// <param name="sort">Optional sort query parameters values.</param>
/// <param name="skip">The amount of items to skip.</param>
/// <param name="take">The amount of items to take.</param>
/// <returns>The paged result of the content item(s).</returns>
[HttpGet]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedViewModel<IApiContentResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PagedViewModel<IApiContentResponse>>> Query(
string? fetch,
[FromQuery] string[] filter,
[FromQuery] string[] sort,
int skip = 0,
int take = 10)
{
PagedModel<Guid> pagedResult = _apiContentQueryService.ExecuteQuery(fetch, filter, sort, skip, take);
IEnumerable<IPublishedContent> contentItems = ApiPublishedContentCache.GetByIds(pagedResult.Items);
IApiContentResponse[] apiContentItems = contentItems.Select(ApiContentResponseBuilder.Build).ToArray();
var model = new PagedViewModel<IApiContentResponse>
{
Total = pagedResult.Total,
Items = apiContentItems
};
return await Task.FromResult(Ok(model));
}
}

View File

@@ -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<IRequestStartItemProvider, RequestStartItemProvider>();
builder.Services.AddScoped<IOutputExpansionStrategy, RequestContextOutputExpansionStrategy>();
builder.Services.AddSingleton<IRequestCultureService, RequestCultureService>();
builder.Services.AddSingleton<IRequestRoutingService, RequestRoutingService>();
builder.Services.AddSingleton<IRequestRedirectService, RequestRedirectService>();
builder.Services.AddSingleton<IRequestPreviewService, RequestPreviewService>();
builder.Services.AddSingleton<IOutputExpansionStrategyAccessor, RequestContextOutputExpansionStrategyAccessor>();
builder.Services.AddSingleton<IRequestStartItemProviderAccessor, RequestContextRequestStartItemProviderAccessor>();
builder.Services.AddSingleton<IApiAccessService, ApiAccessService>();
builder.Services.AddSingleton<IApiContentQueryService, ApiContentQueryService>();
builder.Services.ConfigureOptions<ConfigureApiVersioningOptions>();
builder.Services.AddApiVersioning();
builder.Services.ConfigureOptions<ConfigureApiExplorerOptions>();
builder.Services.AddVersionedApiExplorer();
builder
.Services
.ConfigureOptions<ConfigureMvcOptions>()
.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;
}
}

View File

@@ -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)
{
}
}
}

View File

@@ -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)
{
}
}
}

View File

@@ -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) }
};
}

View File

@@ -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:";
/// <inheritdoc />
public bool CanHandle(string query)
=> query.StartsWith(ContentTypeSpecifier, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
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;
}
}

View File

@@ -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:";
/// <inheritdoc />
public bool CanHandle(string query)
=> query.StartsWith(NameSpecifier, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
/// <inheritdoc />
public bool CanHandle(string query)
=> query.StartsWith(AncestorsSpecifier, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
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<Guid> ancestorKeys = contentItem.Ancestors().Select(a => a.Key);
return new SelectorOption
{
FieldName = "id",
Value = string.Join(" ", ancestorKeys)
};
}
}

View File

@@ -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)
{
}
/// <inheritdoc />
public bool CanHandle(string query)
=> query.StartsWith(ChildrenSpecifier, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
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
};
}
}

View File

@@ -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)
{
}
/// <inheritdoc />
public bool CanHandle(string query)
=> query.StartsWith(DescendantsSpecifier, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
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
};
}
}

View File

@@ -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:";
/// <inheritdoc />
public bool CanHandle(string query)
=> query.StartsWith(SortOptionSpecifier, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
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
};
}
}

View File

@@ -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:";
/// <inheritdoc />
public bool CanHandle(string query)
=> query.StartsWith(SortOptionSpecifier, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
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
};
}
}

View File

@@ -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:";
/// <inheritdoc />
public bool CanHandle(string query)
=> query.StartsWith(SortOptionSpecifier, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
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
};
}
}

View File

@@ -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:";
/// <inheritdoc />
public bool CanHandle(string query)
=> query.StartsWith(SortOptionSpecifier, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
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
};
}
}

View File

@@ -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<string, object?> MapElementProperties(IPublishedElement element)
=> MapProperties(element.Properties);
public IDictionary<string, object?> MapProperties(IEnumerable<IPublishedProperty> properties)
=> properties.ToDictionary(
p => p.Alias,
p => p.GetDeliveryApiValue(_state == ExpansionState.Expanding));
public IDictionary<string, object?> 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<string, object?>();
}
private (bool ExpandAll, string[] ExpanedAliases) InitialRequestState(IHttpContextAccessor httpContextAccessor)
{
string? toExpand = httpContextAccessor.HttpContext?.Request.Query["expand"];
if (toExpand.IsNullOrWhiteSpace())
{
return new(false, Array.Empty<string>());
}
const string propertySpecifier = "property:";
return new(
toExpand == "all",
toExpand.StartsWith(propertySpecifier)
? toExpand.Substring(propertySpecifier.Length).Split(Constants.CharArrays.Comma)
: Array.Empty<string>());
}
private enum ExpansionState
{
Initial,
Pending,
Expanding,
Expanded
}
}

View File

@@ -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('/')}")
{
}
}

View File

@@ -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> deliveryApiSettings)
: base(httpContextAccessor)
{
_deliveryApiSettings = deliveryApiSettings.CurrentValue;
deliveryApiSettings.OnChange(settings => _deliveryApiSettings = settings);
}
/// <inheritdoc />
public bool HasPublicAccess() => IfEnabled(() => _deliveryApiSettings.PublicAccess || HasValidApiKey());
/// <inheritdoc />
public bool HasPreviewAccess() => IfEnabled(HasValidApiKey);
private bool IfEnabled(Func<bool> condition) => _deliveryApiSettings.Enabled && condition();
private bool HasValidApiKey() => _deliveryApiSettings.ApiKey.IsNullOrWhiteSpace() == false
&& _deliveryApiSettings.ApiKey.Equals(GetHeaderValue("Api-Key"));
}

View File

@@ -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");
}
/// <inheritdoc/>
public PagedModel<Guid> ExecuteQuery(string? fetch, IEnumerable<string> filters, IEnumerable<string> sorts, int skip, int take)
{
var emptyResult = new PagedModel<Guid>();
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<Guid>(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<string> 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<string> 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);
}
}

View File

@@ -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;
/// <inheritdoc />
public string? GetRequestedCulture() => GetHeaderValue(HeaderNames.AcceptLanguage);
/// <inheritdoc />
public void SetRequestCulture(string culture)
{
if (_variationContextAccessor.VariationContext?.Culture == culture)
{
return;
}
_variationContextAccessor.VariationContext = new VariationContext(culture);
}
}

View File

@@ -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];
}
}

View File

@@ -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)
{
}
/// <inheritdoc />
public bool IsPreview() => GetHeaderValue("Preview") == "true";
}

View File

@@ -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> 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;
}
}

View File

@@ -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;
/// <inheritdoc />
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);
}
}

View File

@@ -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;
/// <inheritdoc/>
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<IPublishedContent> 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;
}
}

View File

@@ -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<Domain> 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();
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Title>Umbraco CMS - Delivery API</Title>
<Description>Contains the presentation layer for the Umbraco CMS Delivery API.</Description>
<IsPackable>true</IsPackable>
<EnablePackageValidation>false</EnablePackageValidation>
<AssemblyName>Umbraco.Cms.Api.Delivery</AssemblyName>
<RootNamespace>Umbraco.Cms.Api.Delivery</RootNamespace>
<PackageId>Umbraco.Cms.Api.Delivery</PackageId>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Umbraco.Cms.Api.Common\Umbraco.Cms.Api.Common.csproj" />
<ProjectReference Include="..\Umbraco.Infrastructure\Umbraco.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests.UnitTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -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<ConfigureApiVersioningOptions>();
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<ConfigureApiExplorerOptions>();
services.AddVersionedApiExplorer();
services.AddControllers()
.AddJsonOptions(options =>
{

View File

@@ -10,8 +10,6 @@
<ItemGroup>
<PackageReference Include="JsonPatch.Net" Version="2.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="5.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.2" />
<PackageReference Include="OpenIddict.AspNetCore" Version="3.1.1" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="3.1.1" />

View File

@@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Umbraco.Cms.Api.Delivery\Umbraco.Cms.Api.Delivery.csproj" />
<ProjectReference Include="..\Umbraco.Cms.StaticAssets\Umbraco.Cms.StaticAssets.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/en-us/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Umbraco.Cms.Core.Models.PublishedContent.PublishedPropertyBase.GetDeliveryApiValue(System.Boolean,System.String,System.String)</Target>
<Left>lib/net7.0/Umbraco.Core.dll</Left>
<Right>lib/net7.0/Umbraco.Core.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Umbraco.Cms.Core.Models.PublishedContent.IPublishedProperty.GetDeliveryApiValue(System.Boolean,System.String,System.String)</Target>
<Left>lib/net7.0/Umbraco.Core.dll</Left>
<Right>lib/net7.0/Umbraco.Core.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Umbraco.Cms.Core.Models.PublishedContent.IPublishedPropertyType.ConvertInterToDeliveryApiObject(Umbraco.Cms.Core.Models.PublishedContent.IPublishedElement,Umbraco.Cms.Core.PropertyEditors.PropertyCacheLevel,System.Object,System.Boolean)</Target>
<Left>lib/net7.0/Umbraco.Core.dll</Left>
<Right>lib/net7.0/Umbraco.Core.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>P:Umbraco.Cms.Core.Models.PublishedContent.IPublishedPropertyType.DeliveryApiCacheLevel</Target>
<Left>lib/net7.0/Umbraco.Core.dll</Left>
<Right>lib/net7.0/Umbraco.Core.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>

View File

@@ -0,0 +1,51 @@
using System.ComponentModel;
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Typed configuration options for Delivery API settings.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigDeliveryApi)]
public class DeliveryApiSettings
{
private const bool StaticEnabled = false;
private const bool StaticPublicAccess = true;
private const bool StaticRichTextOutputAsJson = false;
/// <summary>
/// Gets or sets a value indicating whether the Delivery API should be enabled.
/// </summary>
/// <value><c>true</c> if the Delivery API should be enabled; otherwise, <c>false</c>.</value>
[DefaultValue(StaticEnabled)]
public bool Enabled { get; set; } = StaticEnabled;
/// <summary>
/// Gets or sets a value indicating whether the Delivery API (if enabled) should be
/// publicly available or should require an API key for access.
/// </summary>
/// <value><c>true</c> if the Delivery API should be publicly available; <c>false</c> if an API key should be required for access.</value>
[DefaultValue(StaticPublicAccess)]
public bool PublicAccess { get; set; } = StaticPublicAccess;
/// <summary>
/// Gets or sets the API key used for authorizing API access (if the API is not publicly available) and preview access.
/// </summary>
/// <value>A <c>string</c> representing the API key.</value>
public string? ApiKey { get; set; } = null;
/// <summary>
/// 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.
/// </summary>
/// <value>The content type aliases that are not to be exposed.</value>
public string[] DisallowedContentTypeAliases { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets a value indicating whether the Delivery API should output rich text values as JSON instead of HTML.
/// </summary>
/// <value><c>true</c> if the Delivery API should output rich text values as JSON; <c>false</c> they should be output as HTML (default).</value>
[DefaultValue(StaticRichTextOutputAsJson)]
public bool RichTextOutputAsJson { get; set; } = StaticRichTextOutputAsJson;
}

View File

@@ -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";

View File

@@ -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";
}
}

View File

@@ -0,0 +1,9 @@
namespace Umbraco.Cms.Core;
public static partial class Constants
{
public static class JsonOptionsNames
{
public const string DeliveryApi = "DeliveryApi";
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<IApiContent>, 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<string, object?> properties)
=> new ApiContent(id, name, contentType, route, properties);
}

View File

@@ -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<T>
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<string, object?> properties);
public virtual T Build(IPublishedContent content)
{
IDictionary<string, object?> properties =
_outputExpansionStrategyAccessor.TryGetValue(out IOutputExpansionStrategy? outputExpansionStrategy)
? outputExpansionStrategy.MapContentProperties(content)
: new Dictionary<string, object?>();
return Create(
content,
content.Key,
_apiContentNameProvider.GetName(content),
content.ContentType.Alias,
_apiContentRouteBuilder.Build(content),
properties);
}
}

View File

@@ -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;
}

View File

@@ -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<IApiContentResponse>, 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<string, object?> 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);
}
}

View File

@@ -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> 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));
}
}

View File

@@ -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<string, object?> properties =
_outputExpansionStrategyAccessor.TryGetValue(out IOutputExpansionStrategy? outputExpansionStrategy)
? outputExpansionStrategy.MapElementProperties(element)
: new Dictionary<string, object?>();
return new ApiElement(
element.Key,
element.ContentType.Alias,
properties);
}
}

View File

@@ -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<string, object?> Properties(IPublishedContent media) =>
_outputExpansionStrategyAccessor.TryGetValue(out IOutputExpansionStrategy? outputExpansionStrategy)
? outputExpansionStrategy.MapProperties(media.Properties.Where(p => p.Alias != Constants.Conventions.Media.File))
: new Dictionary<string, object?>();
}

View File

@@ -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);
}
}

View File

@@ -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> 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<IPublishedContent> GetByIds(IEnumerable<Guid> contentIds)
{
IPublishedContentCache? contentCache = GetContentCache();
if (contentCache == null)
{
return Enumerable.Empty<IPublishedContent>();
}
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;
}

View File

@@ -0,0 +1,11 @@
using Umbraco.Cms.Core.Composing;
namespace Umbraco.Cms.Core.DeliveryApi;
public class FilterHandlerCollection : BuilderCollectionBase<IFilterHandler>
{
public FilterHandlerCollection(Func<IEnumerable<IFilterHandler>> items)
: base(items)
{
}
}

View File

@@ -0,0 +1,9 @@
using Umbraco.Cms.Core.Composing;
namespace Umbraco.Cms.Core.DeliveryApi;
public class FilterHandlerCollectionBuilder
: LazyCollectionBuilderBase<FilterHandlerCollectionBuilder, FilterHandlerCollection, IFilterHandler>
{
protected override FilterHandlerCollectionBuilder This => this;
}

View File

@@ -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
}

View File

@@ -0,0 +1,14 @@
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IApiAccessService
{
/// <summary>
/// Retrieves information on whether or not the API currently allows public access.
/// </summary>
bool HasPublicAccess();
/// <summary>
/// Retrieves information on whether or not the API currently allows preview access.
/// </summary>
bool HasPreviewAccess();
}

View File

@@ -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);
}

View File

@@ -0,0 +1,8 @@
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IApiContentNameProvider
{
string GetName(IPublishedContent content);
}

View File

@@ -0,0 +1,20 @@
using Umbraco.New.Cms.Core.Models;
namespace Umbraco.Cms.Core.DeliveryApi;
/// <summary>
/// Service that handles querying of the Delivery API.
/// </summary>
public interface IApiContentQueryService
{
/// <summary>
/// Returns a collection of item ids that passed the search criteria as a paged model.
/// </summary>
/// <param name="fetch">Optional fetch query parameter value.</param>
/// <param name="filters">Optional filter query parameters values.</param>
/// <param name="sorts">Optional sort query parameters values.</param>
/// <param name="skip">The amount of items to skip.</param>
/// <param name="take">The amount of items to take.</param>
/// <returns>A paged model of item ids that are returned after applying the search queries.</returns>
PagedModel<Guid> ExecuteQuery(string? fetch, IEnumerable<string> filters, IEnumerable<string> sorts, int skip, int take);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -0,0 +1,8 @@
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IApiMediaUrlProvider
{
string GetUrl(IPublishedContent media);
}

View File

@@ -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<IPublishedContent> GetByIds(IEnumerable<Guid> contentIds);
}

View File

@@ -0,0 +1,14 @@
namespace Umbraco.Cms.Core.DeliveryApi;
/// <summary>
/// A handler that handles filter query parameters.
/// </summary>
public interface IFilterHandler : IQueryHandler
{
/// <summary>
/// Builds a <see cref="FilterOption"/> for the filter query.
/// </summary>
/// <param name="filter">The filter query (i.e. "contentType:article").</param>
/// <returns>A <see cref="FilterOption"/> that can be used when building specific filter queries.</returns>
FilterOption BuildFilterOption(string filter);
}

View File

@@ -0,0 +1,12 @@
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IOutputExpansionStrategy
{
IDictionary<string, object?> MapElementProperties(IPublishedElement element);
IDictionary<string, object?> MapProperties(IEnumerable<IPublishedProperty> properties);
IDictionary<string, object?> MapContentProperties(IPublishedContent content);
}

View File

@@ -0,0 +1,8 @@
using System.Diagnostics.CodeAnalysis;
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IOutputExpansionStrategyAccessor
{
bool TryGetValue([NotNullWhen(true)] out IOutputExpansionStrategy? outputExpansionStrategy);
}

View File

@@ -0,0 +1,13 @@
using Umbraco.Cms.Core.Composing;
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IQueryHandler : IDiscoverable
{
/// <summary>
/// Determines whether this query handler can handle the given query.
/// </summary>
/// <param name="query">The query string to check (i.e. "children:articles", "contentType:article", "name:asc", ...).</param>
/// <returns>True if this query handler can handle the given query; otherwise, false.</returns>
bool CanHandle(string query);
}

View File

@@ -0,0 +1,15 @@
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IRequestCultureService
{
/// <summary>
/// Gets the requested culture from the "Accept-Language" header, if present.
/// </summary>
string? GetRequestedCulture();
/// <summary>
/// Updates the current request culture if applicable.
/// </summary>
/// <param name="culture">The culture to use for the current request.</param>
void SetRequestCulture(string culture);
}

View File

@@ -0,0 +1,9 @@
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IRequestPreviewService
{
/// <summary>
/// Retrieves information on whether or not to output draft content for preview.
/// </summary>
bool IsPreview();
}

View File

@@ -0,0 +1,11 @@
using Umbraco.Cms.Core.Models.DeliveryApi;
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IRequestRedirectService
{
/// <summary>
/// Retrieves the redirect URL (if any) for a requested content path
/// </summary>
IApiContentRoute? GetRedirectRoute(string requestedPath);
}

View File

@@ -0,0 +1,9 @@
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IRequestRoutingService
{
/// <summary>
/// Retrieves the actual route for content in the content cache from a requested content path
/// </summary>
string GetContentRoute(string requestedPath);
}

View File

@@ -0,0 +1,11 @@
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IRequestStartItemProvider
{
/// <summary>
/// Gets the requested start item from the "Start-Item" header, if present.
/// </summary>
IPublishedContent? GetStartItem();
}

View File

@@ -0,0 +1,8 @@
using System.Diagnostics.CodeAnalysis;
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IRequestStartItemProviderAccessor
{
bool TryGetValue([NotNullWhen(true)] out IRequestStartItemProvider? requestStartItemProvider);
}

View File

@@ -0,0 +1,14 @@
namespace Umbraco.Cms.Core.DeliveryApi;
/// <summary>
/// A handler that handles fetch query parameter.
/// </summary>
public interface ISelectorHandler : IQueryHandler
{
/// <summary>
/// Builds a <see cref="SelectorOption"/> for the selector query.
/// </summary>
/// <param name="selector">The selector query (i.e. "children:articles").</param>
/// <returns>A <see cref="SelectorOption"/> that can be used when building specific search query for requesting a subset of the items.</returns>
SelectorOption BuildSelectorOption(string selector);
}

View File

@@ -0,0 +1,14 @@
namespace Umbraco.Cms.Core.DeliveryApi;
/// <summary>
/// A handler that handles sort query parameters.
/// </summary>
public interface ISortHandler : IQueryHandler
{
/// <summary>
/// Builds a <see cref="SortOption"/> for the sort query.
/// </summary>
/// <param name="sort">The sort query (i.e. "name:asc").</param>
/// <returns>A <see cref="SortOption"/> that can be used when building specific sorting queries.</returns>
SortOption BuildSortOption(string sort);
}

View File

@@ -0,0 +1,10 @@
namespace Umbraco.Cms.Core.DeliveryApi;
public sealed class NoopApiAccessService : IApiAccessService
{
/// <inheritdoc />
public bool HasPublicAccess() => false;
/// <inheritdoc />
public bool HasPreviewAccess() => false;
}

View File

@@ -0,0 +1,10 @@
using Umbraco.New.Cms.Core.Models;
namespace Umbraco.Cms.Core.DeliveryApi;
public sealed class NoopApiContentQueryService : IApiContentQueryService
{
/// <inheritdoc />
public PagedModel<Guid> ExecuteQuery(string? fetch, IEnumerable<string> filters, IEnumerable<string> sorts, int skip, int take)
=> new();
}

View File

@@ -0,0 +1,15 @@
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Core.DeliveryApi;
internal sealed class NoopOutputExpansionStrategy : IOutputExpansionStrategy
{
public IDictionary<string, object?> MapElementProperties(IPublishedElement element)
=> MapProperties(element.Properties);
public IDictionary<string, object?> MapProperties(IEnumerable<IPublishedProperty> properties)
=> properties.ToDictionary(p => p.Alias, p => p.GetDeliveryApiValue(true));
public IDictionary<string, object?> MapContentProperties(IPublishedContent content)
=> MapProperties(content.Properties);
}

View File

@@ -0,0 +1,12 @@
namespace Umbraco.Cms.Core.DeliveryApi;
public sealed class NoopRequestCultureService : IRequestCultureService
{
/// <inheritdoc />
public string? GetRequestedCulture() => null;
/// <inheritdoc />
public void SetRequestCulture(string culture)
{
}
}

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Core.DeliveryApi;
public sealed class NoopRequestPreviewService : IRequestPreviewService
{
/// <inheritdoc />
public bool IsPreview() => false;
}

View File

@@ -0,0 +1,9 @@
using Umbraco.Cms.Core.Models.DeliveryApi;
namespace Umbraco.Cms.Core.DeliveryApi;
public sealed class NoopRequestRedirectService : IRequestRedirectService
{
/// <inheritdoc />
public IApiContentRoute? GetRedirectRoute(string requestedPath) => null;
}

View File

@@ -0,0 +1,7 @@
namespace Umbraco.Cms.Core.DeliveryApi;
public sealed class NoopRequestRoutingService : IRequestRoutingService
{
/// <inheritdoc />
public string GetContentRoute(string requestedPath) => requestedPath;
}

View File

@@ -0,0 +1,9 @@
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Core.DeliveryApi;
internal sealed class NoopRequestStartItemProvider : IRequestStartItemProvider
{
/// <inheritdoc />
public IPublishedContent? GetStartItem() => null;
}

View File

@@ -0,0 +1,11 @@
using Umbraco.Cms.Core.Composing;
namespace Umbraco.Cms.Core.DeliveryApi;
public class SelectorHandlerCollection : BuilderCollectionBase<ISelectorHandler>
{
public SelectorHandlerCollection(Func<IEnumerable<ISelectorHandler>> items)
: base(items)
{
}
}

View File

@@ -0,0 +1,9 @@
using Umbraco.Cms.Core.Composing;
namespace Umbraco.Cms.Core.DeliveryApi;
public class SelectorHandlerCollectionBuilder
: LazyCollectionBuilderBase<SelectorHandlerCollectionBuilder, SelectorHandlerCollection, ISelectorHandler>
{
protected override SelectorHandlerCollectionBuilder This => this;
}

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Core.DeliveryApi;
public class SelectorOption
{
public required string FieldName { get; set; }
public required string Value { get; set; }
}

View File

@@ -0,0 +1,11 @@
using Umbraco.Cms.Core.Composing;
namespace Umbraco.Cms.Core.DeliveryApi;
public class SortHandlerCollection : BuilderCollectionBase<ISortHandler>
{
public SortHandlerCollection(Func<IEnumerable<ISortHandler>> items)
: base(items)
{
}
}

View File

@@ -0,0 +1,9 @@
using Umbraco.Cms.Core.Composing;
namespace Umbraco.Cms.Core.DeliveryApi;
public class SortHandlerCollectionBuilder
: LazyCollectionBuilderBase<SortHandlerCollectionBuilder, SortHandlerCollection, ISortHandler>
{
protected override SortHandlerCollectionBuilder This => this;
}

View File

@@ -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
}

View File

@@ -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<LottieFiles>();
builder.SearchableTrees().Add(() => builder.TypeLoader.GetTypes<ISearchableTree>());
builder.BackOfficeAssets();
builder.SelectorHandlers().Add(() => builder.TypeLoader.GetTypes<ISelectorHandler>());
builder.FilterHandlers().Add(() => builder.TypeLoader.GetTypes<IFilterHandler>());
builder.SortHandlers().Add(() => builder.TypeLoader.GetTypes<ISortHandler>());
}
/// <summary>
@@ -298,4 +302,22 @@ public static partial class UmbracoBuilderExtensions
/// </summary>
public static CustomBackOfficeAssetsCollectionBuilder BackOfficeAssets(this IUmbracoBuilder builder)
=> builder.WithCollectionBuilder<CustomBackOfficeAssetsCollectionBuilder>();
/// <summary>
/// Gets the Delivery API selector handler collection builder
/// </summary>
public static SelectorHandlerCollectionBuilder SelectorHandlers(this IUmbracoBuilder builder)
=> builder.WithCollectionBuilder<SelectorHandlerCollectionBuilder>();
/// <summary>
/// Gets the Delivery API filter handler collection builder
/// </summary>
public static FilterHandlerCollectionBuilder FilterHandlers(this IUmbracoBuilder builder)
=> builder.WithCollectionBuilder<FilterHandlerCollectionBuilder>();
/// <summary>
/// Gets the Delivery API sort handler collection builder
/// </summary>
public static SortHandlerCollectionBuilder SortHandlers(this IUmbracoBuilder builder)
=> builder.WithCollectionBuilder<SortHandlerCollectionBuilder>();
}

View File

@@ -52,6 +52,7 @@ public static partial class UmbracoBuilderExtensions
.AddUmbracoOptions<ActiveDirectorySettings>()
.AddUmbracoOptions<MarketplaceSettings>()
.AddUmbracoOptions<ContentSettings>()
.AddUmbracoOptions<DeliveryApiSettings>()
.AddUmbracoOptions<CoreDebugSettings>()
.AddUmbracoOptions<ExceptionFilterSettings>()
.AddUmbracoOptions<GlobalSettings>(optionsBuilder => optionsBuilder.PostConfigure(options =>

View File

@@ -0,0 +1,20 @@
namespace Umbraco.Cms.Core.Models.DeliveryApi;
public class ApiBlockGridArea
{
public ApiBlockGridArea(string alias, int rowSpan, int columnSpan, IEnumerable<ApiBlockGridItem> items)
{
Alias = alias;
RowSpan = rowSpan;
ColumnSpan = columnSpan;
Items = items;
}
public string Alias { get; }
public int RowSpan { get; }
public int ColumnSpan { get; }
public IEnumerable<ApiBlockGridItem> Items { get; }
}

View File

@@ -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<ApiBlockGridArea> 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<ApiBlockGridArea> Areas { get; }
}

Some files were not shown because too many files have changed in this diff Show More