Media Delivery API (#14692)

* Introduce media API - controllers, services, tests, Swagger docs

* Add path to media API response + add "by path" endpoint

* Review comments

* Implement filtering and sorting

* Add explicit media access configuration

* Cleanup

* Adding default case as in the MediaApiControllerBase

* Update src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs

Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com>

* Swap sort order calculation to align with Content API

* Add CreateDate and UpdateDate to media responses

* Mirror Content Delivery API behavior for empty children selector

---------

Co-authored-by: Elitsa <elm@umbraco.dk>
Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com>
This commit is contained in:
Kenn Jacobsen
2023-08-21 13:57:36 +02:00
committed by GitHub
parent 29d0c6770c
commit 66bbad3379
33 changed files with 1142 additions and 245 deletions

View File

@@ -17,12 +17,14 @@ public class ConfigureUmbracoDeliveryApiSwaggerGenOptions: IConfigureOptions<Swa
{
Title = DeliveryApiConfiguration.ApiTitle,
Version = "Latest",
Description = $"You can find out more about the {DeliveryApiConfiguration.ApiTitle} in [the documentation]({DeliveryApiConfiguration.ApiDocumentationArticleLink})."
Description = $"You can find out more about the {DeliveryApiConfiguration.ApiTitle} in [the documentation]({DeliveryApiConfiguration.ApiDocumentationContentArticleLink})."
});
swaggerGenOptions.DocumentFilter<MimeTypeDocumentFilter>(DeliveryApiConfiguration.ApiName);
swaggerGenOptions.OperationFilter<SwaggerDocumentationFilter>();
swaggerGenOptions.ParameterFilter<SwaggerDocumentationFilter>();
swaggerGenOptions.OperationFilter<SwaggerContentDocumentationFilter>();
swaggerGenOptions.OperationFilter<SwaggerMediaDocumentationFilter>();
swaggerGenOptions.ParameterFilter<SwaggerContentDocumentationFilter>();
swaggerGenOptions.ParameterFilter<SwaggerMediaDocumentationFilter>();
}
}

View File

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

View File

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

View File

@@ -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;
/// <summary>
/// Gets a media item by its path.
/// </summary>
/// <param name="path">The path of the media item.</param>
/// <returns>The media item or not found result.</returns>
[HttpGet("item/{*path}")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(ApiMediaWithCropsResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ByPath(string path)
{
path = DecodePath(path);
IPublishedContent? media = _apiMediaQueryService.GetByPath(path);
if (media is null)
{
return await Task.FromResult(NotFound());
}
return Ok(BuildApiMediaWithCrops(media));
}
}

View File

@@ -48,15 +48,7 @@ public class ByRouteContentApiController : ContentApiItemControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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;

View File

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

View File

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

View File

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

View File

@@ -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;
/// <summary>
/// Gets a paginated list of media 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 media item(s).</returns>
[HttpGet]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedViewModel<ApiMediaWithCropsResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Query(
string? fetch,
[FromQuery] string[] filter,
[FromQuery] string[] sort,
int skip = 0,
int take = 10)
{
Attempt<PagedModel<Guid>, ApiMediaQueryOperationStatus> queryAttempt = _apiMediaQueryService.ExecuteQuery(fetch, filter, sort, skip, take);
if (queryAttempt.Success is false)
{
return ApiMediaQueryOperationStatusResult(queryAttempt.Status);
}
PagedModel<Guid> pagedResult = queryAttempt.Result;
IPublishedContent[] mediaItems = pagedResult.Items.Select(PublishedMediaCache.GetById).WhereNotNull().ToArray();
var model = new PagedViewModel<ApiMediaWithCropsResponse>
{
Total = pagedResult.Total,
Items = mediaItems.Select(BuildApiMediaWithCrops)
};
return await Task.FromResult(Ok(model));
}
}

View File

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

View File

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

View File

@@ -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<ContentApiControllerBase>
{
protected override string DocumentationLink => DeliveryApiConfiguration.ApiDocumentationContentArticleLink;
protected override void ApplyOperation(OpenApiOperation operation, OperationFilterContext context)
{
operation.Parameters ??= new List<OpenApiParameter>();
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<string, OpenApiExample>
{
{ "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<string, OpenApiExample> 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<string, OpenApiExample> 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<string, OpenApiExample> 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")
}
}
}
};
}

View File

@@ -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<OpenApiParameter>();
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<string, OpenApiExample>
{
{ "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<string, OpenApiExample>
{
{ "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<string, OpenApiExample> examples, string description)
{
parameter.Description = QueryParameterDescription(description);
parameter.Examples = examples;
}
private Dictionary<string, OpenApiExample> 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<string, OpenApiExample> 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<string, OpenApiExample> 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")
}
}
}
};
}

View File

@@ -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<TBaseController> : 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<string, OpenApiExample> 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<string, OpenApiExample>
{
{ "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<TBaseController>() is true;
}

View File

@@ -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<MediaApiControllerBase>
{
protected override string DocumentationLink => DeliveryApiConfiguration.ApiDocumentationMediaArticleLink;
protected override void ApplyOperation(OpenApiOperation operation, OperationFilterContext context)
{
operation.Parameters ??= new List<OpenApiParameter>();
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<string, OpenApiExample> 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<string, OpenApiExample> 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<string, OpenApiExample> 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")
}
}
}
};
}

View File

@@ -23,8 +23,13 @@ internal sealed class ApiAccessService : RequestHeaderHandler, IApiAccessService
/// <inheritdoc />
public bool HasPreviewAccess() => IfEnabled(HasValidApiKey);
/// <inheritdoc />
public bool HasMediaAccess() => IfMediaEnabled(() => _deliveryApiSettings is { PublicAccess: true, Media.PublicAccess: true } || HasValidApiKey());
private bool IfEnabled(Func<bool> condition) => _deliveryApiSettings.Enabled && condition();
private bool HasValidApiKey() => _deliveryApiSettings.ApiKey.IsNullOrWhiteSpace() == false
&& _deliveryApiSettings.ApiKey.Equals(GetHeaderValue("Api-Key"));
private bool IfMediaEnabled(Func<bool> condition) => _deliveryApiSettings is { Enabled: true, Media.Enabled: true } && condition();
}

View File

@@ -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;
/// <inheritdoc />
internal sealed class ApiMediaQueryService : IApiMediaQueryService
{
private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor;
private readonly ILogger<ApiMediaQueryService> _logger;
public ApiMediaQueryService(IPublishedSnapshotAccessor publishedSnapshotAccessor, ILogger<ApiMediaQueryService> logger)
{
_publishedSnapshotAccessor = publishedSnapshotAccessor;
_logger = logger;
}
/// <inheritdoc/>
public Attempt<PagedModel<Guid>, ApiMediaQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable<string> filters, IEnumerable<string> sorts, int skip, int take)
{
var emptyResult = new PagedModel<Guid>();
IEnumerable<IPublishedContent>? 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);
}
/// <inheritdoc/>
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<IPublishedContent> 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<IPublishedContent>? 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<IPublishedContent>();
}
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<IPublishedContent>();
}
private IEnumerable<IPublishedContent>? ApplyFilters(IEnumerable<IPublishedContent> source, IEnumerable<string> 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<IPublishedContent>? ApplySorts(IEnumerable<IPublishedContent> source, IEnumerable<string> 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<IPublishedContent, object> 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<PagedModel<Guid>, ApiMediaQueryOperationStatus> PagedResult(IEnumerable<IPublishedContent> children, int skip, int take)
{
IPublishedContent[] childrenAsArray = children as IPublishedContent[] ?? children.ToArray();
var result = new PagedModel<Guid>
{
Total = childrenAsArray.Length,
Items = childrenAsArray.Skip(skip).Take(take).Select(child => child.Key)
};
return Attempt.SucceedWithStatus(ApiMediaQueryOperationStatus.Success, result);
}
}

View File

@@ -48,4 +48,40 @@ public class DeliveryApiSettings
/// <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;
/// <summary>
/// Gets or sets the settings for the Media APIs of the Delivery API.
/// </summary>
public MediaSettings Media { get; set; } = new ();
/// <summary>
/// Typed configuration options for the Media APIs of the Delivery API.
/// </summary>
/// <remarks>
/// The Delivery API settings (as configured in <see cref="DeliveryApiSettings"/>) supersede these settings in levels of restriction.
/// I.e. the Media APIs cannot be enabled, if the Delivery API is disabled.
/// </remarks>
public class MediaSettings
{
/// <summary>
/// Gets or sets a value indicating whether the Media APIs of the Delivery API should be enabled.
/// </summary>
/// <value><c>true</c> if the Media APIs should be enabled; otherwise, <c>false</c>.</value>
/// <remarks>
/// Setting this to <c>true</c> will have no effect if the Delivery API itself is disabled through <see cref="DeliveryApiSettings"/>
/// </remarks>
[DefaultValue(StaticEnabled)]
public bool Enabled { get; set; } = StaticEnabled;
/// <summary>
/// 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.
/// </summary>
/// <value><c>true</c> if the Media APIs should be publicly available; <c>false</c> if an API key should be required for access.</value>
/// <remarks>
/// Setting this to <c>true</c> will have no effect if the Delivery API itself has public access disabled through <see cref="DeliveryApiSettings"/>
/// </remarks>
[DefaultValue(StaticPublicAccess)]
public bool PublicAccess { get; set; } = StaticPublicAccess;
}
}

View File

@@ -11,4 +11,9 @@ public interface IApiAccessService
/// Retrieves information on whether or not the API currently allows preview access.
/// </summary>
bool HasPreviewAccess();
/// <summary>
/// Retrieves information on whether or not the API currently allows access to media.
/// </summary>
bool HasMediaAccess() => false;
}

View File

@@ -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;
/// <summary>
/// Service that handles querying of the Media APIs.
/// </summary>
public interface IApiMediaQueryService
{
/// <summary>
/// Returns an attempt with a collection of media 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 media ids that are returned after applying the search queries in an attempt.</returns>
Attempt<PagedModel<Guid>, ApiMediaQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable<string> filters, IEnumerable<string> sorts, int skip, int take);
/// <summary>
/// Returns the media item that matches the supplied path (if any).
/// </summary>
/// <param name="path">The path to look up.</param>
/// <returns>The media item at <see cref="path"/>, or null if it does not exist.</returns>
IPublishedContent? GetByPath(string path);
}

View File

@@ -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
{
/// <inheritdoc />
public Attempt<PagedModel<Guid>, ApiMediaQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable<string> filters, IEnumerable<string> sorts, int skip, int take)
=> Attempt.SucceedWithStatus(ApiMediaQueryOperationStatus.Success, new PagedModel<Guid>());
/// <inheritdoc />
public IPublishedContent? GetByPath(string path) => null;
}

View File

@@ -0,0 +1,9 @@
namespace Umbraco.Cms.Core.Services.OperationStatus;
public enum ApiMediaQueryOperationStatus
{
Success,
FilterOptionNotFound,
SelectorOptionNotFound,
SortOptionNotFound
}

View File

@@ -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<ApiMediaWithCrops>, IApiMediaWithCropsBuilder
{
public ApiMediaWithCropsBuilder(IApiMediaBuilder apiMediaBuilder, IPublishedValueFallback publishedValueFallback)
: base(apiMediaBuilder, publishedValueFallback)
{
}
protected override ApiMediaWithCrops Create(
IPublishedContent media,
IApiMedia inner,
ImageCropperValue.ImageCropperFocalPoint? focalPoint,
IEnumerable<ImageCropperValue.ImageCropperCrop>? crops) =>
new ApiMediaWithCrops(inner, focalPoint, crops);
}

View File

@@ -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<T>
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<ImageCropperValue.ImageCropperCrop>? 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<ImageCropperValue>(_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);
}
}

View File

@@ -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<ApiMediaWithCropsResponse>, IApiMediaWithCropsResponseBuilder
{
public ApiMediaWithCropsResponseBuilder(IApiMediaBuilder apiMediaBuilder, IPublishedValueFallback publishedValueFallback)
: base(apiMediaBuilder, publishedValueFallback)
{
}
protected override ApiMediaWithCropsResponse Create(
IPublishedContent media,
IApiMedia inner,
ImageCropperValue.ImageCropperFocalPoint? focalPoint,
IEnumerable<ImageCropperValue.ImageCropperCrop>? crops)
{
var path = $"/{string.Join("/", PathSegments(media).Reverse())}/";
return new ApiMediaWithCropsResponse(inner, focalPoint, crops, path, media.CreateDate, media.UpdateDate);
}
private IEnumerable<string> PathSegments(IPublishedContent media)
{
IPublishedContent? current = media;
while (current != null)
{
yield return current.Name.ToLowerInvariant();
current = current.Parent;
}
}
}

View File

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

View File

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

View File

@@ -423,6 +423,8 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IApiContentBuilder, ApiContentBuilder>();
builder.Services.AddSingleton<IApiContentResponseBuilder, ApiContentResponseBuilder>();
builder.Services.AddSingleton<IApiMediaBuilder, ApiMediaBuilder>();
builder.Services.AddSingleton<IApiMediaWithCropsBuilder, ApiMediaWithCropsBuilder>();
builder.Services.AddSingleton<IApiMediaWithCropsResponseBuilder, ApiMediaWithCropsResponseBuilder>();
builder.Services.AddSingleton<IApiContentNameProvider, ApiContentNameProvider>();
builder.Services.AddSingleton<IOutputExpansionStrategyAccessor, NoopOutputExpansionStrategyAccessor>();
builder.Services.AddSingleton<IRequestStartItemProviderAccessor, NoopRequestStartItemProviderAccessor>();
@@ -432,6 +434,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IRequestPreviewService, NoopRequestPreviewService>();
builder.Services.AddSingleton<IApiAccessService, NoopApiAccessService>();
builder.Services.AddSingleton<IApiContentQueryService, NoopApiContentQueryService>();
builder.Services.AddSingleton<IApiMediaQueryService, NoopApiMediaQueryService>();
builder.Services.AddSingleton<IApiMediaUrlProvider, ApiMediaUrlProvider>();
builder.Services.AddSingleton<IApiContentRouteBuilder, ApiContentRouteBuilder>();
builder.Services.AddSingleton<IApiPublishedContentCache, ApiPublishedContentCache>();

View File

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

View File

@@ -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<ImageCropperValue.ImageCropperCrop>? 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; }
}

View File

@@ -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<IApiMediaBuilder>()
StaticServiceProvider.Instance.GetRequiredService<IApiMediaWithCropsBuilder>()
)
{
}
[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<IApiMediaWithCropsBuilder>())
{
}
[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<ImageCropperValue>(_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.

View File

@@ -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<IPublishedValueFallback>();
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<IPublishedValueFallback>(),
CreateOutputExpansionStrategyAccessor()));
apiMediaWithCropsBuilder);
}
[Test]

View File

@@ -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<IPublishedValueFallback>(), new JsonNetSerializer(), mediaBuilder);
var publishedValueFallback = Mock.Of<IPublishedValueFallback>();
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);