diff --git a/src/Umbraco.Core/Constants-HttpContextItemsKeys.cs b/src/Umbraco.Core/Constants-HttpContextItemsKeys.cs new file mode 100644 index 0000000000..7be1fbd140 --- /dev/null +++ b/src/Umbraco.Core/Constants-HttpContextItemsKeys.cs @@ -0,0 +1,19 @@ +namespace Umbraco.Cms.Core +{ + public static partial class Constants + { + public static class HttpContext + { + /// + /// Defines keys for items stored in HttpContext.Items + /// + public static class Items + { + /// + /// Key for current requests body deserialized as JObject. + /// + public const string RequestBodyAsJObject = "RequestBodyAsJObject"; + } + } + } +} diff --git a/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs b/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs index c3f36e92cb..408ae224ab 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs @@ -54,6 +54,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers [ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] [ParameterSwapControllerActionSelector(nameof(GetByIds), "ids", typeof(int[]), typeof(Guid[]), typeof(Udi[]))] [ParameterSwapControllerActionSelector(nameof(GetUrl), "id", typeof(int), typeof(Udi))] + [ParameterSwapControllerActionSelector(nameof(GetUrlsByIds), "ids", typeof(int[]), typeof(Guid[]), typeof(Udi[]))] public class EntityController : UmbracoAuthorizedJsonController { private static readonly string[] _postFilterSplitStrings = { "=", "==", "!=", "<>", ">", "<", ">=", "<=" }; @@ -318,6 +319,145 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers return GetUrl(intId.Result, entityType, culture); } + /// + /// Get entity URLs by IDs + /// + /// + /// A list of IDs to lookup items by + /// + /// The entity type to look for. + /// The culture to fetch the URL for. + /// Dictionary mapping Udi -> Url + /// + /// We allow for POST because there could be quite a lot of Ids. + /// + [HttpGet] + [HttpPost] + public IDictionary GetUrlsByIds([FromJsonPath] int[] ids, [FromQuery] UmbracoEntityTypes type, [FromQuery] string culture = null) + { + if (ids == null || !ids.Any()) + { + return new Dictionary(); + } + + string MediaOrDocumentUrl(int id) + { + switch (type) + { + case UmbracoEntityTypes.Document: + return _publishedUrlProvider.GetUrl(id, culture: culture ?? ClientCulture()); + + case UmbracoEntityTypes.Media: + { + IPublishedContent media = _publishedContentQuery.Media(id); + + // NOTE: If culture is passed here we get an empty string rather than a media item URL. + return _publishedUrlProvider.GetMediaUrl(media, culture: null); + } + + default: + return null; + } + } + + return ids + .Distinct() + .Select(id => new { + Id = id, + Url = MediaOrDocumentUrl(id) + }).ToDictionary(x => x.Id, x => x.Url); + } + + /// + /// Get entity URLs by IDs + /// + /// + /// A list of IDs to lookup items by + /// + /// The entity type to look for. + /// The culture to fetch the URL for. + /// Dictionary mapping Udi -> Url + /// + /// We allow for POST because there could be quite a lot of Ids. + /// + [HttpGet] + [HttpPost] + public IDictionary GetUrlsByIds([FromJsonPath] Guid[] ids, [FromQuery] UmbracoEntityTypes type, [FromQuery] string culture = null) + { + if (ids == null || !ids.Any()) + { + return new Dictionary(); + } + + string MediaOrDocumentUrl(Guid id) + { + return type switch + { + UmbracoEntityTypes.Document => _publishedUrlProvider.GetUrl(id, culture: culture ?? ClientCulture()), + + // NOTE: If culture is passed here we get an empty string rather than a media item URL. + UmbracoEntityTypes.Media => _publishedUrlProvider.GetMediaUrl(id, culture: null), + + _ => null + }; + } + + return ids + .Distinct() + .Select(id => new { + Id = id, + Url = MediaOrDocumentUrl(id) + }).ToDictionary(x => x.Id, x => x.Url); + } + + /// + /// Get entity URLs by IDs + /// + /// + /// A list of IDs to lookup items by + /// + /// The entity type to look for. + /// The culture to fetch the URL for. + /// Dictionary mapping Udi -> Url + /// + /// We allow for POST because there could be quite a lot of Ids. + /// + [HttpGet] + [HttpPost] + public IDictionary GetUrlsByIds([FromJsonPath] Udi[] ids, [FromQuery] UmbracoEntityTypes type, [FromQuery] string culture = null) + { + if (ids == null || !ids.Any()) + { + return new Dictionary(); + } + + // TODO: PMJ 2021-09-27 - Should GetUrl(Udi) exist as an extension method on UrlProvider/IUrlProvider (in v9) + string MediaOrDocumentUrl(Udi id) + { + if (id is not GuidUdi guidUdi) + { + return null; + } + + return type switch + { + UmbracoEntityTypes.Document => _publishedUrlProvider.GetUrl(guidUdi.Guid, culture: culture ?? ClientCulture()), + + // NOTE: If culture is passed here we get an empty string rather than a media item URL. + UmbracoEntityTypes.Media => _publishedUrlProvider.GetMediaUrl(guidUdi.Guid, culture: null), + + _ => null + }; + } + + return ids + .Distinct() + .Select(id => new { + Id = id, + Url = MediaOrDocumentUrl(id) + }).ToDictionary(x => x.Id, x => x.Url); + } + /// /// Get entity URLs by UDIs /// @@ -331,33 +471,31 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers /// [HttpGet] [HttpPost] + [Obsolete("Use GetUrlsByIds instead.")] public IDictionary GetUrlsByUdis([FromJsonPath] Udi[] udis, string culture = null) { - if (udis == null || udis.Length == 0) + if (udis == null || !udis.Any()) { return new Dictionary(); } - // TODO: PMJ 2021-09-27 - Should GetUrl(Udi) exist as an extension method on UrlProvider/IUrlProvider (in v9) - string MediaOrDocumentUrl(Udi udi) - { - if (udi is not GuidUdi guidUdi) - { - return null; - } + var udiEntityType = udis.First().EntityType; + UmbracoEntityTypes entityType; - return guidUdi.EntityType switch - { - Constants.UdiEntityType.Document => _publishedUrlProvider.GetUrl(guidUdi.Guid, - culture: culture ?? ClientCulture()), - // NOTE: If culture is passed here we get an empty string rather than a media item URL WAT - Constants.UdiEntityType.Media => _publishedUrlProvider.GetMediaUrl(guidUdi.Guid, culture: null), - _ => null - }; + switch (udiEntityType) + { + case Constants.UdiEntityType.Document: + entityType = UmbracoEntityTypes.Document; + break; + case Constants.UdiEntityType.Media: + entityType = UmbracoEntityTypes.Media; + break; + default: + entityType = (UmbracoEntityTypes)(-1); + break; } - return udis - .Select(udi => new { Udi = udi, Url = MediaOrDocumentUrl(udi) }).ToDictionary(x => x.Udi, x => x.Url); + return GetUrlsByIds(udis, entityType, culture); } /// diff --git a/src/Umbraco.Web.BackOffice/Controllers/ParameterSwapControllerActionSelectorAttribute.cs b/src/Umbraco.Web.BackOffice/Controllers/ParameterSwapControllerActionSelectorAttribute.cs index ac0a7fea84..3fafd2c1e4 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ParameterSwapControllerActionSelectorAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ParameterSwapControllerActionSelectorAttribute.cs @@ -1,22 +1,48 @@ -using System; +using System; using System.Linq; using System.Net.Http; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.Controllers; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Umbraco.Cms.Core; using Umbraco.Extensions; namespace Umbraco.Cms.Web.BackOffice.Controllers { + /// + /// + /// This attribute is odd because it applies at class level where some methods may use it whilst others don't. + /// + /// + /// + /// What we should probably have (if we really even need something like this at all) is an attribute for method level. + /// + /// + /// + /// + /// [HasParameterFromUriOrBodyOfType("ids", typeof(Guid[]))] + /// public IActionResult GetByIds([FromJsonPath] Guid[] ids) { } + /// + /// [HasParameterFromUriOrBodyOfType("ids", typeof(int[]))] + /// public IActionResult GetByIds([FromJsonPath] int[] ids) { } + /// + /// + /// + /// + /// + /// That way we wouldn't need confusing things like Accept returning true when action name doesn't even match attribute metadata. + /// + /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] internal class ParameterSwapControllerActionSelectorAttribute : Attribute, IActionConstraint { + private readonly string _actionName; private readonly string _parameterName; private readonly Type[] _supportedTypes; - private string _requestBody; public ParameterSwapControllerActionSelectorAttribute(string actionName, string parameterName, params Type[] supportedTypes) { @@ -33,10 +59,12 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers { if (!IsValidCandidate(context.CurrentCandidate)) { + // See remarks on class, required because we apply at class level + // and some controllers have some actions with parameter swaps and others without. return true; } - var chosenCandidate = SelectAction(context); + ActionSelectorCandidate? chosenCandidate = SelectAction(context); var found = context.CurrentCandidate.Equals(chosenCandidate); return found; @@ -49,23 +77,45 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers return candidate; } + HttpContext httpContext = context.RouteContext.HttpContext; + // if it's a post we can try to read from the body and bind from the json value - if (context.RouteContext.HttpContext.Request.Method == HttpMethod.Post.ToString()) + if (context.RouteContext.HttpContext.Request.Method.Equals(HttpMethod.Post.Method)) { - // We need to use the asynchronous method here if synchronous IO is not allowed (it may or may not be, depending - // on configuration in UmbracoBackOfficeServiceCollectionExtensions.AddUmbraco()). - // We can't use async/await due to the need to override IsValidForRequest, which doesn't have an async override, so going with - // this, which seems to be the least worst option for "sync to async" (https://stackoverflow.com/a/32429753/489433). - var strJson = _requestBody ??= Task.Run(() => context.RouteContext.HttpContext.Request.GetRawBodyStringAsync()).GetAwaiter().GetResult(); + JObject postBodyJson; - var json = JsonConvert.DeserializeObject(strJson); + if (httpContext.Items.TryGetValue(Constants.HttpContext.Items.RequestBodyAsJObject, out var value) && value is JObject cached) + { + postBodyJson = cached; + } + else + { + // We need to use the asynchronous method here if synchronous IO is not allowed (it may or may not be, depending + // on configuration in UmbracoBackOfficeServiceCollectionExtensions.AddUmbraco()). + // We can't use async/await due to the need to override IsValidForRequest, which doesn't have an async override, so going with + // this, which seems to be the least worst option for "sync to async" (https://stackoverflow.com/a/32429753/489433). + // + // To expand on the above, if KestrelServerOptions/IISServerOptions is AllowSynchronousIO=false + // And you attempt to read stream sync an InvalidOperationException is thrown with message + // "Synchronous operations are disallowed. Call ReadAsync or set AllowSynchronousIO to true instead." + var rawBody = Task.Run(() => httpContext.Request.GetRawBodyStringAsync()).GetAwaiter().GetResult(); + try + { + postBodyJson = JsonConvert.DeserializeObject(rawBody); + httpContext.Items[Constants.HttpContext.Items.RequestBodyAsJObject] = postBodyJson; + } + catch (JsonException) + { + postBodyJson = null; + } + } - if (json == null) + if (postBodyJson == null) { return null; } - var requestParam = json[_parameterName]; + var requestParam = postBodyJson[_parameterName]; if (requestParam != null) { @@ -85,11 +135,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers } } } - catch (JsonReaderException) - { - // can't convert - } - catch (JsonSerializationException) + catch (JsonException) { // can't convert } diff --git a/src/Umbraco.Web.BackOffice/ModelBinders/FromJsonPathAttribute.cs b/src/Umbraco.Web.BackOffice/ModelBinders/FromJsonPathAttribute.cs index a27243714f..f53fbd0b43 100644 --- a/src/Umbraco.Web.BackOffice/ModelBinders/FromJsonPathAttribute.cs +++ b/src/Umbraco.Web.BackOffice/ModelBinders/FromJsonPathAttribute.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Umbraco.Cms.Core; using Umbraco.Extensions; namespace Umbraco.Cms.Web.BackOffice.ModelBinders @@ -36,6 +37,10 @@ namespace Umbraco.Cms.Web.BackOffice.ModelBinders return; } + if (TryModelBindFromHttpContextItems(bindingContext)) + { + return; + } var strJson = await bindingContext.HttpContext.Request.GetRawBodyStringAsync(); @@ -60,6 +65,30 @@ namespace Umbraco.Cms.Web.BackOffice.ModelBinders bindingContext.Result = ModelBindingResult.Success(model); } + public static bool TryModelBindFromHttpContextItems(ModelBindingContext bindingContext) + { + const string key = Constants.HttpContext.Items.RequestBodyAsJObject; + + if (!bindingContext.HttpContext.Items.TryGetValue(key, out var cached)) + { + return false; + } + + if (cached is not JObject json) + { + return false; + } + + JToken match = json.SelectToken(bindingContext.FieldName); + + // ReSharper disable once InvertIf + if (match != null) + { + bindingContext.Result = ModelBindingResult.Success(match.ToObject(bindingContext.ModelType)); + } + + return true; + } } } } diff --git a/src/Umbraco.Web.UI.Client/src/common/mocks/resources/entity.mocks.js b/src/Umbraco.Web.UI.Client/src/common/mocks/resources/entity.mocks.js index 05594115e1..08c28fcbd1 100644 --- a/src/Umbraco.Web.UI.Client/src/common/mocks/resources/entity.mocks.js +++ b/src/Umbraco.Web.UI.Client/src/common/mocks/resources/entity.mocks.js @@ -34,7 +34,7 @@ angular.module('umbraco.mocks'). return [200, nodes, null]; } - function returnUrlsbyUdis(status, data, headers) { + function returnUrlsByIds(status, data, headers) { if (!mocksUtils.checkAuth()) { return [401, null, null]; @@ -83,8 +83,8 @@ angular.module('umbraco.mocks'). .respond(returnEntitybyIdsPost); $httpBackend - .whenPOST(mocksUtils.urlRegex('/umbraco/UmbracoApi/Entity/GetUrlsByUdis')) - .respond(returnUrlsbyUdis); + .whenPOST(mocksUtils.urlRegex('/umbraco/UmbracoApi/Entity/GetUrlsByIds')) + .respond(returnUrlsByIds); $httpBackend .whenGET(mocksUtils.urlRegex('/umbraco/UmbracoApi/Entity/GetAncestors')) diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js index c6dc313bc7..d94bb4e6be 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js @@ -127,6 +127,24 @@ function entityResource($q, $http, umbRequestHelper) { 'Failed to retrieve url for id:' + id); }, + getUrlsByIds: function(ids, type, culture) { + var query = `type=${type}&culture=${culture || ""}`; + + return umbRequestHelper.resourcePromise( + $http.post( + umbRequestHelper.getApiUrl( + "entityApiBaseUrl", + "GetUrlsByIds", + query), + { + ids: ids + }), + 'Failed to retrieve url map for ids ' + ids); + }, + + /** + * @deprecated use getUrlsByIds instead. + */ getUrlsByUdis: function(udis, culture) { var query = "culture=" + (culture || ""); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js index 1ecd6bdf26..d2a1710e49 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js @@ -421,7 +421,7 @@ function contentPickerController($scope, $q, $routeParams, $location, entityReso var requests = [ entityResource.getByIds(missingIds, entityType), - entityResource.getUrlsByUdis(missingIds) + entityResource.getUrlsByIds(missingIds, entityType) ]; return $q.all(requests).then(function ([data, urlMap]) { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/EntityControllerTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/EntityControllerTests.cs new file mode 100644 index 0000000000..4e4ce29e9a --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/EntityControllerTests.cs @@ -0,0 +1,483 @@ +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Integration.TestServerTest; +using Umbraco.Cms.Web.BackOffice.Controllers; +using Umbraco.Cms.Web.Common.Formatters; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.Controllers +{ + [TestFixture] + public class EntityControllerTests : UmbracoTestServerTestBase + { + [Test] + public async Task GetUrlsByIds_MediaWithIntegerIds_ReturnsValidMap() + { + IMediaTypeService mediaTypeService = Services.GetRequiredService(); + IMediaService mediaService = Services.GetRequiredService(); + + var mediaItems = new List(); + + using (ScopeProvider.CreateScope(autoComplete: true)) + { + IMediaType mediaType = mediaTypeService.Get("image"); + mediaTypeService.Save(mediaType); + + mediaItems.Add(MediaBuilder.CreateMediaImage(mediaType, -1)); + mediaItems.Add(MediaBuilder.CreateMediaImage(mediaType, -1)); + + foreach (Media media in mediaItems) + { + mediaService.Save(media); + } + } + + var queryParameters = new Dictionary + { + ["type"] = Constants.UdiEntityType.Media + }; + + var url = LinkGenerator.GetUmbracoControllerUrl("GetUrlsByIds", typeof(EntityController), queryParameters); + + var payload = new + { + ids = new[] + { + mediaItems[0].Id, + mediaItems[1].Id, + } + }; + + HttpResponseMessage response = await HttpClientJsonExtensions.PostAsJsonAsync(Client, url, payload); + + // skip pointless un-parseable cruft. + (await response.Content.ReadAsStreamAsync()).Seek(AngularJsonMediaTypeFormatter.XsrfPrefix.Length, SeekOrigin.Begin); + + IDictionary results = await response.Content.ReadFromJsonAsync>(); + + Assert.Multiple(() => + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.IsTrue(results![payload.ids[0]].StartsWith("/media")); + Assert.IsTrue(results![payload.ids[1]].StartsWith("/media")); + }); + } + + [Test] + public async Task GetUrlsByIds_Media_ReturnsEmptyStringsInMapForUnknownItems() + { + var queryParameters = new Dictionary + { + ["type"] = Constants.UdiEntityType.Media + }; + + var url = LinkGenerator.GetUmbracoControllerUrl("GetUrlsByIds", typeof(EntityController), queryParameters); + + var payload = new + { + ids = new[] { 1, 2 } + }; + + HttpResponseMessage response = await HttpClientJsonExtensions.PostAsJsonAsync(Client, url, payload); + + // skip pointless un-parseable cruft. + (await response.Content.ReadAsStreamAsync()).Seek(AngularJsonMediaTypeFormatter.XsrfPrefix.Length, SeekOrigin.Begin); + + IDictionary results = await response.Content.ReadFromJsonAsync>(); + + Assert.Multiple(() => + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.That(results!.Keys.Count, Is.EqualTo(2)); + Assert.AreEqual(results![payload.ids[0]], string.Empty); + }); + } + + [Test] + public async Task GetUrlsByIds_MediaWithGuidIds_ReturnsValidMap() + { + IMediaTypeService mediaTypeService = Services.GetRequiredService(); + IMediaService mediaService = Services.GetRequiredService(); + + var mediaItems = new List(); + + using (ScopeProvider.CreateScope(autoComplete: true)) + { + IMediaType mediaType = mediaTypeService.Get("image"); + mediaTypeService.Save(mediaType); + + mediaItems.Add(MediaBuilder.CreateMediaImage(mediaType, -1)); + mediaItems.Add(MediaBuilder.CreateMediaImage(mediaType, -1)); + + foreach (Media media in mediaItems) + { + mediaService.Save(media); + } + } + + var queryParameters = new Dictionary + { + ["type"] = Constants.UdiEntityType.Media + }; + + var url = LinkGenerator.GetUmbracoControllerUrl("GetUrlsByIds", typeof(EntityController), queryParameters); + + var payload = new + { + ids = new[] + { + mediaItems[0].Key.ToString(), + mediaItems[1].Key.ToString(), + } + }; + + HttpResponseMessage response = await HttpClientJsonExtensions.PostAsJsonAsync(Client, url, payload); + + // skip pointless un-parseable cruft. + (await response.Content.ReadAsStreamAsync()).Seek(AngularJsonMediaTypeFormatter.XsrfPrefix.Length, SeekOrigin.Begin); + + IDictionary results = await response.Content.ReadFromJsonAsync>(); + + Assert.Multiple(() => + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.IsTrue(results![payload.ids[0]].StartsWith("/media")); + Assert.IsTrue(results![payload.ids[1]].StartsWith("/media")); + }); + } + + [Test] + public async Task GetUrlsByIds_MediaWithUdiIds_ReturnsValidMap() + { + IMediaTypeService mediaTypeService = Services.GetRequiredService(); + IMediaService mediaService = Services.GetRequiredService(); + + var mediaItems = new List(); + + using (ScopeProvider.CreateScope(autoComplete: true)) + { + IMediaType mediaType = mediaTypeService.Get("image"); + mediaTypeService.Save(mediaType); + + mediaItems.Add(MediaBuilder.CreateMediaImage(mediaType, -1)); + mediaItems.Add(MediaBuilder.CreateMediaImage(mediaType, -1)); + + foreach (Media media in mediaItems) + { + mediaService.Save(media); + } + } + + var queryParameters = new Dictionary + { + ["type"] = Constants.UdiEntityType.Media + }; + + var url = LinkGenerator.GetUmbracoControllerUrl("GetUrlsByIds", typeof(EntityController), queryParameters); + + var payload = new + { + ids = new[] + { + mediaItems[0].GetUdi().ToString(), + mediaItems[1].GetUdi().ToString(), + } + }; + + HttpResponseMessage response = await HttpClientJsonExtensions.PostAsJsonAsync(Client, url, payload); + + // skip pointless un-parseable cruft. + (await response.Content.ReadAsStreamAsync()).Seek(AngularJsonMediaTypeFormatter.XsrfPrefix.Length, SeekOrigin.Begin); + + IDictionary results = await response.Content.ReadFromJsonAsync>(); + + Assert.Multiple(() => + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.IsTrue(results![payload.ids[0]].StartsWith("/media")); + Assert.IsTrue(results![payload.ids[1]].StartsWith("/media")); + }); + } + + [Test] + public async Task GetUrlsByIds_Documents_ReturnsHashesInMapForUnknownItems() + { + var queryParameters = new Dictionary + { + ["type"] = Constants.UdiEntityType.Document + }; + + var url = LinkGenerator.GetUmbracoControllerUrl("GetUrlsByIds", typeof(EntityController), queryParameters); + + var payload = new + { + ids = new[] { 1, 2 } + }; + + HttpResponseMessage response = await HttpClientJsonExtensions.PostAsJsonAsync(Client, url, payload); + + // skip pointless un-parseable cruft. + (await response.Content.ReadAsStreamAsync()).Seek(AngularJsonMediaTypeFormatter.XsrfPrefix.Length, SeekOrigin.Begin); + + IDictionary results = await response.Content.ReadFromJsonAsync>(); + + Assert.Multiple(() => + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.That(results!.Keys.Count, Is.EqualTo(2)); + Assert.AreEqual(results![payload.ids[0]], "#"); + }); + } + + [Test] + public async Task GetUrlsByIds_DocumentWithIntIds_ReturnsValidMap() + { + IContentTypeService contentTypeService = Services.GetRequiredService(); + IContentService contentService = Services.GetRequiredService(); + + var contentItems = new List(); + + using (ScopeProvider.CreateScope(autoComplete: true)) + { + IContentType contentType = ContentTypeBuilder.CreateBasicContentType(); + contentTypeService.Save(contentType); + + ContentBuilder builder = new ContentBuilder() + .WithContentType(contentType); + + Content root = builder.WithName("foo").Build(); + contentService.SaveAndPublish(root); + + contentItems.Add(builder.WithParent(root).WithName("bar").Build()); + contentItems.Add(builder.WithParent(root).WithName("baz").Build()); + + foreach (IContent content in contentItems) + { + contentService.SaveAndPublish(content); + } + } + + var queryParameters = new Dictionary + { + ["type"] = Constants.UdiEntityType.Document + }; + + var url = LinkGenerator.GetUmbracoControllerUrl("GetUrlsByIds", typeof(EntityController), queryParameters); + + var payload = new + { + ids = new[] + { + contentItems[0].Id, + contentItems[1].Id, + } + }; + + HttpResponseMessage response = await HttpClientJsonExtensions.PostAsJsonAsync(Client, url, payload); + + // skip pointless un-parseable cruft. + (await response.Content.ReadAsStreamAsync()).Seek(AngularJsonMediaTypeFormatter.XsrfPrefix.Length, SeekOrigin.Begin); + + IDictionary results = await response.Content.ReadFromJsonAsync>(); + + Assert.Multiple(() => + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.IsTrue(results![payload.ids[0]].StartsWith("/bar")); + Assert.IsTrue(results![payload.ids[1]].StartsWith("/baz")); + }); + } + + [Test] + public async Task GetUrlsByIds_DocumentWithGuidIds_ReturnsValidMap() + { + IContentTypeService contentTypeService = Services.GetRequiredService(); + IContentService contentService = Services.GetRequiredService(); + + var contentItems = new List(); + + using (ScopeProvider.CreateScope(autoComplete: true)) + { + IContentType contentType = ContentTypeBuilder.CreateBasicContentType(); + contentTypeService.Save(contentType); + + ContentBuilder builder = new ContentBuilder() + .WithContentType(contentType); + + Content root = builder.WithName("foo").Build(); + contentService.SaveAndPublish(root); + + contentItems.Add(builder.WithParent(root).WithName("bar").Build()); + contentItems.Add(builder.WithParent(root).WithName("baz").Build()); + + foreach (IContent content in contentItems) + { + contentService.SaveAndPublish(content); + } + } + + var queryParameters = new Dictionary + { + ["type"] = Constants.UdiEntityType.Document + }; + + var url = LinkGenerator.GetUmbracoControllerUrl("GetUrlsByIds", typeof(EntityController), queryParameters); + + var payload = new + { + ids = new[] + { + contentItems[0].Key.ToString(), + contentItems[1].Key.ToString(), + } + }; + + HttpResponseMessage response = await HttpClientJsonExtensions.PostAsJsonAsync(Client, url, payload); + + // skip pointless un-parseable cruft. + (await response.Content.ReadAsStreamAsync()).Seek(AngularJsonMediaTypeFormatter.XsrfPrefix.Length, SeekOrigin.Begin); + + IDictionary results = await response.Content.ReadFromJsonAsync>(); + + Assert.Multiple(() => + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.IsTrue(results![payload.ids[0]].StartsWith("/bar")); + Assert.IsTrue(results![payload.ids[1]].StartsWith("/baz")); + }); + } + + [Test] + public async Task GetUrlsByIds_DocumentWithUdiIds_ReturnsValidMap() + { + IContentTypeService contentTypeService = Services.GetRequiredService(); + IContentService contentService = Services.GetRequiredService(); + + var contentItems = new List(); + + using (ScopeProvider.CreateScope(autoComplete: true)) + { + IContentType contentType = ContentTypeBuilder.CreateBasicContentType(); + contentTypeService.Save(contentType); + + ContentBuilder builder = new ContentBuilder() + .WithContentType(contentType); + + Content root = builder.WithName("foo").Build(); + contentService.SaveAndPublish(root); + + contentItems.Add(builder.WithParent(root).WithName("bar").Build()); + contentItems.Add(builder.WithParent(root).WithName("baz").Build()); + + foreach (IContent content in contentItems) + { + contentService.SaveAndPublish(content); + } + } + + var queryParameters = new Dictionary + { + ["type"] = Constants.UdiEntityType.Document + }; + + var url = LinkGenerator.GetUmbracoControllerUrl("GetUrlsByIds", typeof(EntityController), queryParameters); + + var payload = new + { + ids = new[] + { + contentItems[0].GetUdi().ToString(), + contentItems[1].GetUdi().ToString(), + } + }; + + HttpResponseMessage response = await HttpClientJsonExtensions.PostAsJsonAsync(Client, url, payload); + + // skip pointless un-parseable cruft. + (await response.Content.ReadAsStreamAsync()).Seek(AngularJsonMediaTypeFormatter.XsrfPrefix.Length, SeekOrigin.Begin); + + IDictionary results = await response.Content.ReadFromJsonAsync>(); + + Assert.Multiple(() => + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.IsTrue(results![payload.ids[0]].StartsWith("/bar")); + Assert.IsTrue(results![payload.ids[1]].StartsWith("/baz")); + }); + } + + [Test] + public async Task GetByIds_MultipleCalls_WorksAsExpected() + { + IContentTypeService contentTypeService = Services.GetRequiredService(); + IContentService contentService = Services.GetRequiredService(); + + var contentItems = new List(); + + using (ScopeProvider.CreateScope(autoComplete: true)) + { + IContentType contentType = ContentTypeBuilder.CreateBasicContentType(); + contentTypeService.Save(contentType); + + ContentBuilder builder = new ContentBuilder() + .WithContentType(contentType); + + Content root = builder.WithName("foo").Build(); + contentService.SaveAndPublish(root); + + contentItems.Add(builder.WithParent(root).WithName("bar").Build()); + contentItems.Add(builder.WithParent(root).WithName("baz").Build()); + + foreach (IContent content in contentItems) + { + contentService.SaveAndPublish(content); + } + } + + var queryParameters = new Dictionary + { + ["type"] = Constants.UdiEntityType.Document + }; + + var url = LinkGenerator.GetUmbracoControllerUrl("GetByIds", typeof(EntityController), queryParameters); + + var udiPayload = new + { + ids = new[] + { + contentItems[0].GetUdi().ToString(), + contentItems[1].GetUdi().ToString(), + } + }; + + var intPayload = new + { + ids = new[] + { + contentItems[0].Id, + contentItems[1].Id, + } + }; + + HttpResponseMessage udiResponse = await HttpClientJsonExtensions.PostAsJsonAsync(Client, url, udiPayload); + HttpResponseMessage intResponse = await HttpClientJsonExtensions.PostAsJsonAsync(Client, url, intPayload); + + Assert.Multiple(() => + { + Assert.AreEqual(HttpStatusCode.OK, udiResponse.StatusCode, "First request error"); + Assert.AreEqual(HttpStatusCode.OK, intResponse.StatusCode, "Second request error"); + }); + } + } +}