Skip to content

Commit 6a2a5d7

Browse files
author
Paul Johnson
authored
Bugfixes for MNTP / EntityController (#11685)
* Added EntityController.GetUrlsByIds support for int & guid + update MNTP Fixes issue with MNTP (for 8.18) in a partial view macro - GH #11631 Renamed GetUrlsByUdis to match, don't do this in v9 as it would be breaking there, instead mark it obsolete. TODO: v9 ensure integration test coverage, more painful here as no WebApplicationFactory. * Added partial test coverage for GetUrlsByIds. This doesn't actually work in the backoffice because of GH #11448 So lets fix that next. * Failing test demonstrating #11448 * Fix for #11448 getByIds doesn't work as expected. ParameterSwapControllerActionSelectorAttribute - cached body survived between requests. * Expand on sync vs async comment for future confused souls. * Might aswell cache parsed json vs string for performance * Make ParameterSwapControllerActionSelector remarks more accurate. * Share deserialized request body between action constraint and model binder * Be more defensive with RequestBodyAsJObject HttpContext item Only store if deserialize success. Don't assume key being present means can cast as JObject. * Nest constant a little deeper. * Final defensive tweak
1 parent b58a0cf commit 6a2a5d7

File tree

8 files changed

+770
-37
lines changed

8 files changed

+770
-37
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace Umbraco.Cms.Core
2+
{
3+
public static partial class Constants
4+
{
5+
public static class HttpContext
6+
{
7+
/// <summary>
8+
/// Defines keys for items stored in HttpContext.Items
9+
/// </summary>
10+
public static class Items
11+
{
12+
/// <summary>
13+
/// Key for current requests body deserialized as JObject.
14+
/// </summary>
15+
public const string RequestBodyAsJObject = "RequestBodyAsJObject";
16+
}
17+
}
18+
}
19+
}

src/Umbraco.Web.BackOffice/Controllers/EntityController.cs

Lines changed: 154 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
5454
[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))]
5555
[ParameterSwapControllerActionSelector(nameof(GetByIds), "ids", typeof(int[]), typeof(Guid[]), typeof(Udi[]))]
5656
[ParameterSwapControllerActionSelector(nameof(GetUrl), "id", typeof(int), typeof(Udi))]
57+
[ParameterSwapControllerActionSelector(nameof(GetUrlsByIds), "ids", typeof(int[]), typeof(Guid[]), typeof(Udi[]))]
5758
public class EntityController : UmbracoAuthorizedJsonController
5859
{
5960
private static readonly string[] _postFilterSplitStrings = { "=", "==", "!=", "<>", ">", "<", ">=", "<=" };
@@ -319,45 +320,182 @@ public IActionResult GetUrl(Udi id, string culture = "*")
319320
}
320321

321322
/// <summary>
322-
/// Get entity URLs by UDIs
323+
/// Get entity URLs by IDs
323324
/// </summary>
324-
/// <param name="udis">
325-
/// A list of UDIs to lookup items by
325+
/// <param name="ids">
326+
/// A list of IDs to lookup items by
326327
/// </param>
327-
/// <param name="culture">The culture to fetch the URL for</param>
328+
/// <param name="type">The entity type to look for.</param>
329+
/// <param name="culture">The culture to fetch the URL for.</param>
328330
/// <returns>Dictionary mapping Udi -> Url</returns>
329331
/// <remarks>
330-
/// We allow for POST because there could be quite a lot of Ids.
332+
/// We allow for POST because there could be quite a lot of Ids.
331333
/// </remarks>
332334
[HttpGet]
333335
[HttpPost]
334-
public IDictionary<Udi, string> GetUrlsByUdis([FromJsonPath] Udi[] udis, string culture = null)
336+
public IDictionary<int, string> GetUrlsByIds([FromJsonPath] int[] ids, [FromQuery] UmbracoEntityTypes type, [FromQuery] string culture = null)
337+
{
338+
if (ids == null || !ids.Any())
339+
{
340+
return new Dictionary<int, string>();
341+
}
342+
343+
string MediaOrDocumentUrl(int id)
344+
{
345+
switch (type)
346+
{
347+
case UmbracoEntityTypes.Document:
348+
return _publishedUrlProvider.GetUrl(id, culture: culture ?? ClientCulture());
349+
350+
case UmbracoEntityTypes.Media:
351+
{
352+
IPublishedContent media = _publishedContentQuery.Media(id);
353+
354+
// NOTE: If culture is passed here we get an empty string rather than a media item URL.
355+
return _publishedUrlProvider.GetMediaUrl(media, culture: null);
356+
}
357+
358+
default:
359+
return null;
360+
}
361+
}
362+
363+
return ids
364+
.Distinct()
365+
.Select(id => new {
366+
Id = id,
367+
Url = MediaOrDocumentUrl(id)
368+
}).ToDictionary(x => x.Id, x => x.Url);
369+
}
370+
371+
/// <summary>
372+
/// Get entity URLs by IDs
373+
/// </summary>
374+
/// <param name="ids">
375+
/// A list of IDs to lookup items by
376+
/// </param>
377+
/// <param name="type">The entity type to look for.</param>
378+
/// <param name="culture">The culture to fetch the URL for.</param>
379+
/// <returns>Dictionary mapping Udi -> Url</returns>
380+
/// <remarks>
381+
/// We allow for POST because there could be quite a lot of Ids.
382+
/// </remarks>
383+
[HttpGet]
384+
[HttpPost]
385+
public IDictionary<Guid, string> GetUrlsByIds([FromJsonPath] Guid[] ids, [FromQuery] UmbracoEntityTypes type, [FromQuery] string culture = null)
335386
{
336-
if (udis == null || udis.Length == 0)
387+
if (ids == null || !ids.Any())
388+
{
389+
return new Dictionary<Guid, string>();
390+
}
391+
392+
string MediaOrDocumentUrl(Guid id)
393+
{
394+
return type switch
395+
{
396+
UmbracoEntityTypes.Document => _publishedUrlProvider.GetUrl(id, culture: culture ?? ClientCulture()),
397+
398+
// NOTE: If culture is passed here we get an empty string rather than a media item URL.
399+
UmbracoEntityTypes.Media => _publishedUrlProvider.GetMediaUrl(id, culture: null),
400+
401+
_ => null
402+
};
403+
}
404+
405+
return ids
406+
.Distinct()
407+
.Select(id => new {
408+
Id = id,
409+
Url = MediaOrDocumentUrl(id)
410+
}).ToDictionary(x => x.Id, x => x.Url);
411+
}
412+
413+
/// <summary>
414+
/// Get entity URLs by IDs
415+
/// </summary>
416+
/// <param name="ids">
417+
/// A list of IDs to lookup items by
418+
/// </param>
419+
/// <param name="type">The entity type to look for.</param>
420+
/// <param name="culture">The culture to fetch the URL for.</param>
421+
/// <returns>Dictionary mapping Udi -> Url</returns>
422+
/// <remarks>
423+
/// We allow for POST because there could be quite a lot of Ids.
424+
/// </remarks>
425+
[HttpGet]
426+
[HttpPost]
427+
public IDictionary<Udi, string> GetUrlsByIds([FromJsonPath] Udi[] ids, [FromQuery] UmbracoEntityTypes type, [FromQuery] string culture = null)
428+
{
429+
if (ids == null || !ids.Any())
337430
{
338431
return new Dictionary<Udi, string>();
339432
}
340433

341434
// TODO: PMJ 2021-09-27 - Should GetUrl(Udi) exist as an extension method on UrlProvider/IUrlProvider (in v9)
342-
string MediaOrDocumentUrl(Udi udi)
435+
string MediaOrDocumentUrl(Udi id)
343436
{
344-
if (udi is not GuidUdi guidUdi)
437+
if (id is not GuidUdi guidUdi)
345438
{
346439
return null;
347440
}
348441

349-
return guidUdi.EntityType switch
442+
return type switch
350443
{
351-
Constants.UdiEntityType.Document => _publishedUrlProvider.GetUrl(guidUdi.Guid,
352-
culture: culture ?? ClientCulture()),
353-
// NOTE: If culture is passed here we get an empty string rather than a media item URL WAT
354-
Constants.UdiEntityType.Media => _publishedUrlProvider.GetMediaUrl(guidUdi.Guid, culture: null),
444+
UmbracoEntityTypes.Document => _publishedUrlProvider.GetUrl(guidUdi.Guid, culture: culture ?? ClientCulture()),
445+
446+
// NOTE: If culture is passed here we get an empty string rather than a media item URL.
447+
UmbracoEntityTypes.Media => _publishedUrlProvider.GetMediaUrl(guidUdi.Guid, culture: null),
448+
355449
_ => null
356450
};
357451
}
358452

359-
return udis
360-
.Select(udi => new { Udi = udi, Url = MediaOrDocumentUrl(udi) }).ToDictionary(x => x.Udi, x => x.Url);
453+
return ids
454+
.Distinct()
455+
.Select(id => new {
456+
Id = id,
457+
Url = MediaOrDocumentUrl(id)
458+
}).ToDictionary(x => x.Id, x => x.Url);
459+
}
460+
461+
/// <summary>
462+
/// Get entity URLs by UDIs
463+
/// </summary>
464+
/// <param name="udis">
465+
/// A list of UDIs to lookup items by
466+
/// </param>
467+
/// <param name="culture">The culture to fetch the URL for</param>
468+
/// <returns>Dictionary mapping Udi -> Url</returns>
469+
/// <remarks>
470+
/// We allow for POST because there could be quite a lot of Ids.
471+
/// </remarks>
472+
[HttpGet]
473+
[HttpPost]
474+
[Obsolete("Use GetUrlsByIds instead.")]
475+
public IDictionary<Udi, string> GetUrlsByUdis([FromJsonPath] Udi[] udis, string culture = null)
476+
{
477+
if (udis == null || !udis.Any())
478+
{
479+
return new Dictionary<Udi, string>();
480+
}
481+
482+
var udiEntityType = udis.First().EntityType;
483+
UmbracoEntityTypes entityType;
484+
485+
switch (udiEntityType)
486+
{
487+
case Constants.UdiEntityType.Document:
488+
entityType = UmbracoEntityTypes.Document;
489+
break;
490+
case Constants.UdiEntityType.Media:
491+
entityType = UmbracoEntityTypes.Media;
492+
break;
493+
default:
494+
entityType = (UmbracoEntityTypes)(-1);
495+
break;
496+
}
497+
498+
return GetUrlsByIds(udis, entityType, culture);
361499
}
362500

363501
/// <summary>

src/Umbraco.Web.BackOffice/Controllers/ParameterSwapControllerActionSelectorAttribute.cs

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,48 @@
1-
using System;
1+
using System;
22
using System.Linq;
33
using System.Net.Http;
44
using System.Threading.Tasks;
5+
using Microsoft.AspNetCore.Http;
56
using Microsoft.AspNetCore.Mvc.ActionConstraints;
67
using Microsoft.AspNetCore.Mvc.Controllers;
78
using Newtonsoft.Json;
89
using Newtonsoft.Json.Linq;
10+
using Umbraco.Cms.Core;
911
using Umbraco.Extensions;
1012

1113
namespace Umbraco.Cms.Web.BackOffice.Controllers
1214
{
15+
/// <remarks>
16+
/// <para>
17+
/// This attribute is odd because it applies at class level where some methods may use it whilst others don't.
18+
/// </para>
19+
///
20+
/// <para>
21+
/// What we should probably have (if we really even need something like this at all) is an attribute for method level.
22+
///
23+
/// <example>
24+
/// <code>
25+
///
26+
/// [HasParameterFromUriOrBodyOfType("ids", typeof(Guid[]))]
27+
/// public IActionResult GetByIds([FromJsonPath] Guid[] ids) { }
28+
///
29+
/// [HasParameterFromUriOrBodyOfType("ids", typeof(int[]))]
30+
/// public IActionResult GetByIds([FromJsonPath] int[] ids) { }
31+
/// </code>
32+
/// </example>
33+
/// </para>
34+
///
35+
/// <para>
36+
/// That way we wouldn't need confusing things like Accept returning true when action name doesn't even match attribute metadata.
37+
/// </para>
38+
/// </remarks>
1339
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
1440
internal class ParameterSwapControllerActionSelectorAttribute : Attribute, IActionConstraint
1541
{
42+
1643
private readonly string _actionName;
1744
private readonly string _parameterName;
1845
private readonly Type[] _supportedTypes;
19-
private string _requestBody;
2046

2147
public ParameterSwapControllerActionSelectorAttribute(string actionName, string parameterName, params Type[] supportedTypes)
2248
{
@@ -33,10 +59,12 @@ public bool Accept(ActionConstraintContext context)
3359
{
3460
if (!IsValidCandidate(context.CurrentCandidate))
3561
{
62+
// See remarks on class, required because we apply at class level
63+
// and some controllers have some actions with parameter swaps and others without.
3664
return true;
3765
}
3866

39-
var chosenCandidate = SelectAction(context);
67+
ActionSelectorCandidate? chosenCandidate = SelectAction(context);
4068

4169
var found = context.CurrentCandidate.Equals(chosenCandidate);
4270
return found;
@@ -49,23 +77,45 @@ public bool Accept(ActionConstraintContext context)
4977
return candidate;
5078
}
5179

80+
HttpContext httpContext = context.RouteContext.HttpContext;
81+
5282
// if it's a post we can try to read from the body and bind from the json value
53-
if (context.RouteContext.HttpContext.Request.Method == HttpMethod.Post.ToString())
83+
if (context.RouteContext.HttpContext.Request.Method.Equals(HttpMethod.Post.Method))
5484
{
55-
// We need to use the asynchronous method here if synchronous IO is not allowed (it may or may not be, depending
56-
// on configuration in UmbracoBackOfficeServiceCollectionExtensions.AddUmbraco()).
57-
// We can't use async/await due to the need to override IsValidForRequest, which doesn't have an async override, so going with
58-
// this, which seems to be the least worst option for "sync to async" (https://stackoverflow.com/a/32429753/489433).
59-
var strJson = _requestBody ??= Task.Run(() => context.RouteContext.HttpContext.Request.GetRawBodyStringAsync()).GetAwaiter().GetResult();
85+
JObject postBodyJson;
6086

61-
var json = JsonConvert.DeserializeObject<JObject>(strJson);
87+
if (httpContext.Items.TryGetValue(Constants.HttpContext.Items.RequestBodyAsJObject, out var value) && value is JObject cached)
88+
{
89+
postBodyJson = cached;
90+
}
91+
else
92+
{
93+
// We need to use the asynchronous method here if synchronous IO is not allowed (it may or may not be, depending
94+
// on configuration in UmbracoBackOfficeServiceCollectionExtensions.AddUmbraco()).
95+
// We can't use async/await due to the need to override IsValidForRequest, which doesn't have an async override, so going with
96+
// this, which seems to be the least worst option for "sync to async" (https://stackoverflow.com/a/32429753/489433).
97+
//
98+
// To expand on the above, if KestrelServerOptions/IISServerOptions is AllowSynchronousIO=false
99+
// And you attempt to read stream sync an InvalidOperationException is thrown with message
100+
// "Synchronous operations are disallowed. Call ReadAsync or set AllowSynchronousIO to true instead."
101+
var rawBody = Task.Run(() => httpContext.Request.GetRawBodyStringAsync()).GetAwaiter().GetResult();
102+
try
103+
{
104+
postBodyJson = JsonConvert.DeserializeObject<JObject>(rawBody);
105+
httpContext.Items[Constants.HttpContext.Items.RequestBodyAsJObject] = postBodyJson;
106+
}
107+
catch (JsonException)
108+
{
109+
postBodyJson = null;
110+
}
111+
}
62112

63-
if (json == null)
113+
if (postBodyJson == null)
64114
{
65115
return null;
66116
}
67117

68-
var requestParam = json[_parameterName];
118+
var requestParam = postBodyJson[_parameterName];
69119

70120
if (requestParam != null)
71121
{
@@ -85,11 +135,7 @@ public bool Accept(ActionConstraintContext context)
85135
}
86136
}
87137
}
88-
catch (JsonReaderException)
89-
{
90-
// can't convert
91-
}
92-
catch (JsonSerializationException)
138+
catch (JsonException)
93139
{
94140
// can't convert
95141
}

0 commit comments

Comments
 (0)