Merge pull request #8080 from AndyButland/feature/7991-netcore-content-model-binder
Netcore: Implemented content model binder.
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Umbraco.Tests.Common\Umbraco.Tests.Common.csproj" />
|
||||
<ProjectReference Include="..\Umbraco.Web.Common\Umbraco.Web.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Core;
|
||||
using Umbraco.Core.Models.PublishedContent;
|
||||
using Umbraco.Web.Common.ModelBinders;
|
||||
using Umbraco.Web.Models;
|
||||
|
||||
namespace Umbraco.Tests.UnitTests.Umbraco.Web.Common
|
||||
{
|
||||
[TestFixture]
|
||||
public class ContentModelBinderTests
|
||||
{
|
||||
[Test]
|
||||
public void Does_Not_Bind_Model_When_UmbracoDataToken_Not_In_Route_Data()
|
||||
{
|
||||
// Arrange
|
||||
var bindingContext = CreateBindingContext(typeof(ContentModel), withUmbracoDataToken: false);
|
||||
var binder = new ContentModelBinder();
|
||||
|
||||
// Act
|
||||
binder.BindModelAsync(bindingContext);
|
||||
|
||||
// Assert
|
||||
Assert.False(bindingContext.Result.IsModelSet);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Does_Not_Bind_Model_When_Source_Not_Of_Expected_Type()
|
||||
{
|
||||
// Arrange
|
||||
var bindingContext = CreateBindingContext(typeof(ContentModel), source: new NonContentModel());
|
||||
var binder = new ContentModelBinder();
|
||||
|
||||
// Act
|
||||
binder.BindModelAsync(bindingContext);
|
||||
|
||||
// Assert
|
||||
Assert.False(bindingContext.Result.IsModelSet);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Does_Not_Bind_Model_When_Source_Type_Matches_Model_Type()
|
||||
{
|
||||
// Arrange
|
||||
var bindingContext = CreateBindingContext(typeof(ContentModel), source: new ContentModel(CreatePublishedContent()));
|
||||
var binder = new ContentModelBinder();
|
||||
|
||||
// Act
|
||||
binder.BindModelAsync(bindingContext);
|
||||
|
||||
// Assert
|
||||
Assert.False(bindingContext.Result.IsModelSet);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Binds_From_IPublishedContent_To_Content_Model()
|
||||
{
|
||||
// Arrange
|
||||
var bindingContext = CreateBindingContext(typeof(ContentModel), source: CreatePublishedContent());
|
||||
var binder = new ContentModelBinder();
|
||||
|
||||
// Act
|
||||
binder.BindModelAsync(bindingContext);
|
||||
|
||||
// Assert
|
||||
Assert.True(bindingContext.Result.IsModelSet);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Binds_From_IPublishedContent_To_Content_Model_Of_T()
|
||||
{
|
||||
// Arrange
|
||||
var bindingContext = CreateBindingContext(typeof(ContentModel<ContentType1>), source: new ContentModel<ContentType2>(new ContentType2(CreatePublishedContent())));
|
||||
var binder = new ContentModelBinder();
|
||||
|
||||
// Act
|
||||
binder.BindModelAsync(bindingContext);
|
||||
|
||||
// Assert
|
||||
Assert.True(bindingContext.Result.IsModelSet);
|
||||
}
|
||||
|
||||
private ModelBindingContext CreateBindingContext(Type modelType, bool withUmbracoDataToken = true, object source = null)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
var routeData = new RouteData();
|
||||
if (withUmbracoDataToken)
|
||||
{
|
||||
routeData.DataTokens.Add(Constants.Web.UmbracoDataToken, source);
|
||||
}
|
||||
|
||||
var actionContext = new ActionContext(httpContext, routeData, new ActionDescriptor());
|
||||
var metadataProvider = new EmptyModelMetadataProvider();
|
||||
var routeValueDictionary = new RouteValueDictionary();
|
||||
var valueProvider = new RouteValueProvider(BindingSource.Path, routeValueDictionary);
|
||||
return new DefaultModelBindingContext
|
||||
{
|
||||
ActionContext = actionContext,
|
||||
ModelMetadata = metadataProvider.GetMetadataForType(modelType),
|
||||
ModelName = modelType.Name,
|
||||
ValueProvider = valueProvider,
|
||||
};
|
||||
}
|
||||
|
||||
private class NonContentModel
|
||||
{
|
||||
}
|
||||
|
||||
private IPublishedContent CreatePublishedContent()
|
||||
{
|
||||
return new ContentType2(new Mock<IPublishedContent>().Object);
|
||||
}
|
||||
|
||||
public class ContentType1 : PublishedContentWrapped
|
||||
{
|
||||
public ContentType1(IPublishedContent content) : base(content) { }
|
||||
}
|
||||
|
||||
public class ContentType2 : ContentType1
|
||||
{
|
||||
public ContentType2(IPublishedContent content) : base(content) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Umbraco.Core;
|
||||
using Umbraco.Core.Configuration;
|
||||
using Umbraco.Core.Models.PublishedContent;
|
||||
using Umbraco.Web.Common.ModelBinders;
|
||||
|
||||
namespace Umbraco.Web.Common.Filters
|
||||
{
|
||||
/// <summary>
|
||||
/// An exception filter checking if we get a <see cref="ModelBindingException" /> or <see cref="InvalidCastException" /> with the same model.
|
||||
/// In which case it returns a redirect to the same page after 1 sec if not in debug mode.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is only enabled when running PureLive
|
||||
/// </remarks>
|
||||
internal class ModelBindingExceptionFilter : ActionFilterAttribute, IExceptionFilter
|
||||
{
|
||||
private static readonly Regex _getPublishedModelsTypesRegex = new Regex("Umbraco.Web.PublishedModels.(\\w+)", RegexOptions.Compiled);
|
||||
|
||||
private readonly IExceptionFilterSettings _exceptionFilterSettings;
|
||||
private readonly IPublishedModelFactory _publishedModelFactory;
|
||||
|
||||
public ModelBindingExceptionFilter(IExceptionFilterSettings exceptionFilterSettings, IPublishedModelFactory publishedModelFactory)
|
||||
{
|
||||
_exceptionFilterSettings = exceptionFilterSettings;
|
||||
_publishedModelFactory = publishedModelFactory ?? throw new ArgumentNullException(nameof(publishedModelFactory));
|
||||
}
|
||||
|
||||
public void OnException(ExceptionContext filterContext)
|
||||
{
|
||||
var disabled = _exceptionFilterSettings?.Disabled ?? false;
|
||||
if (_publishedModelFactory.IsLiveFactory()
|
||||
&& !disabled
|
||||
&& !filterContext.ExceptionHandled
|
||||
&& ((filterContext.Exception is ModelBindingException || filterContext.Exception is InvalidCastException)
|
||||
&& IsMessageAboutTheSameModelType(filterContext.Exception.Message)))
|
||||
{
|
||||
filterContext.HttpContext.Response.Headers.Add(HttpResponseHeader.RetryAfter.ToString(), "1");
|
||||
filterContext.Result = new RedirectResult(filterContext.HttpContext.Request.GetEncodedUrl(), false);
|
||||
|
||||
filterContext.ExceptionHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the message is about two models with the same name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Message could be something like:
|
||||
/// <para>
|
||||
/// InvalidCastException:
|
||||
/// [A]Umbraco.Web.PublishedModels.Home cannot be cast to [B]Umbraco.Web.PublishedModels.Home. Type A originates from 'App_Web_all.generated.cs.8f9494c4.rtdigm_z, Version=0.0.0.3, Culture=neutral, PublicKeyToken=null' in the context 'Default' at location 'C:\Users\User\AppData\Local\Temp\Temporary ASP.NET Files\root\c5c63f4d\c168d9d4\App_Web_all.generated.cs.8f9494c4.rtdigm_z.dll'. Type B originates from 'App_Web_all.generated.cs.8f9494c4.rbyqlplu, Version=0.0.0.5, Culture=neutral, PublicKeyToken=null' in the context 'Default' at location 'C:\Users\User\AppData\Local\Temp\Temporary ASP.NET Files\root\c5c63f4d\c168d9d4\App_Web_all.generated.cs.8f9494c4.rbyqlplu.dll'.
|
||||
///</para>
|
||||
/// <para>
|
||||
/// ModelBindingException:
|
||||
/// Cannot bind source content type Umbraco.Web.PublishedModels.Home to model type Umbraco.Web.PublishedModels.Home. Both view and content models are PureLive, with different versions. The application is in an unstable state and is going to be restarted. The application is restarting now.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool IsMessageAboutTheSameModelType(string exceptionMessage)
|
||||
{
|
||||
var matches = _getPublishedModelsTypesRegex.Matches(exceptionMessage);
|
||||
|
||||
if (matches.Count >= 2)
|
||||
{
|
||||
return string.Equals(matches[0].Value, matches[1].Value, StringComparison.InvariantCulture);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
192
src/Umbraco.Web.Common/ModelBinders/ContentModelBinder.cs
Normal file
192
src/Umbraco.Web.Common/ModelBinders/ContentModelBinder.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Umbraco.Core;
|
||||
using Umbraco.Core.Models.PublishedContent;
|
||||
using Umbraco.Web.Models;
|
||||
|
||||
namespace Umbraco.Web.Common.ModelBinders
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps view models, supporting mapping to and from any IPublishedContent or IContentModel.
|
||||
/// </summary>
|
||||
public class ContentModelBinder : IModelBinder
|
||||
{
|
||||
public Task BindModelAsync(ModelBindingContext bindingContext)
|
||||
{
|
||||
if (bindingContext.ActionContext.RouteData.DataTokens.TryGetValue(Core.Constants.Web.UmbracoDataToken, out var source) == false)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// This model binder deals with IContentModel and IPublishedContent by extracting the model from the route's
|
||||
// datatokens. This data token is set in 2 places: RenderRouteHandler, UmbracoVirtualNodeRouteHandler
|
||||
// and both always set the model to an instance of `ContentModel`.
|
||||
|
||||
// No need for type checks to ensure we have the appropriate binder, as in .NET Core this is handled in the provider,
|
||||
// in this case ContentModelBinderProvider.
|
||||
|
||||
// Being defensice though.... if for any reason the model is not either IContentModel or IPublishedContent,
|
||||
// then we return since those are the only types this binder is dealing with.
|
||||
if (source is IContentModel == false && source is IPublishedContent == false)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
BindModelAsync(bindingContext, source, bindingContext.ModelType);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// source is the model that we have
|
||||
// modelType is the type of the model that we need to bind to
|
||||
//
|
||||
// create a model object of the modelType by mapping:
|
||||
// { ContentModel, ContentModel<TContent>, IPublishedContent }
|
||||
// to
|
||||
// { ContentModel, ContentModel<TContent>, IPublishedContent }
|
||||
//
|
||||
public Task BindModelAsync(ModelBindingContext bindingContext, object source, Type modelType)
|
||||
{
|
||||
// Null model, return
|
||||
if (source == null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// If types already match, return
|
||||
var sourceType = source.GetType();
|
||||
if (sourceType.Inherits(modelType)) // includes ==
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Try to grab the content
|
||||
var sourceContent = source as IPublishedContent; // check if what we have is an IPublishedContent
|
||||
if (sourceContent == null && sourceType.Implements<IContentModel>())
|
||||
// else check if it's an IContentModel, and get the content
|
||||
sourceContent = ((IContentModel)source).Content;
|
||||
if (sourceContent == null)
|
||||
{
|
||||
// else check if we can convert it to a content
|
||||
var attempt1 = source.TryConvertTo<IPublishedContent>();
|
||||
if (attempt1.Success) sourceContent = attempt1.Result;
|
||||
}
|
||||
|
||||
// If we have a content
|
||||
if (sourceContent != null)
|
||||
{
|
||||
// If model is IPublishedContent, check content type and return
|
||||
if (modelType.Implements<IPublishedContent>())
|
||||
{
|
||||
if (sourceContent.GetType().Inherits(modelType) == false)
|
||||
{
|
||||
ThrowModelBindingException(true, false, sourceContent.GetType(), modelType);
|
||||
}
|
||||
|
||||
bindingContext.Result = ModelBindingResult.Success(sourceContent);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// If model is ContentModel, create and return
|
||||
if (modelType == typeof(ContentModel))
|
||||
{
|
||||
bindingContext.Result = ModelBindingResult.Success(new ContentModel(sourceContent));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// If model is ContentModel<TContent>, check content type, then create and return
|
||||
if (modelType.IsGenericType && modelType.GetGenericTypeDefinition() == typeof(ContentModel<>))
|
||||
{
|
||||
var targetContentType = modelType.GetGenericArguments()[0];
|
||||
if (sourceContent.GetType().Inherits(targetContentType) == false)
|
||||
{
|
||||
ThrowModelBindingException(true, true, sourceContent.GetType(), targetContentType);
|
||||
}
|
||||
|
||||
bindingContext.Result = ModelBindingResult.Success(Activator.CreateInstance(modelType, sourceContent));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
// Last chance : try to convert
|
||||
var attempt2 = source.TryConvertTo(modelType);
|
||||
if (attempt2.Success)
|
||||
{
|
||||
bindingContext.Result = ModelBindingResult.Success(attempt2.Result);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Fail
|
||||
ThrowModelBindingException(false, false, sourceType, modelType);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ThrowModelBindingException(bool sourceContent, bool modelContent, Type sourceType, Type modelType)
|
||||
{
|
||||
var msg = new StringBuilder();
|
||||
|
||||
// prepare message
|
||||
msg.Append("Cannot bind source");
|
||||
if (sourceContent) msg.Append(" content");
|
||||
msg.Append(" type ");
|
||||
msg.Append(sourceType.FullName);
|
||||
msg.Append(" to model");
|
||||
if (modelContent) msg.Append(" content");
|
||||
msg.Append(" type ");
|
||||
msg.Append(modelType.FullName);
|
||||
msg.Append(".");
|
||||
|
||||
// raise event, to give model factories a chance at reporting
|
||||
// the error with more details, and optionally request that
|
||||
// the application restarts.
|
||||
|
||||
var args = new ModelBindingArgs(sourceType, modelType, msg);
|
||||
ModelBindingException?.Invoke(this, args);
|
||||
|
||||
throw new ModelBindingException(msg.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains event data for the <see cref="ModelBindingException"/> event.
|
||||
/// </summary>
|
||||
public class ModelBindingArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ModelBindingArgs"/> class.
|
||||
/// </summary>
|
||||
public ModelBindingArgs(Type sourceType, Type modelType, StringBuilder message)
|
||||
{
|
||||
SourceType = sourceType;
|
||||
ModelType = modelType;
|
||||
Message = message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of the source object.
|
||||
/// </summary>
|
||||
public Type SourceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of the view model.
|
||||
/// </summary>
|
||||
public Type ModelType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message string builder.
|
||||
/// </summary>
|
||||
/// <remarks>Handlers of the event can append text to the message.</remarks>
|
||||
public StringBuilder Message { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the application should restart.
|
||||
/// </summary>
|
||||
public bool Restart { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Occurs on model binding exceptions.
|
||||
/// </summary>
|
||||
public static event EventHandler<ModelBindingArgs> ModelBindingException;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
|
||||
using Umbraco.Core.Models.PublishedContent;
|
||||
using Umbraco.Web.Models;
|
||||
|
||||
namespace Umbraco.Web.Common.ModelBinders
|
||||
{
|
||||
/// <summary>
|
||||
/// The provider for <see cref="ContentModelBinder"/> mapping view models, supporting mapping to and from any IPublishedContent or IContentModel.
|
||||
/// </summary>
|
||||
public class ContentModelBinderProvider : IModelBinderProvider
|
||||
{
|
||||
public IModelBinder GetBinder(ModelBinderProviderContext context)
|
||||
{
|
||||
var modelType = context.Metadata.ModelType;
|
||||
|
||||
// Can bind to ContentModel (exact type match)
|
||||
// or to ContentModel<TContent> (exact generic type match)
|
||||
// or to TContent where TContent : IPublishedContent (any IPublishedContent implementation)
|
||||
if (modelType == typeof(ContentModel) ||
|
||||
(modelType.IsGenericType && modelType.GetGenericTypeDefinition() == typeof(ContentModel<>)) ||
|
||||
typeof(IPublishedContent).IsAssignableFrom(modelType))
|
||||
{
|
||||
return new BinderTypeModelBinder(typeof(ContentModelBinder));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/Umbraco.Web.Common/ModelBinders/ModelBindingException.cs
Normal file
46
src/Umbraco.Web.Common/ModelBinders/ModelBindingException.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace Umbraco.Web.Common.ModelBinders
|
||||
{
|
||||
/// <summary>
|
||||
/// The exception that is thrown when an error occurs while binding a source to a model.
|
||||
/// </summary>
|
||||
/// <seealso cref="Exception" />
|
||||
/// Migrated to .NET Core
|
||||
[Serializable]
|
||||
public class ModelBindingException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ModelBindingException" /> class.
|
||||
/// </summary>
|
||||
public ModelBindingException()
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ModelBindingException" /> class.
|
||||
/// </summary>
|
||||
/// <param name="message">The message that describes the error.</param>
|
||||
public ModelBindingException(string message)
|
||||
: base(message)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ModelBindingException" /> class.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message that explains the reason for the exception.</param>
|
||||
/// <param name="innerException">The exception that is the cause of the current exception, or a null reference (<see langword="Nothing" /> in Visual Basic) if no inner exception is specified.</param>
|
||||
public ModelBindingException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ModelBindingException" /> class.
|
||||
/// </summary>
|
||||
/// <param name="info">The <see cref="T:System.Runtime.Serialization.SerializationInfo" /> that holds the serialized object data about the exception being thrown.</param>
|
||||
/// <param name="context">The <see cref="T:System.Runtime.Serialization.StreamingContext" /> that contains contextual information about the source or destination.</param>
|
||||
protected ModelBindingException(SerializationInfo info, StreamingContext context)
|
||||
: base(info, context)
|
||||
{ }
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,10 @@ using Umbraco.Core.Configuration;
|
||||
using Umbraco.Core.IO;
|
||||
using Umbraco.Core.Logging;
|
||||
using Umbraco.Web.BackOffice.AspNetCore;
|
||||
using Umbraco.Web.Common.AspNetCore;
|
||||
using Umbraco.Web.Common.Extensions;
|
||||
using Umbraco.Web.Website.AspNetCore;
|
||||
using IHostingEnvironment = Umbraco.Core.Hosting.IHostingEnvironment;
|
||||
|
||||
|
||||
namespace Umbraco.Web.UI.BackOffice
|
||||
{
|
||||
public class Startup
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Web;
|
||||
using System.Web.Mvc;
|
||||
@@ -14,6 +12,7 @@ namespace Umbraco.Web.Mvc
|
||||
/// <summary>
|
||||
/// Maps view models, supporting mapping to and from any IPublishedContent or IContentModel.
|
||||
/// </summary>
|
||||
/// Migrated to .NET Core
|
||||
public class ContentModelBinder : DefaultModelBinder, IModelBinderProvider
|
||||
{
|
||||
// use Instance
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace Umbraco.Web.Mvc
|
||||
/// <remarks>
|
||||
/// This is only enabled when running PureLive
|
||||
/// </remarks>
|
||||
/// Migrated to .NET Core
|
||||
internal class ModelBindingExceptionFilter : FilterAttribute, IExceptionFilter
|
||||
{
|
||||
private static readonly Regex GetPublishedModelsTypesRegex = new Regex("Umbraco.Web.PublishedModels.(\\w+)", RegexOptions.Compiled);
|
||||
|
||||
Reference in New Issue
Block a user