1

I'm using AngularJS to manipulate a fairly complex parent object with children that need to behave quite differently server-side. Based on this answer, which appears pretty solid, I've created the test case below. The issue I'm running into is that whenever I enter the CreateModel function, any call to bindingContext.ValueProvider.GetValue(key) returns null. I've checked all values in the debugger. The object type appears to be loaded, but no values have yet been bound.

My Models:

public class Menagerie
{
    public Menagerie()
    {
        Critters = new List<Creature>();
    }

    public string MakeNoise()
    {
        return String.Join(" ", Critters.Select(c => c.MakeNoise()));
    }

    public List<Creature> Critters { get; set; }
}

public class Tiger : Creature
{
    public Tiger() { }
    public override CreatureType Type => CreatureType.Tiger;
    public override string Sound => "ROAR";
}

public class Kitty : Creature
{
    public Kitty() { }
    public override CreatureType Type => CreatureType.Kitty;
    public override string Sound => "meow";
}

public class Creature
{
    public Creature() { }
    public string Name { get; set; }
    public virtual CreatureType Type { get; set; }
    public virtual string Sound { get; }
    public string MakeNoise()
    {
        return $"{Name} says {Sound ?? "nothing"}.";
    }

    public static Type SelectFor(CreatureType type)
    {
        switch (type)
        {
            case CreatureType.Tiger:
                return typeof(Tiger);
            case CreatureType.Kitty:
                return typeof(Kitty);
            default:
                throw new Exception();
        }
    }
}

public enum CreatureType
{
    Tiger,
    Kitty,
}

public class CreatureModelBinder : DefaultModelBinder
{
    protected override object CreateModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Type modelType)
    {
        CreatureType creatureType = GetValue<CreatureType>(bindingContext, "Type");
        Type model = Creature.SelectFor(creatureType);
        Creature instance = (Creature)base.CreateModel(controllerContext, bindingContext, model);
        bindingContext.ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => instance, model);
        return instance;
    }

    private T GetValue<T>(ModelBindingContext bindingContext, string key)
    {
        ValueProviderResult valueResult = bindingContext.ValueProvider.GetValue(key); // valueResult is null
        bindingContext.ModelState.SetModelValue(key, valueResult);
        return (T)valueResult.ConvertTo(typeof(T)); // NullReferenceException
    }
}

My script:

(function () {
    'use strict';

    angular.module('app', []).controller('CritterController', ['$scope', '$http', function ($scope, $http) {

        $http.post('/a/admin/get-menagerie', { }).success(function (data) {
            $scope.menagerie = data.menagerie;
        });

        $scope.makeNoise = function () {
            $http.post('/a/admin/make-noise', { menagerie: $scope.menagerie }).success(function (data) {
                $scope.message = data.message;
            });
        }
    }]);

})();

Things I've tried

I've tried just using a string to indicate the class name, as in this answer and this one. However, the call to bindingContext.ValueProvider.GetValue(key) still returns null, resulting in a NullReferenceException.

I also checked to ensure the model is binding properly. When I change my CreatureModelBinder to the following, everything maps fine. But each creature loses its inherited type and becomes a Creature.

public class CreatureModelBinder : DefaultModelBinder
{
    protected override object CreateModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Type modelType)
    {
        return base.CreateModel(controllerContext, bindingContext, modelType);
    }
} // MakeNoise returns: "Shere Khan says nothing. Mr. Boots says nothing. Dr. Evil says nothing."
snumpy
  • 2,718
  • 5
  • 22
  • 38

1 Answers1

0

The solution I was hoping existed:

When posting JSON data, it appears that all keys have the model name prepended. This can be solved simply by changing the first line of CreateModel to:

CreatureType creatureType = GetValue<CreatureType>(bindingContext, bindingContext.ModelName + ".Type");

Previous Answer:

The data my AngularJS function posts is labeled by Chrome as "Request Payload", which cannot (as far as I can tell) be accessed in CreateModel. When I implemented @DarinDimitrov's solutions line-for-line, the data was posted as "Form Data", which was available in CreateModel. I found a little more info about the difference between data types here, though AngularJS doesn't appear to be able to send data with a content-type other than application/json without some fancy acrobatics.

I did, however, find that I can access an instance of my object (and create a new instance with a different type) in the BindModel function as follows:

public class CreatureModelBinder : DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        Creature instance = (Creature)base.BindModel(controllerContext, bindingContext);
        Creature newInstance = (Creature)Activator.CreateInstance(Creature.SelectFor((instance).Type));
        newInstance.Name = instance.Name;

        return newInstance;
    }
} // MakeNoise() returns: "Shere Khan says ROAR. Mr. Boots says meow. Dr. Evil says meow."

The greatest downside I can see is that every property in the object must be manually mapped to the new instance, which can become cumbersome and produce hard-to-track bugs.

This is what I'm using for now, though I'm open to suggestions if anyone can provide a more elegant solution.

EDIT

I found that when posting as application/json, the model data can be accessed using this:

snumpy
  • 2,718
  • 5
  • 22
  • 38