diff --git a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs index 2cc9ffca0b..7fb301c87e 100644 --- a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs +++ b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs @@ -17,12 +17,14 @@ public class ConfigureUmbracoDeliveryApiSwaggerGenOptions: IConfigureOptions(DeliveryApiConfiguration.ApiName); - swaggerGenOptions.OperationFilter(); - swaggerGenOptions.ParameterFilter(); + swaggerGenOptions.OperationFilter(); + swaggerGenOptions.OperationFilter(); + swaggerGenOptions.ParameterFilter(); + swaggerGenOptions.ParameterFilter(); } } diff --git a/src/Umbraco.Cms.Api.Delivery/Configuration/DeliveryApiConfiguration.cs b/src/Umbraco.Cms.Api.Delivery/Configuration/DeliveryApiConfiguration.cs index b1f4a15973..83a2d1e9d9 100644 --- a/src/Umbraco.Cms.Api.Delivery/Configuration/DeliveryApiConfiguration.cs +++ b/src/Umbraco.Cms.Api.Delivery/Configuration/DeliveryApiConfiguration.cs @@ -6,5 +6,8 @@ internal static class DeliveryApiConfiguration internal const string ApiName = "delivery"; - internal const string ApiDocumentationArticleLink = "https://docs.umbraco.com/umbraco-cms/v/12.latest/reference/content-delivery-api"; + internal const string ApiDocumentationContentArticleLink = "https://docs.umbraco.com/umbraco-cms/reference/content-delivery-api"; + + // TODO: update this when the Media article is out + internal const string ApiDocumentationMediaArticleLink = "https://docs.umbraco.com/umbraco-cms/reference/content-delivery-api"; } diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdMediaApiController.cs new file mode 100644 index 0000000000..423d70fd5b --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdMediaApiController.cs @@ -0,0 +1,39 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +[ApiVersion("1.0")] +public class ByIdMediaApiController : MediaApiControllerBase +{ + public ByIdMediaApiController(IPublishedSnapshotAccessor publishedSnapshotAccessor, IApiMediaWithCropsResponseBuilder apiMediaWithCropsResponseBuilder) + : base(publishedSnapshotAccessor, apiMediaWithCropsResponseBuilder) + { + } + + /// + /// Gets a media item by id. + /// + /// The unique identifier of the media item. + /// The media item or not found result. + [HttpGet("item/{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(ApiMediaWithCropsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ById(Guid id) + { + IPublishedContent? media = PublishedMediaCache.GetById(id); + + if (media is null) + { + return await Task.FromResult(NotFound()); + } + + return Ok(BuildApiMediaWithCrops(media)); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ByPathMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ByPathMediaApiController.cs new file mode 100644 index 0000000000..947dd820a1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ByPathMediaApiController.cs @@ -0,0 +1,45 @@ +using Asp.Versioning; +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; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +[ApiVersion("1.0")] +public class ByPathMediaApiController : MediaApiControllerBase +{ + private readonly IApiMediaQueryService _apiMediaQueryService; + + public ByPathMediaApiController( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IApiMediaWithCropsResponseBuilder apiMediaWithCropsResponseBuilder, + IApiMediaQueryService apiMediaQueryService) + : base(publishedSnapshotAccessor, apiMediaWithCropsResponseBuilder) + => _apiMediaQueryService = apiMediaQueryService; + + /// + /// Gets a media item by its path. + /// + /// The path of the media item. + /// The media item or not found result. + [HttpGet("item/{*path}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(ApiMediaWithCropsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ByPath(string path) + { + path = DecodePath(path); + + IPublishedContent? media = _apiMediaQueryService.GetByPath(path); + if (media is null) + { + return await Task.FromResult(NotFound()); + } + + return Ok(BuildApiMediaWithCrops(media)); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ByRouteContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ByRouteContentApiController.cs index 02181f6129..04900f52d2 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/ByRouteContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ByRouteContentApiController.cs @@ -48,15 +48,7 @@ public class ByRouteContentApiController : ContentApiItemControllerBase [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task ByRoute(string path = "") { - // OpenAPI does not allow reserved chars as "in:path" parameters, so clients based on the Swagger JSON will URL - // encode the path. Normally, ASP.NET Core handles that encoding with an automatic decoding - apparently just not - // for forward slashes, for whatever reason... so we need to deal with those. Hopefully this will be addressed in - // an upcoming version of ASP.NET Core. - // See also https://github.com/dotnet/aspnetcore/issues/11544 - if (path.Contains("%2F", StringComparison.OrdinalIgnoreCase)) - { - path = WebUtility.UrlDecode(path); - } + path = DecodePath(path); path = path.TrimStart("/"); path = path.Length == 0 ? "/" : path; diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs index e260200d5e..07439505e0 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs @@ -2,12 +2,12 @@ using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Delivery.Filters; using Umbraco.Cms.Api.Delivery.Routing; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Delivery.Controllers; +[DeliveryApiAccess] [VersionedDeliveryApiRoute("content")] [ApiExplorerSettings(GroupName = "Content")] [LocalizeFromAcceptLanguageHeader] @@ -39,5 +39,9 @@ public abstract class ContentApiControllerBase : DeliveryApiControllerBase .WithTitle("Sort option not found") .WithDetail("One of the attempted 'sort' options does not exist") .Build()), + _ => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Unknown content query status") + .WithDetail($"Content query status \"{status}\" was not expected here") + .Build()), }; } diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs index 552e2e2f8b..4160cc1fa8 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs @@ -1,17 +1,29 @@ -using Asp.Versioning; +using System.Net; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.Attributes; using Umbraco.Cms.Api.Common.Filters; using Umbraco.Cms.Api.Delivery.Configuration; -using Umbraco.Cms.Api.Delivery.Filters; using Umbraco.Cms.Core; namespace Umbraco.Cms.Api.Delivery.Controllers; [ApiController] -[DeliveryApiAccess] [JsonOptionsName(Constants.JsonOptionsNames.DeliveryApi)] [MapToApi(DeliveryApiConfiguration.ApiName)] public abstract class DeliveryApiControllerBase : Controller { + protected string DecodePath(string path) + { + // OpenAPI does not allow reserved chars as "in:path" parameters, so clients based on the Swagger JSON will URL + // encode the path. Normally, ASP.NET Core handles that encoding with an automatic decoding - apparently just not + // for forward slashes, for whatever reason... so we need to deal with those. Hopefully this will be addressed in + // an upcoming version of ASP.NET Core. + // See also https://github.com/dotnet/aspnetcore/issues/11544 + if (path.Contains("%2F", StringComparison.OrdinalIgnoreCase)) + { + path = WebUtility.UrlDecode(path); + } + + return path; + } } diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/MediaApiControllerBase.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/MediaApiControllerBase.cs new file mode 100644 index 0000000000..dc279cf703 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/MediaApiControllerBase.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; +using Umbraco.Cms.Api.Delivery.Filters; +using Umbraco.Cms.Api.Delivery.Routing; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Infrastructure.DeliveryApi; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +[DeliveryApiMediaAccess] +[VersionedDeliveryApiRoute("media")] +[ApiExplorerSettings(GroupName = "Media")] +public abstract class MediaApiControllerBase : DeliveryApiControllerBase +{ + private readonly IApiMediaWithCropsResponseBuilder _apiMediaWithCropsResponseBuilder; + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private IPublishedMediaCache? _publishedMediaCache; + + protected MediaApiControllerBase(IPublishedSnapshotAccessor publishedSnapshotAccessor, IApiMediaWithCropsResponseBuilder apiMediaWithCropsResponseBuilder) + { + _publishedSnapshotAccessor = publishedSnapshotAccessor; + _apiMediaWithCropsResponseBuilder = apiMediaWithCropsResponseBuilder; + } + + protected IPublishedMediaCache PublishedMediaCache => _publishedMediaCache + ??= _publishedSnapshotAccessor.GetRequiredPublishedSnapshot().Media + ?? throw new InvalidOperationException("Could not obtain the published media cache"); + + protected ApiMediaWithCropsResponse BuildApiMediaWithCrops(IPublishedContent media) + => _apiMediaWithCropsResponseBuilder.Build(media); + + protected IActionResult ApiMediaQueryOperationStatusResult(ApiMediaQueryOperationStatus status) => + status switch + { + ApiMediaQueryOperationStatus.FilterOptionNotFound => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Filter option not found") + .WithDetail("One of the attempted 'filter' options does not exist") + .Build()), + ApiMediaQueryOperationStatus.SelectorOptionNotFound => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Selector option not found") + .WithDetail("The attempted 'fetch' option does not exist") + .Build()), + ApiMediaQueryOperationStatus.SortOptionNotFound => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Sort option not found") + .WithDetail("One of the attempted 'sort' options does not exist") + .Build()), + _ => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Unknown media query status") + .WithDetail($"Media query status \"{status}\" was not expected here") + .Build()), + }; +} diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/QueryMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/QueryMediaApiController.cs new file mode 100644 index 0000000000..98110e9589 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/QueryMediaApiController.cs @@ -0,0 +1,67 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Core; +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.Services.OperationStatus; +using Umbraco.Cms.Infrastructure.DeliveryApi; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +[ApiVersion("1.0")] +public class QueryMediaApiController : MediaApiControllerBase +{ + private readonly IApiMediaQueryService _apiMediaQueryService; + + public QueryMediaApiController( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IApiMediaWithCropsResponseBuilder apiMediaWithCropsResponseBuilder, + IApiMediaQueryService apiMediaQueryService) + : base(publishedSnapshotAccessor, apiMediaWithCropsResponseBuilder) + => _apiMediaQueryService = apiMediaQueryService; + + /// + /// Gets a paginated list of media item(s) from query. + /// + /// Optional fetch query parameter value. + /// Optional filter query parameters values. + /// Optional sort query parameters values. + /// The amount of items to skip. + /// The amount of items to take. + /// The paged result of the media item(s). + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task Query( + string? fetch, + [FromQuery] string[] filter, + [FromQuery] string[] sort, + int skip = 0, + int take = 10) + { + Attempt, ApiMediaQueryOperationStatus> queryAttempt = _apiMediaQueryService.ExecuteQuery(fetch, filter, sort, skip, take); + + if (queryAttempt.Success is false) + { + return ApiMediaQueryOperationStatusResult(queryAttempt.Status); + } + + PagedModel pagedResult = queryAttempt.Result; + IPublishedContent[] mediaItems = pagedResult.Items.Select(PublishedMediaCache.GetById).WhereNotNull().ToArray(); + + var model = new PagedViewModel + { + Total = pagedResult.Total, + Items = mediaItems.Select(BuildApiMediaWithCrops) + }; + + return await Task.FromResult(Ok(model)); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index 34c6c37d18..d87741746a 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -28,6 +28,7 @@ public static class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.ConfigureOptions(); builder.AddUmbracoApiOpenApiUI(); diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/DeliveryApiMediaAccessAttribute.cs b/src/Umbraco.Cms.Api.Delivery/Filters/DeliveryApiMediaAccessAttribute.cs new file mode 100644 index 0000000000..e6dacce2c1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Filters/DeliveryApiMediaAccessAttribute.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Filters; + +internal sealed class DeliveryApiMediaAccessAttribute : TypeFilterAttribute +{ + public DeliveryApiMediaAccessAttribute() + : base(typeof(DeliveryApiMediaAccessFilter)) + { + } + + private class DeliveryApiMediaAccessFilter : IActionFilter + { + private readonly IApiAccessService _apiAccessService; + + public DeliveryApiMediaAccessFilter(IApiAccessService apiAccessService) + => _apiAccessService = apiAccessService; + + public void OnActionExecuting(ActionExecutingContext context) + { + if (_apiAccessService.HasMediaAccess()) + { + return; + } + + context.Result = new UnauthorizedResult(); + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs new file mode 100644 index 0000000000..8b3c946873 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs @@ -0,0 +1,171 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using Umbraco.Cms.Api.Delivery.Configuration; +using Umbraco.Cms.Api.Delivery.Controllers; + +namespace Umbraco.Cms.Api.Delivery.Filters; + +internal sealed class SwaggerContentDocumentationFilter : SwaggerDocumentationFilterBase +{ + protected override string DocumentationLink => DeliveryApiConfiguration.ApiDocumentationContentArticleLink; + + protected override void ApplyOperation(OpenApiOperation operation, OperationFilterContext context) + { + operation.Parameters ??= new List(); + + AddExpand(operation); + + operation.Parameters.Add(new OpenApiParameter + { + Name = "Accept-Language", + In = ParameterLocation.Header, + Required = false, + Description = "Defines the language to return. Use this when querying language variant content items.", + Schema = new OpenApiSchema { Type = "string" }, + Examples = new Dictionary + { + { "Default", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { "English culture", new OpenApiExample { Value = new OpenApiString("en-us") } } + } + }); + + AddApiKey(operation); + + operation.Parameters.Add(new OpenApiParameter + { + Name = "Preview", + In = ParameterLocation.Header, + Required = false, + Description = "Whether to request draft content.", + Schema = new OpenApiSchema { Type = "boolean" } + }); + + operation.Parameters.Add(new OpenApiParameter + { + Name = "Start-Item", + In = ParameterLocation.Header, + Required = false, + Description = "URL segment or GUID of a root content item.", + Schema = new OpenApiSchema { Type = "string" } + }); + } + + protected override void ApplyParameter(OpenApiParameter parameter, ParameterFilterContext context) + { + switch (parameter.Name) + { + case "fetch": + AddQueryParameterDocumentation(parameter, FetchQueryParameterExamples(), "Specifies the content items to fetch"); + break; + case "filter": + AddQueryParameterDocumentation(parameter, FilterQueryParameterExamples(), "Defines how to filter the fetched content items"); + break; + case "sort": + AddQueryParameterDocumentation(parameter, SortQueryParameterExamples(), "Defines how to sort the found content items"); + break; + case "skip": + parameter.Description = PaginationDescription(true, "content"); + break; + case "take": + parameter.Description = PaginationDescription(false, "content"); + break; + default: + return; + } + } + + private Dictionary FetchQueryParameterExamples() => + new() + { + { "Select all", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { + "Select all ancestors of a node by id", + new OpenApiExample { Value = new OpenApiString("ancestors:id") } + }, + { + "Select all ancestors of a node by path", + new OpenApiExample { Value = new OpenApiString("ancestors:path") } + }, + { + "Select all children of a node by id", + new OpenApiExample { Value = new OpenApiString("children:id") } + }, + { + "Select all children of a node by path", + new OpenApiExample { Value = new OpenApiString("children:path") } + }, + { + "Select all descendants of a node by id", + new OpenApiExample { Value = new OpenApiString("descendants:id") } + }, + { + "Select all descendants of a node by path", + new OpenApiExample { Value = new OpenApiString("descendants:path") } + } + }; + + private Dictionary FilterQueryParameterExamples() => + new() + { + { "Default filter", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { + "Filter by content type", + new OpenApiExample { Value = new OpenApiArray { new OpenApiString("contentType:alias1") } } + }, + { + "Filter by name", + new OpenApiExample { Value = new OpenApiArray { new OpenApiString("name:nodeName") } } + } + }; + + private Dictionary SortQueryParameterExamples() => + new() + { + { "Default sort", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { + "Sort by create date", + new OpenApiExample + { + Value = new OpenApiArray + { + new OpenApiString("createDate:asc"), new OpenApiString("createDate:desc") + } + } + }, + { + "Sort by level", + new OpenApiExample + { + Value = new OpenApiArray { new OpenApiString("level:asc"), new OpenApiString("level:desc") } + } + }, + { + "Sort by name", + new OpenApiExample + { + Value = new OpenApiArray { new OpenApiString("name:asc"), new OpenApiString("name:desc") } + } + }, + { + "Sort by sort order", + new OpenApiExample + { + Value = new OpenApiArray + { + new OpenApiString("sortOrder:asc"), new OpenApiString("sortOrder:desc") + } + } + }, + { + "Sort by update date", + new OpenApiExample + { + Value = new OpenApiArray + { + new OpenApiString("updateDate:asc"), new OpenApiString("updateDate:desc") + } + } + } + }; +} diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilter.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilter.cs index e41a4b19c1..bc0e138a82 100644 --- a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilter.cs +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilter.cs @@ -1,217 +1,18 @@ -using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; -using Umbraco.Cms.Api.Delivery.Configuration; -using Umbraco.Extensions; namespace Umbraco.Cms.Api.Delivery.Filters; +[Obsolete($"Superseded by {nameof(SwaggerContentDocumentationFilter)} and {nameof(SwaggerMediaDocumentationFilter)}. Will be removed in V14.")] public class SwaggerDocumentationFilter : IOperationFilter, IParameterFilter { public void Apply(OpenApiOperation operation, OperationFilterContext context) { - if (context.MethodInfo.HasMapToApiAttribute(DeliveryApiConfiguration.ApiName) == false) - { - return; - } - - operation.Parameters ??= new List(); - - operation.Parameters.Add(new OpenApiParameter - { - Name = "expand", - In = ParameterLocation.Query, - Required = false, - Description = QueryParameterDescription("Defines the properties that should be expanded in the response"), - Schema = new OpenApiSchema { Type = "string" }, - Examples = new Dictionary - { - { "Expand none", new OpenApiExample { Value = new OpenApiString("") } }, - { "Expand all", new OpenApiExample { Value = new OpenApiString("all") } }, - { - "Expand specific property", - new OpenApiExample { Value = new OpenApiString("property:alias1") } - }, - { - "Expand specific properties", - new OpenApiExample { Value = new OpenApiString("property:alias1,alias2") } - } - } - }); - - operation.Parameters.Add(new OpenApiParameter - { - Name = "Accept-Language", - In = ParameterLocation.Header, - Required = false, - Description = "Defines the language to return. Use this when querying language variant content items.", - Schema = new OpenApiSchema { Type = "string" }, - Examples = new Dictionary - { - { "Default", new OpenApiExample { Value = new OpenApiString("") } }, - { "English culture", new OpenApiExample { Value = new OpenApiString("en-us") } } - } - }); - - operation.Parameters.Add(new OpenApiParameter - { - Name = "Api-Key", - In = ParameterLocation.Header, - Required = false, - Description = "API key specified through configuration to authorize access to the API.", - Schema = new OpenApiSchema { Type = "string" } - }); - - operation.Parameters.Add(new OpenApiParameter - { - Name = "Preview", - In = ParameterLocation.Header, - Required = false, - Description = "Whether to request draft content.", - Schema = new OpenApiSchema { Type = "boolean" } - }); - - operation.Parameters.Add(new OpenApiParameter - { - Name = "Start-Item", - In = ParameterLocation.Header, - Required = false, - Description = "URL segment or GUID of a root content item.", - Schema = new OpenApiSchema { Type = "string" } - }); + // retained for backwards compat } public void Apply(OpenApiParameter parameter, ParameterFilterContext context) { - if (context.DocumentName != DeliveryApiConfiguration.ApiName) - { - return; - } - - switch (parameter.Name) - { - case "fetch": - AddQueryParameterDocumentation(parameter, FetchQueryParameterExamples(), "Specifies the content items to fetch"); - break; - case "filter": - AddQueryParameterDocumentation(parameter, FilterQueryParameterExamples(), "Defines how to filter the fetched content items"); - break; - case "sort": - AddQueryParameterDocumentation(parameter, SortQueryParameterExamples(), "Defines how to sort the found content items"); - break; - case "skip": - parameter.Description = PaginationDescription(true); - break; - case "take": - parameter.Description = PaginationDescription(false); - break; - default: - return; - } + // retained for backwards compat } - - private string QueryParameterDescription(string description) - => $"{description}. Refer to [the documentation]({DeliveryApiConfiguration.ApiDocumentationArticleLink}#query-parameters) for more details on this."; - - private string PaginationDescription(bool skip) => $"Specifies the number of found content items to {(skip ? "skip" : "take")}. Use this to control pagination of the response."; - - private void AddQueryParameterDocumentation(OpenApiParameter parameter, Dictionary examples, string description) - { - parameter.Description = QueryParameterDescription(description); - parameter.Examples = examples; - } - - private Dictionary FetchQueryParameterExamples() => - new() - { - { "Select all", new OpenApiExample { Value = new OpenApiString("") } }, - { - "Select all ancestors of a node by id", - new OpenApiExample { Value = new OpenApiString("ancestors:id") } - }, - { - "Select all ancestors of a node by path", - new OpenApiExample { Value = new OpenApiString("ancestors:path") } - }, - { - "Select all children of a node by id", - new OpenApiExample { Value = new OpenApiString("children:id") } - }, - { - "Select all children of a node by path", - new OpenApiExample { Value = new OpenApiString("children:path") } - }, - { - "Select all descendants of a node by id", - new OpenApiExample { Value = new OpenApiString("descendants:id") } - }, - { - "Select all descendants of a node by path", - new OpenApiExample { Value = new OpenApiString("descendants:path") } - } - }; - - private Dictionary FilterQueryParameterExamples() => - new() - { - { "Default filter", new OpenApiExample { Value = new OpenApiString("") } }, - { - "Filter by content type", - new OpenApiExample { Value = new OpenApiArray { new OpenApiString("contentType:alias1") } } - }, - { - "Filter by name", - new OpenApiExample { Value = new OpenApiArray { new OpenApiString("name:nodeName") } } - } - }; - - private Dictionary SortQueryParameterExamples() => - new() - { - { "Default sort", new OpenApiExample { Value = new OpenApiString("") } }, - { - "Sort by create date", - new OpenApiExample - { - Value = new OpenApiArray - { - new OpenApiString("createDate:asc"), new OpenApiString("createDate:desc") - } - } - }, - { - "Sort by level", - new OpenApiExample - { - Value = new OpenApiArray { new OpenApiString("level:asc"), new OpenApiString("level:desc") } - } - }, - { - "Sort by name", - new OpenApiExample - { - Value = new OpenApiArray { new OpenApiString("name:asc"), new OpenApiString("name:desc") } - } - }, - { - "Sort by sort order", - new OpenApiExample - { - Value = new OpenApiArray - { - new OpenApiString("sortOrder:asc"), new OpenApiString("sortOrder:desc") - } - } - }, - { - "Sort by update date", - new OpenApiExample - { - Value = new OpenApiArray - { - new OpenApiString("updateDate:asc"), new OpenApiString("updateDate:desc") - } - } - } - }; } diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs new file mode 100644 index 0000000000..32791b9b5e --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs @@ -0,0 +1,82 @@ +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Filters; + +internal abstract class SwaggerDocumentationFilterBase : IOperationFilter, IParameterFilter + where TBaseController : Controller +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (CanApply(context.MethodInfo)) + { + ApplyOperation(operation, context); + } + } + + public void Apply(OpenApiParameter parameter, ParameterFilterContext context) + { + if (CanApply(context.ParameterInfo.Member)) + { + ApplyParameter(parameter, context); + } + } + + protected abstract string DocumentationLink { get; } + + protected abstract void ApplyOperation(OpenApiOperation operation, OperationFilterContext context); + + protected abstract void ApplyParameter(OpenApiParameter parameter, ParameterFilterContext context); + + protected void AddQueryParameterDocumentation(OpenApiParameter parameter, Dictionary examples, string description) + { + parameter.Description = QueryParameterDescription(description); + parameter.Examples = examples; + } + + protected void AddExpand(OpenApiOperation operation) => + operation.Parameters.Add(new OpenApiParameter + { + Name = "expand", + In = ParameterLocation.Query, + Required = false, + Description = QueryParameterDescription("Defines the properties that should be expanded in the response"), + Schema = new OpenApiSchema { Type = "string" }, + Examples = new Dictionary + { + { "Expand none", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { "Expand all", new OpenApiExample { Value = new OpenApiString("all") } }, + { + "Expand specific property", + new OpenApiExample { Value = new OpenApiString("property:alias1") } + }, + { + "Expand specific properties", + new OpenApiExample { Value = new OpenApiString("property:alias1,alias2") } + } + } + }); + + protected void AddApiKey(OpenApiOperation operation) => + operation.Parameters.Add(new OpenApiParameter + { + Name = "Api-Key", + In = ParameterLocation.Header, + Required = false, + Description = "API key specified through configuration to authorize access to the API.", + Schema = new OpenApiSchema { Type = "string" } + }); + + protected string PaginationDescription(bool skip, string itemType) + => $"Specifies the number of found {itemType} items to {(skip ? "skip" : "take")}. Use this to control pagination of the response."; + + private string QueryParameterDescription(string description) + => $"{description}. Refer to [the documentation]({DocumentationLink}#query-parameters) for more details on this."; + + private bool CanApply(MemberInfo member) + => member.DeclaringType?.Implements() is true; +} diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs new file mode 100644 index 0000000000..af22914f78 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs @@ -0,0 +1,119 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using Umbraco.Cms.Api.Delivery.Configuration; +using Umbraco.Cms.Api.Delivery.Controllers; + +namespace Umbraco.Cms.Api.Delivery.Filters; + +internal sealed class SwaggerMediaDocumentationFilter : SwaggerDocumentationFilterBase +{ + protected override string DocumentationLink => DeliveryApiConfiguration.ApiDocumentationMediaArticleLink; + + protected override void ApplyOperation(OpenApiOperation operation, OperationFilterContext context) + { + operation.Parameters ??= new List(); + + AddExpand(operation); + + AddApiKey(operation); + } + + protected override void ApplyParameter(OpenApiParameter parameter, ParameterFilterContext context) + { + switch (parameter.Name) + { + case "fetch": + AddQueryParameterDocumentation(parameter, FetchQueryParameterExamples(), "Specifies the media items to fetch"); + break; + case "filter": + AddQueryParameterDocumentation(parameter, FilterQueryParameterExamples(), "Defines how to filter the fetched media items"); + break; + case "sort": + AddQueryParameterDocumentation(parameter, SortQueryParameterExamples(), "Defines how to sort the found media items"); + break; + case "skip": + parameter.Description = PaginationDescription(true, "media"); + break; + case "take": + parameter.Description = PaginationDescription(false, "media"); + break; + default: + return; + } + } + + private Dictionary FetchQueryParameterExamples() => + new() + { + { + "Select all children at root level", + new OpenApiExample { Value = new OpenApiString("children:/") } + }, + { + "Select all children of a media item by id", + new OpenApiExample { Value = new OpenApiString("children:id") } + }, + { + "Select all children of a media item by path", + new OpenApiExample { Value = new OpenApiString("children:path") } + } + }; + + private Dictionary FilterQueryParameterExamples() => + new() + { + { "Default filter", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { + "Filter by media type", + new OpenApiExample { Value = new OpenApiArray { new OpenApiString("mediaType:alias1") } } + }, + { + "Filter by name", + new OpenApiExample { Value = new OpenApiArray { new OpenApiString("name:nodeName") } } + } + }; + + private Dictionary SortQueryParameterExamples() => + new() + { + { "Default sort", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { + "Sort by create date", + new OpenApiExample + { + Value = new OpenApiArray + { + new OpenApiString("createDate:asc"), new OpenApiString("createDate:desc") + } + } + }, + { + "Sort by name", + new OpenApiExample + { + Value = new OpenApiArray { new OpenApiString("name:asc"), new OpenApiString("name:desc") } + } + }, + { + "Sort by sort order", + new OpenApiExample + { + Value = new OpenApiArray + { + new OpenApiString("sortOrder:asc"), new OpenApiString("sortOrder:desc") + } + } + }, + { + "Sort by update date", + new OpenApiExample + { + Value = new OpenApiArray + { + new OpenApiString("updateDate:asc"), new OpenApiString("updateDate:desc") + } + } + } + }; +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiAccessService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiAccessService.cs index b87205501c..0ba7df9b49 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ApiAccessService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiAccessService.cs @@ -23,8 +23,13 @@ internal sealed class ApiAccessService : RequestHeaderHandler, IApiAccessService /// public bool HasPreviewAccess() => IfEnabled(HasValidApiKey); + /// + public bool HasMediaAccess() => IfMediaEnabled(() => _deliveryApiSettings is { PublicAccess: true, Media.PublicAccess: true } || HasValidApiKey()); + private bool IfEnabled(Func condition) => _deliveryApiSettings.Enabled && condition(); private bool HasValidApiKey() => _deliveryApiSettings.ApiKey.IsNullOrWhiteSpace() == false && _deliveryApiSettings.ApiKey.Equals(GetHeaderValue("Api-Key")); + + private bool IfMediaEnabled(Func condition) => _deliveryApiSettings is { Enabled: true, Media.Enabled: true } && condition(); } diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs new file mode 100644 index 0000000000..1fe1e92d9b --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs @@ -0,0 +1,191 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Services; + +/// +internal sealed class ApiMediaQueryService : IApiMediaQueryService +{ + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly ILogger _logger; + + public ApiMediaQueryService(IPublishedSnapshotAccessor publishedSnapshotAccessor, ILogger logger) + { + _publishedSnapshotAccessor = publishedSnapshotAccessor; + _logger = logger; + } + + /// + public Attempt, ApiMediaQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, int skip, int take) + { + var emptyResult = new PagedModel(); + + IEnumerable? source = GetSource(fetch); + if (source is null) + { + return Attempt.FailWithStatus(ApiMediaQueryOperationStatus.SelectorOptionNotFound, emptyResult); + } + + source = ApplyFilters(source, filters); + if (source is null) + { + return Attempt.FailWithStatus(ApiMediaQueryOperationStatus.FilterOptionNotFound, emptyResult); + } + + source = ApplySorts(source, sorts); + if (source is null) + { + return Attempt.FailWithStatus(ApiMediaQueryOperationStatus.SortOptionNotFound, emptyResult); + } + + return PagedResult(source, skip, take); + } + + /// + public IPublishedContent? GetByPath(string path) + => TryGetByPath(path, GetRequiredPublishedMediaCache()); + + private IPublishedMediaCache GetRequiredPublishedMediaCache() + => _publishedSnapshotAccessor.GetRequiredPublishedSnapshot().Media + ?? throw new InvalidOperationException("Could not obtain the published media cache"); + + private IPublishedContent? TryGetByPath(string path, IPublishedMediaCache mediaCache) + { + var segments = path.Split(Constants.CharArrays.ForwardSlash, StringSplitOptions.RemoveEmptyEntries); + IEnumerable currentChildren = mediaCache.GetAtRoot(); + IPublishedContent? resolvedMedia = null; + + foreach (var segment in segments) + { + resolvedMedia = currentChildren.FirstOrDefault(c => segment.InvariantEquals(c.Name)); + if (resolvedMedia is null) + { + break; + } + + currentChildren = resolvedMedia.Children; + } + + return resolvedMedia; + } + + private IEnumerable? GetSource(string? fetch) + { + const string childrenOfParameter = "children:"; + + if (fetch?.StartsWith(childrenOfParameter, StringComparison.OrdinalIgnoreCase) is not true) + { + _logger.LogInformation($"The current implementation of {nameof(IApiMediaQueryService)} expects \"{childrenOfParameter}[id/path]\" in the \"{nameof(fetch)}\" query option"); + return null; + } + + var childrenOf = fetch.TrimStart(childrenOfParameter); + if (childrenOf.IsNullOrWhiteSpace()) + { + // this mirrors the current behavior of the Content Delivery API :-) + return Array.Empty(); + } + + IPublishedMediaCache mediaCache = GetRequiredPublishedMediaCache(); + if (childrenOf.Trim(Constants.CharArrays.ForwardSlash).Length == 0) + { + return mediaCache.GetAtRoot(); + } + + IPublishedContent? parent = Guid.TryParse(childrenOf, out Guid parentKey) + ? mediaCache.GetById(parentKey) + : TryGetByPath(childrenOf, mediaCache); + + return parent?.Children ?? Array.Empty(); + } + + private IEnumerable? ApplyFilters(IEnumerable source, IEnumerable filters) + { + foreach (var filter in filters) + { + var parts = filter.Split(':'); + if (parts.Length != 2) + { + // invalid filter + _logger.LogInformation($"The \"{nameof(filters)}\" query option \"{filter}\" is not valid"); + return null; + } + + switch (parts[0]) + { + case "mediaType": + source = source.Where(c => c.ContentType.Alias == parts[1]); + break; + case "name": + source = source.Where(c => c.Name.InvariantContains(parts[1])); + break; + default: + // unknown filter + _logger.LogInformation($"The \"{nameof(filters)}\" query option \"{filter}\" is not supported"); + return null; + } + } + + return source; + } + + private IEnumerable? ApplySorts(IEnumerable source, IEnumerable sorts) + { + foreach (var sort in sorts) + { + var parts = sort.Split(':'); + if (parts.Length != 2) + { + // invalid sort + _logger.LogInformation($"The \"{nameof(sorts)}\" query option \"{sort}\" is not valid"); + return null; + } + + Func keySelector; + switch (parts[0]) + { + case "createDate": + keySelector = content => content.CreateDate; + break; + case "updateDate": + keySelector = content => content.UpdateDate; + break; + case "name": + keySelector = content => content.Name.ToLowerInvariant(); + break; + case "sortOrder": + keySelector = content => content.SortOrder; + break; + default: + // unknown sort + _logger.LogInformation($"The \"{nameof(sorts)}\" query option \"{sort}\" is not supported"); + return null; + } + + source = parts[1].StartsWith("asc") + ? source.OrderBy(keySelector) + : source.OrderByDescending(keySelector); + } + + return source; + } + + + private Attempt, ApiMediaQueryOperationStatus> PagedResult(IEnumerable children, int skip, int take) + { + IPublishedContent[] childrenAsArray = children as IPublishedContent[] ?? children.ToArray(); + var result = new PagedModel + { + Total = childrenAsArray.Length, + Items = childrenAsArray.Skip(skip).Take(take).Select(child => child.Key) + }; + + return Attempt.SucceedWithStatus(ApiMediaQueryOperationStatus.Success, result); + } +} diff --git a/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs b/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs index eeb4bb2bcb..69b1943c60 100644 --- a/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs @@ -48,4 +48,40 @@ public class DeliveryApiSettings /// true if the Delivery API should output rich text values as JSON; false they should be output as HTML (default). [DefaultValue(StaticRichTextOutputAsJson)] public bool RichTextOutputAsJson { get; set; } = StaticRichTextOutputAsJson; + + /// + /// Gets or sets the settings for the Media APIs of the Delivery API. + /// + public MediaSettings Media { get; set; } = new (); + + /// + /// Typed configuration options for the Media APIs of the Delivery API. + /// + /// + /// The Delivery API settings (as configured in ) supersede these settings in levels of restriction. + /// I.e. the Media APIs cannot be enabled, if the Delivery API is disabled. + /// + public class MediaSettings + { + /// + /// Gets or sets a value indicating whether the Media APIs of the Delivery API should be enabled. + /// + /// true if the Media APIs should be enabled; otherwise, false. + /// + /// Setting this to true will have no effect if the Delivery API itself is disabled through + /// + [DefaultValue(StaticEnabled)] + public bool Enabled { get; set; } = StaticEnabled; + + /// + /// Gets or sets a value indicating whether the Media APIs of the Delivery API (if enabled) should be + /// publicly available or should require an API key for access. + /// + /// true if the Media APIs should be publicly available; false if an API key should be required for access. + /// + /// Setting this to true will have no effect if the Delivery API itself has public access disabled through + /// + [DefaultValue(StaticPublicAccess)] + public bool PublicAccess { get; set; } = StaticPublicAccess; + } } diff --git a/src/Umbraco.Core/DeliveryApi/IApiAccessService.cs b/src/Umbraco.Core/DeliveryApi/IApiAccessService.cs index e734571b7b..e890386a3a 100644 --- a/src/Umbraco.Core/DeliveryApi/IApiAccessService.cs +++ b/src/Umbraco.Core/DeliveryApi/IApiAccessService.cs @@ -11,4 +11,9 @@ public interface IApiAccessService /// Retrieves information on whether or not the API currently allows preview access. /// bool HasPreviewAccess(); + + /// + /// Retrieves information on whether or not the API currently allows access to media. + /// + bool HasMediaAccess() => false; } diff --git a/src/Umbraco.Core/DeliveryApi/IApiMediaQueryService.cs b/src/Umbraco.Core/DeliveryApi/IApiMediaQueryService.cs new file mode 100644 index 0000000000..db12543d57 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IApiMediaQueryService.cs @@ -0,0 +1,29 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.DeliveryApi; + +/// +/// Service that handles querying of the Media APIs. +/// +public interface IApiMediaQueryService +{ + /// + /// Returns an attempt with a collection of media ids that passed the search criteria as a paged model. + /// + /// Optional fetch query parameter value. + /// Optional filter query parameters values. + /// Optional sort query parameters values. + /// The amount of items to skip. + /// The amount of items to take. + /// A paged model of media ids that are returned after applying the search queries in an attempt. + Attempt, ApiMediaQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, int skip, int take); + + /// + /// Returns the media item that matches the supplied path (if any). + /// + /// The path to look up. + /// The media item at , or null if it does not exist. + IPublishedContent? GetByPath(string path); +} diff --git a/src/Umbraco.Core/DeliveryApi/NoopApiMediaQueryService.cs b/src/Umbraco.Core/DeliveryApi/NoopApiMediaQueryService.cs new file mode 100644 index 0000000000..c7f6bc8797 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopApiMediaQueryService.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public sealed class NoopApiMediaQueryService : IApiMediaQueryService +{ + /// + public Attempt, ApiMediaQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, int skip, int take) + => Attempt.SucceedWithStatus(ApiMediaQueryOperationStatus.Success, new PagedModel()); + + /// + public IPublishedContent? GetByPath(string path) => null; +} diff --git a/src/Umbraco.Core/Services/OperationStatus/ApiMediaQueryOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/ApiMediaQueryOperationStatus.cs new file mode 100644 index 0000000000..f031a37b91 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/ApiMediaQueryOperationStatus.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum ApiMediaQueryOperationStatus +{ + Success, + FilterOptionNotFound, + SelectorOptionNotFound, + SortOptionNotFound +} diff --git a/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsBuilder.cs b/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsBuilder.cs new file mode 100644 index 0000000000..ab9d7943b4 --- /dev/null +++ b/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsBuilder.cs @@ -0,0 +1,21 @@ +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Infrastructure.DeliveryApi; + +internal sealed class ApiMediaWithCropsBuilder : ApiMediaWithCropsBuilderBase, IApiMediaWithCropsBuilder +{ + public ApiMediaWithCropsBuilder(IApiMediaBuilder apiMediaBuilder, IPublishedValueFallback publishedValueFallback) + : base(apiMediaBuilder, publishedValueFallback) + { + } + + protected override ApiMediaWithCrops Create( + IPublishedContent media, + IApiMedia inner, + ImageCropperValue.ImageCropperFocalPoint? focalPoint, + IEnumerable? crops) => + new ApiMediaWithCrops(inner, focalPoint, crops); +} diff --git a/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsBuilderBase.cs b/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsBuilderBase.cs new file mode 100644 index 0000000000..8754eea976 --- /dev/null +++ b/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsBuilderBase.cs @@ -0,0 +1,49 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.DeliveryApi; + +internal abstract class ApiMediaWithCropsBuilderBase + where T : IApiMedia +{ + private readonly IApiMediaBuilder _apiMediaBuilder; + private readonly IPublishedValueFallback _publishedValueFallback; + + protected ApiMediaWithCropsBuilderBase(IApiMediaBuilder apiMediaBuilder, IPublishedValueFallback publishedValueFallback) + { + _apiMediaBuilder = apiMediaBuilder; + _publishedValueFallback = publishedValueFallback; + } + + protected abstract T Create( + IPublishedContent media, + IApiMedia inner, + ImageCropperValue.ImageCropperFocalPoint? focalPoint, + IEnumerable? crops); + + public T Build(MediaWithCrops media) + { + IApiMedia inner = _apiMediaBuilder.Build(media.Content); + + // make sure we merge crops and focal point defined at media level with the locally defined ones (local ones take precedence in case of a conflict) + ImageCropperValue? mediaCrops = media.Content.Value(_publishedValueFallback, Constants.Conventions.Media.File); + ImageCropperValue localCrops = media.LocalCrops; + if (mediaCrops is not null) + { + localCrops = localCrops.Merge(mediaCrops); + } + + return Create(media.Content, inner, localCrops.FocalPoint, localCrops.Crops); + } + + public T Build(IPublishedContent media) + { + var mediaWithCrops = new MediaWithCrops(media, _publishedValueFallback, new ImageCropperValue()); + return Build(mediaWithCrops); + } +} diff --git a/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsResponseBuilder.cs b/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsResponseBuilder.cs new file mode 100644 index 0000000000..68c73304da --- /dev/null +++ b/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsResponseBuilder.cs @@ -0,0 +1,34 @@ +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Infrastructure.DeliveryApi; + +internal sealed class ApiMediaWithCropsResponseBuilder : ApiMediaWithCropsBuilderBase, IApiMediaWithCropsResponseBuilder +{ + public ApiMediaWithCropsResponseBuilder(IApiMediaBuilder apiMediaBuilder, IPublishedValueFallback publishedValueFallback) + : base(apiMediaBuilder, publishedValueFallback) + { + } + + protected override ApiMediaWithCropsResponse Create( + IPublishedContent media, + IApiMedia inner, + ImageCropperValue.ImageCropperFocalPoint? focalPoint, + IEnumerable? crops) + { + var path = $"/{string.Join("/", PathSegments(media).Reverse())}/"; + return new ApiMediaWithCropsResponse(inner, focalPoint, crops, path, media.CreateDate, media.UpdateDate); + } + + private IEnumerable PathSegments(IPublishedContent media) + { + IPublishedContent? current = media; + while (current != null) + { + yield return current.Name.ToLowerInvariant(); + current = current.Parent; + } + } +} diff --git a/src/Umbraco.Infrastructure/DeliveryApi/IApiMediaWithCropsBuilder.cs b/src/Umbraco.Infrastructure/DeliveryApi/IApiMediaWithCropsBuilder.cs new file mode 100644 index 0000000000..63c2a0d218 --- /dev/null +++ b/src/Umbraco.Infrastructure/DeliveryApi/IApiMediaWithCropsBuilder.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Infrastructure.DeliveryApi; + +public interface IApiMediaWithCropsBuilder +{ + ApiMediaWithCrops Build(MediaWithCrops media); + + ApiMediaWithCrops Build(IPublishedContent media); +} diff --git a/src/Umbraco.Infrastructure/DeliveryApi/IApiMediaWithCropsResponseBuilder.cs b/src/Umbraco.Infrastructure/DeliveryApi/IApiMediaWithCropsResponseBuilder.cs new file mode 100644 index 0000000000..62e2cc7156 --- /dev/null +++ b/src/Umbraco.Infrastructure/DeliveryApi/IApiMediaWithCropsResponseBuilder.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Infrastructure.DeliveryApi; + +public interface IApiMediaWithCropsResponseBuilder +{ + ApiMediaWithCropsResponse Build(IPublishedContent media); +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 62ce5d3aec..5416310566 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -423,6 +423,8 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -432,6 +434,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCrops.cs b/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCrops.cs index 4aeaba3dea..d51a34e27d 100644 --- a/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCrops.cs +++ b/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCrops.cs @@ -2,7 +2,7 @@ using Umbraco.Cms.Core.PropertyEditors.ValueConverters; namespace Umbraco.Cms.Core.Models.DeliveryApi; -internal sealed class ApiMediaWithCrops : IApiMedia +public class ApiMediaWithCrops : IApiMedia { private readonly IApiMedia _inner; diff --git a/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCropsResponse.cs b/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCropsResponse.cs new file mode 100644 index 0000000000..e1c1f09344 --- /dev/null +++ b/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCropsResponse.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public sealed class ApiMediaWithCropsResponse : ApiMediaWithCrops +{ + public ApiMediaWithCropsResponse( + IApiMedia inner, + ImageCropperValue.ImageCropperFocalPoint? focalPoint, + IEnumerable? crops, + string path, + DateTime createDate, + DateTime updateDate) + : base(inner, focalPoint, crops) + { + Path = path; + CreateDate = createDate; + UpdateDate = updateDate; + } + + public string Path { get; } + + public DateTime CreateDate { get; } + + public DateTime UpdateDate { get; } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs index 58020c5554..7e0829a399 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs @@ -7,6 +7,7 @@ using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.DeliveryApi; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; @@ -19,9 +20,9 @@ public class MediaPickerWithCropsValueConverter : PropertyValueConverterBase, ID private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; private readonly IPublishedUrlProvider _publishedUrlProvider; private readonly IPublishedValueFallback _publishedValueFallback; - private readonly IApiMediaBuilder _apiMediaBuilder; + private readonly IApiMediaWithCropsBuilder _apiMediaWithCropsBuilder; - [Obsolete("Use constructor that takes all parameters, scheduled for removal in V14")] + [Obsolete($"Use constructor that takes {nameof(IApiMediaWithCropsBuilder)}, scheduled for removal in V14")] public MediaPickerWithCropsValueConverter( IPublishedSnapshotAccessor publishedSnapshotAccessor, IPublishedUrlProvider publishedUrlProvider, @@ -32,24 +33,52 @@ public class MediaPickerWithCropsValueConverter : PropertyValueConverterBase, ID publishedUrlProvider, publishedValueFallback, jsonSerializer, - StaticServiceProvider.Instance.GetRequiredService() + StaticServiceProvider.Instance.GetRequiredService() ) { } + [Obsolete($"Use constructor that takes {nameof(IApiMediaWithCropsBuilder)}, scheduled for removal in V14")] + public MediaPickerWithCropsValueConverter( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IPublishedUrlProvider publishedUrlProvider, + IPublishedValueFallback publishedValueFallback, + IJsonSerializer jsonSerializer, + IApiMediaBuilder apiMediaBuilder) + : this( + publishedSnapshotAccessor, + publishedUrlProvider, + publishedValueFallback, + jsonSerializer, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [Obsolete($"Use constructor that takes {nameof(IApiMediaWithCropsBuilder)} and no {nameof(IApiMediaBuilder)}, scheduled for removal in V14")] + public MediaPickerWithCropsValueConverter( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IPublishedUrlProvider publishedUrlProvider, + IPublishedValueFallback publishedValueFallback, + IJsonSerializer jsonSerializer, + IApiMediaBuilder apiMediaBuilder, + IApiMediaWithCropsBuilder apiMediaWithCropsBuilder) + : this(publishedSnapshotAccessor, publishedUrlProvider, publishedValueFallback, jsonSerializer, apiMediaWithCropsBuilder) + { + } + public MediaPickerWithCropsValueConverter( IPublishedSnapshotAccessor publishedSnapshotAccessor, IPublishedUrlProvider publishedUrlProvider, IPublishedValueFallback publishedValueFallback, IJsonSerializer jsonSerializer, - IApiMediaBuilder apiMediaBuilder) + IApiMediaWithCropsBuilder apiMediaWithCropsBuilder) { _publishedSnapshotAccessor = publishedSnapshotAccessor ?? throw new ArgumentNullException(nameof(publishedSnapshotAccessor)); _publishedUrlProvider = publishedUrlProvider; _publishedValueFallback = publishedValueFallback; _jsonSerializer = jsonSerializer; - _apiMediaBuilder = apiMediaBuilder; + _apiMediaWithCropsBuilder = apiMediaWithCropsBuilder; } public override bool IsConverter(IPublishedPropertyType propertyType) => @@ -128,20 +157,7 @@ public class MediaPickerWithCropsValueConverter : PropertyValueConverterBase, ID { var isMultiple = IsMultipleDataType(propertyType.DataType); - ApiMediaWithCrops ToApiMedia(MediaWithCrops media) - { - IApiMedia inner = _apiMediaBuilder.Build(media.Content); - - // make sure we merge crops and focal point defined at media level with the locally defined ones (local ones take precedence in case of a conflict) - ImageCropperValue? mediaCrops = media.Content.Value(_publishedValueFallback, Constants.Conventions.Media.File); - ImageCropperValue localCrops = media.LocalCrops; - if (mediaCrops != null) - { - localCrops = localCrops.Merge(mediaCrops); - } - - return new ApiMediaWithCrops(inner, localCrops.FocalPoint, localCrops.Crops); - } + ApiMediaWithCrops ToApiMedia(MediaWithCrops media) => _apiMediaWithCropsBuilder.Build(media); // NOTE: eventually we might implement this explicitly instead of piggybacking on the default object conversion. however, this only happens once per cache rebuild, // and the performance gain from an explicit implementation is negligible, so... at least for the time being this will do just fine. diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs index e39d9d83e5..9e0284ffa8 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs @@ -6,6 +6,7 @@ using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Infrastructure.DeliveryApi; using Umbraco.Cms.Infrastructure.Serialization; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; @@ -18,16 +19,19 @@ public class MediaPickerWithCropsValueConverterTests : PropertyValueConverterTes var serializer = new JsonNetSerializer(); var publishedValueFallback = Mock.Of(); var apiUrlProvider = new ApiMediaUrlProvider(PublishedUrlProvider); + var apiMediaWithCropsBuilder = new ApiMediaWithCropsBuilder( + new ApiMediaBuilder( + new ApiContentNameProvider(), + apiUrlProvider, + publishedValueFallback, + CreateOutputExpansionStrategyAccessor()), + publishedValueFallback); return new MediaPickerWithCropsValueConverter( PublishedSnapshotAccessor, PublishedUrlProvider, publishedValueFallback, serializer, - new ApiMediaBuilder( - new ApiContentNameProvider(), - apiUrlProvider, - Mock.Of(), - CreateOutputExpansionStrategyAccessor())); + apiMediaWithCropsBuilder); } [Test] diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs index 443dda29f4..75612932c8 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.PropertyEditors.ValueConverters; using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.DeliveryApi; using Umbraco.Cms.Infrastructure.Serialization; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; @@ -545,7 +546,10 @@ public class OutputExpansionStrategyTests : PropertyValueConverterTests } }); - MediaPickerWithCropsValueConverter mediaPickerValueConverter = new MediaPickerWithCropsValueConverter(PublishedSnapshotAccessor, PublishedUrlProvider, Mock.Of(), new JsonNetSerializer(), mediaBuilder); + var publishedValueFallback = Mock.Of(); + var apiMediaWithCropsBuilder = new ApiMediaWithCropsBuilder(mediaBuilder, publishedValueFallback); + + MediaPickerWithCropsValueConverter mediaPickerValueConverter = new MediaPickerWithCropsValueConverter(PublishedSnapshotAccessor, PublishedUrlProvider, publishedValueFallback, new JsonNetSerializer(), apiMediaWithCropsBuilder); var mediaPickerPropertyType = SetupPublishedPropertyType(mediaPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.MediaPicker3, new MediaPicker3Configuration()); return new PublishedElementPropertyBase(mediaPickerPropertyType, parent, false, PropertyCacheLevel.None, value);