16

I use AutoMapper to map my domain objects to my view models. I have metadata in my domain layer, that I would like to carry over to the view layer and into ModelMetadata. (This metadata is not UI logic, but provides necessary information to my views).

Right now, my solution is to use a separate MetadataProvider (independently of ASP.NET MVC), and use conventions to apply the relevant metadata to the ModelMetadata object via an AssociatedMetadataProvider. The problem with this approach is that I have to test for the same conventions when binding the ModelMetadata from the domain as I do with my AutoMapping, and it seems like there should be a way to make this more orthogonal. Can anyone recommend a better way to accomplish this?

smartcaveman
  • 41,281
  • 29
  • 127
  • 212

3 Answers3

15

I use the approach below to automatically copy data annotations from my entities to my view model. This ensures that things like StringLength and Required values are always the same for entity/viewmodel.

It works using the Automapper configuration, so works if the properties are named differently on the viewmodel as long as AutoMapper is setup correctly.

You need to create a custom ModelValidatorProvider and custom ModelMetadataProvider to get this to work. My memory on why is a little foggy, but I believe it's so both server and client side validation work, as well as any other formatting you do based on the metadata (eg an asterix next to required fields).

Note: I have simplified my code slightly as I added it below, so there may be a few small issues.

Metadata Provider

public class MetadataProvider : DataAnnotationsModelMetadataProvider
{        
    private IConfigurationProvider _mapper;

    public MetadataProvider(IConfigurationProvider mapper)
    {           
        _mapper = mapper;
    }

    protected override System.Web.Mvc.ModelMetadata CreateMetadata(IEnumerable<Attribute> attributes, Type containerType, Func<object> modelAccessor, Type modelType, string propertyName)
    {           
        //Grab attributes from the entity columns and copy them to the view model
        var mappedAttributes = _mapper.GetMappedAttributes(containerType, propertyName, attributes);

        return base.CreateMetadata(mappedAttributes, containerType, modelAccessor, modelType, propertyName);

}
}

Validator Provivder

public class ValidatorProvider : DataAnnotationsModelValidatorProvider
{
    private IConfigurationProvider _mapper;

    public ValidatorProvider(IConfigurationProvider mapper) 
    {
        _mapper = mapper;
    }

    protected override System.Collections.Generic.IEnumerable<ModelValidator> GetValidators(System.Web.Mvc.ModelMetadata metadata, ControllerContext context, IEnumerable<Attribute> attributes)
    {   
        var mappedAttributes = _mapper.GetMappedAttributes(metadata.ContainerType, metadata.PropertyName, attributes);
        return base.GetValidators(metadata, context, mappedAttributes);
    }
}

Helper Method Referenced in above 2 classes

public static IEnumerable<Attribute> GetMappedAttributes(this IConfigurationProvider mapper, Type sourceType, string propertyName, IEnumerable<Attribute> existingAttributes)
{
    if (sourceType != null)
    {
        foreach (var typeMap in mapper.GetAllTypeMaps().Where(i => i.SourceType == sourceType))
        {
            foreach (var propertyMap in typeMap.GetPropertyMaps())
            {
                if (propertyMap.IsIgnored() || propertyMap.SourceMember == null)
                    continue;

                if (propertyMap.SourceMember.Name == propertyName)
                {
                    foreach (ValidationAttribute attribute in propertyMap.DestinationProperty.GetCustomAttributes(typeof(ValidationAttribute), true))
                    {
                        if (!existingAttributes.Any(i => i.GetType() == attribute.GetType()))
                            yield return attribute;
                    }
                }
            }
        }
    }

    if (existingAttributes != null)
    {
        foreach (var attribute in existingAttributes)
        {
            yield return attribute;
        }
    }

}

Other Notes

  • If you're using dependency injection, make sure your container isn't already replacing the built in metadata provider or validator provider. In my case I was using the Ninject.MVC3 package which bound one of them after creating the kernel, I then had to rebind it afterwards so my class was actually used. I was getting exceptions about Required only being allowed to be added once, took most of a day to track it down.
Betty
  • 9,109
  • 2
  • 34
  • 48
  • this is helpful. I have a similar approach that I use right now, but with my own metadata source (not AutoMapper's). It could be extended to do what yours does as well. Help me understand something: You're passing in `metadata.ContainerType` as the source type, but it seems like it would be looking for the type of your business object. This makes me think you are (a) getting ModelMetadata for your business object and copying the view model attributes, or (b) mapping your view models to your business objects with AutoMapper (my use case is the opposite). Can you clear this up? – smartcaveman Apr 11 '12 at 13:56
  • It looks like the `IConfigurationProvider` is the place to work with. Looking at the [source](https://github.com/AutoMapper/AutoMapper/blob/master/src/AutoMapper/IConfigurationProvider.cs), it seems like a better approach for my scenario would be wiring up to `event EventHandler TypeMapCreated;` in my IoC. Have you tried that kind of approach? [It looks like it fires every time a type is created](https://github.com/AutoMapper/AutoMapper/blob/master/src/AutoMapper/ConfigurationStore.cs), so I can hook that into my existing metadata provider – smartcaveman Apr 11 '12 at 14:09
  • I map both directions with AutoMapper. This code is for applying the metadata from the business objects to my viewmodels, however I am using the mappings that go the other direction to find the metadata. No particular reason that I'm aware of, and now that you mention it is does seem a tad odd. – Betty Apr 11 '12 at 20:30
  • I hadn't actually seen that event before. I could use it to create and cache the metadata mappings, however still need both providers for MVC to consume it. I could easily just add caching to my extension method instead so I don't really see the benefit of using the event. If you do end up using it I'd love to hear about it. – Betty Apr 11 '12 at 20:36
  • metadata.ContainerType is the type of the ViewModel. It would be a trivial change to look for that type in the mapping destination instead of the mapping source. – Betty Apr 11 '12 at 20:38
  • Okay, I get what your doing now. I use AutoMapper for uni-directional mapping, so it wasn't immediately clear what you were getting at. – smartcaveman Apr 12 '12 at 18:46
  • Have you figured it all out now, or do you still need some help? – Betty Apr 16 '12 at 05:07
  • I haven't coded it yet, but I'm pretty sure I've got what I need. I haven't selected your answer, because as written, it doesn't directly answer my question, and I'm afraid it would be misleading to other users. However, I'll be happy to select it if you add the additional knowledge from our comment-exchange into your answer. – smartcaveman Apr 16 '12 at 08:12
  • tbh it'd probably be better if you post your own answer once you've coded it up. – Betty Apr 16 '12 at 09:33
  • Trying to use your code sample above, but the compiler (and ReSharper) can't figure out where `GetCustomAttributes(typeof(ValidationAttribute), true)` lives -- is that an AutoMapper extension method that might have gone away in v3.0? – Mr. T Apr 11 '14 at 20:58
  • It's built into .NET System.Reflection.ICustomAttributeProvider.GetCustomAttributes, however it's possible that DestinationProperty isn't an IMemberAccessor anymore. – Betty Apr 13 '14 at 02:50
  • Looks like AutoMapper's `IMemberGetter` interface no longer inherits `ICustomAttributeProvider` - looks like he removed it in Feb of 2013. No idea if there's some other means of getting the same functionality or not, but I'm definitely bummed as I'd like to be able to use your solution! – Mr. T Apr 14 '14 at 13:43
  • 1
    Think I found a way forward -- `DestinationProperty` now has a `MemberInfo` property that is an `ICustomAttributeProvider` so the code changes to `propertyMap.DestinationProperty.MemberInfo.GetCustomAttributes(typeof (ValidationAttribute), true))` – Mr. T Apr 14 '14 at 15:57
  • Could somebody be so kind and demonstrate how to use the solution provided here? I've placed a sample at github https://github.com/draptik/AutoMappingAnnotationsDemo including some very simple tests. – draptik Oct 15 '14 at 13:24
  • @Betty, I've used your example to successfully inherit the Required data annotations. Additionally, I'm implementing IValidatableObject in my domain models and specifying validation rules in the Validate method, but these don't apply. Am I expecting too much!? – Chris Haines Feb 28 '17 at 16:47
  • @ChrisHaines yes this will only copy validation attributes. – Betty Mar 02 '17 at 09:19
  • @Betty I have posted an answer that extends your idea to include this. – Chris Haines Mar 03 '17 at 09:36
  • So close to getting this to work. Getting the "multiple required attributes" error that @Betty mentioned in Other Notes, but I don't quite understand what her solution is. – Jeff Wilson May 12 '17 at 20:55
1

if your metadata are provided with attributes define the attributes in MetaDataTypes, then apply the same MetaDataType to both your domain class and to your viewmodels. You can define all MetaDataTypes in a separate dll that is reference by both layers. There are some issues with this approach if your ViewModel classes have not some properties that is used in the MetaDataType, but this can be fixed with a custom Provider(I have the code if youlike this approach).

Francesco Abbruzzese
  • 4,139
  • 1
  • 17
  • 18
  • The metadata comes from various locations, and I do not currently have any dedicated 'Metadata' classes (nor do I want them). I think this may be a good place for me to look for an extensibility point though. Thanks – smartcaveman Apr 05 '12 at 19:23
  • You might write a kind of "broker" metadata provider that simply rediredt the metadata retrieval from a type to another. Then you might the "redirecting" the ViewModel class to the domain type. – Francesco Abbruzzese Apr 05 '12 at 20:04
  • That's basically what I have going on right now. The problem is that the 'broker' evaluates the ModelMetadata object to get a reference to the Metadata for the mapped Domain Object. And, the logic required to know what the view is a projection of is pretty much the same logic that I use for AutoMapping. I want to get rid of the redundancy. – smartcaveman Apr 05 '12 at 21:34
  • 2
    Give a look to automapper ITypeConverter https://github.com/AutoMapper/AutoMapper/wiki/Custom-type-converters It allows you to customize the mapping. This way you can add the logics to retrieve the metadata from the source type an you can put it into a dictionary indexed by the pair (type/propertyName or PropertyInfo). Then a custom metadata provider access this dictionary to retrieve metadata ... it is a non trivial job ...but it appears to me feasible – Francesco Abbruzzese Apr 06 '12 at 09:28
1

Betty's solution is excellent for "inheriting" data annotations. I have extended this idea to also include validation provided by IValidatableObject.

public class MappedModelValidatorProvider : DataAnnotationsModelValidatorProvider
{
    private readonly IMapper _mapper;

    public MappedModelValidatorProvider(IMapper mapper)
    {
        _mapper = mapper;
    }

    protected override IEnumerable<ModelValidator> GetValidators(ModelMetadata metadata, ControllerContext context, IEnumerable<Attribute> attributes)
    {
        var mappedAttributes = _mapper.ConfigurationProvider.GetMappedAttributes(metadata.ContainerType, metadata.PropertyName, attributes);
        foreach (var validator in base.GetValidators(metadata, context, mappedAttributes))
        {
            yield return validator;
        }
        foreach (var typeMap in _mapper.ConfigurationProvider.GetAllTypeMaps().Where(i => i.SourceType == metadata.ModelType))
        {
            if (typeof(IValidatableObject).IsAssignableFrom(typeMap.DestinationType))
            {
                var model = _mapper.Map(metadata.Model, typeMap.SourceType, typeMap.DestinationType);
                var modelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => model, typeMap.DestinationType);
                yield return new ValidatableObjectAdapter(modelMetadata, context);
            }
        }
    }
}

Then in Global.asax.cs:

ModelValidatorProviders.Providers.Clear();
ModelValidatorProviders.Providers.Add(new MappedModelValidatorProvider(Mapper.Instance));
Chris Haines
  • 6,445
  • 5
  • 49
  • 62