24

UPDATE: for the tl;dr version skip to the bottom


I have a pretty simple subclass of JsonConverter that I'm using with Web API:

public class DbGeographyJsonConverter : JsonConverter
{
    public override bool CanConvert(Type type)
    {
        return typeof(DbGeography).IsAssignableFrom(type);
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        var value = (string)reader.Value;

        if (value.StartsWith("POINT", StringComparison.OrdinalIgnoreCase))
        {
            return DbGeography.PointFromText(value, DbGeography.DefaultCoordinateSystemId);
        }
        else if (value.StartsWith("POLYGON", StringComparison.OrdinalIgnoreCase))
        {
            return DbGeography.FromText(value, DbGeography.DefaultCoordinateSystemId);
        }
        else //We don't want to support anything else right now.
        {
            throw new ArgumentException();
        }
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        serializer.Serialize(writer, ((DbGeography)value).AsText());
    }
}

The problem is, after ReadJson returns the application never returns a bound object to the action method as it appears to be stuck in an infinite validation loop.

Here's the top of the call stack when I pause execution:

System.Web.Http.dll!System.Web.Http.Metadata.Providers.AssociatedMetadataProvider.GetMetadataForPropertiesImpl.AnonymousMethod__0() Line 40 C# System.Web.Http.dll!System.Web.Http.Metadata.ModelMetadata.Model.get() Line 85 C# System.Web.Http.dll!System.Web.Http.Validation.DefaultBodyModelValidator.ValidateNodeAndChildren(System.Web.Http.Metadata.ModelMetadata metadata, System.Web.Http.Validation.DefaultBodyModelValidator.ValidationContext validationContext, object container) Line 94 C# System.Web.Http.dll!System.Web.Http.Validation.DefaultBodyModelValidator.ValidateProperties(System.Web.Http.Metadata.ModelMetadata metadata, System.Web.Http.Validation.DefaultBodyModelValidator.ValidationContext validationContext) Line 156 C# System.Web.Http.dll!System.Web.Http.Validation.DefaultBodyModelValidator.ValidateNodeAndChildren(System.Web.Http.Metadata.ModelMetadata metadata, System.Web.Http.Validation.DefaultBodyModelValidator.ValidationContext validationContext, object container) Line 130 C# System.Web.Http.dll!System.Web.Http.Validation.DefaultBodyModelValidator.ValidateElements(System.Collections.IEnumerable model, System.Web.Http.Validation.DefaultBodyModelValidator.ValidationContext validationContext) Line 176 C#

After that, the DefaultBodyModelValidator.Validation* pattern of calls repeats over and over and over again. Everytime I pause execution, it appears to be at about the same depth, so it doesn't appear to be getting recursively deeper.

If I force the JsonConverter to return null, control returns to the API controller action method, I'm assuming because there's nothing to validate.

I don't have the brain juices left to figure this one out. What am I doing wrong?


UPDATE: With brain juices somewhat replenished, I've stepped through most of the code and it appears that when validating the model the DefaultBodyModelValidator is drilling way down into the SqlTypesAssembly and getting stuck in a loop reading attributes somewhere. I don't really care to find out exactly where because I don't want the DefaultBodyModelValidator drilling into DbGeography type instances to start with.

There's no reason for model validation to drill down into the DbGeography class. I need to figure out how to get the MediaTypeFormatterCollection.IsTypeExcludedFromValidation method to return true for typeof(DbGeography), which will cause the DefaultBodyModelValidator to perform shallow validation on any DbGeography instances. So now the question at hand is- how do I exclude a type from model validation? The ShouldValidateType method of DefaultBodyModelValidator is marked virtual, but is there not a simple way to add an excluded type at startup?

Korayem
  • 12,108
  • 5
  • 69
  • 56
joelmdev
  • 11,083
  • 10
  • 65
  • 89

5 Answers5

37

Whether this issue is a bug or a limitation of Web API, I do not know, but here's my workaround:

First, we need to subclass the DefaultBodyModelValidator and override the ShouldValidateType method.

public class CustomBodyModelValidator : DefaultBodyModelValidator
{
    public override bool ShouldValidateType(Type type)
    {
        return type!= typeof(DbGeography) && base.ShouldValidateType(type);
    }
}

Now in global.asax's Application_Start method, add

GlobalConfiguration.Configuration.Services.Replace(typeof(IBodyModelValidator), new CustomBodyModelValidator());

and that's it. Shallow validation will now be performed on the DbGeography type instances and everything binds nicely.

joelmdev
  • 11,083
  • 10
  • 65
  • 89
  • The root cause is most likely [issue #1024](https://aspnetwebstack.codeplex.com/workitem/1024) that changed behavior of cycles in endless graph - visited objects are now compared **only** by reference (even with custom hashcode/equals). If some of your properties generate new instances on the fly, it can enter endless recursion because new nodes are generated and not detected as previously visited (new instances = different references). The types from TypeHelper.IsSimpleType and ShouldValidateType (DefaultBodyModelValidator.cs line123) are not recursively traversed. – jahav Jul 20 '15 at 07:38
  • I'm having trouble getting this into my `Application_Start` -- is there another place it could also go? The problem is that `GlobalConfiguration.Configuration` is not ready at the point when global.asax loads – jocull Jul 24 '15 at 16:50
  • 2
    We had a custom `HttpConfiguration` in our case as part of an area registration. The solution was to use that and do `HttpConfiguration.Services.Replace(typeof(IBodyModelValidator), new CustomBodyModelValidator());` – jocull Jul 24 '15 at 17:56
  • 2
    This is still happening in Web API 5.2.3 but the above fix doesn't fix it. Has anyone got any other suggestions? Removing all ModelValidators from services works, but then no validation is performed. I need to have data annotation validation at a minimum. I have tried creating my own custom validator provider that extends DataAnnotationsModelValidatorProvider and overrides GetValidators to return an empty list when the model metadata type is DbGeography, but that doesn't seem to work either! – Breeno Mar 01 '17 at 20:35
  • 1
    Pretty sure this doesn't work because I'm using DataAnnotation validation attributes on my models, and so a DataAnnotationModelValidatorProvider gets added to my list of services - it works if I clear all services of type ModelValidatorProvider (as per http://stackoverflow.com/q/14147299/), but then I get no validation at all. I've also tried subclassing DataAnnotationModelValidatorProvider to return 0 validators for any ModelMetadata type that matches DbGeography, but that doesn't work either! And I've tried it in conjunction with the above too, still no joy. Anyone got any ideas?? – Breeno Mar 02 '17 at 17:25
  • @Breeno did you ever get this to work? I'm running into the exact same issue. – jtate Feb 06 '20 at 20:45
  • @jtate I've posted 2 new answers to this question - one covers how I handled this issue in WebApi, and the other how I handled it in an Mvc application. Hopefully you will find it helpful. – Breeno Feb 08 '20 at 18:45
3

The answer by joelmdev lead me in the right direction, but with my WebApi configuration in MVC and WebApi 5.2.3 the new validator would not get called when placed in Global.asax.

The solution was to put it in my WebApiConfig.Register method with the other WebApi routes: config.Services.Replace(typeof(IBodyModelValidator), new CustomBodyModelValidator());

Sherwin F
  • 658
  • 7
  • 13
1

Just had exactly the same issue but then with a custom type. After quite a lot of reasearch it turned out to be quite logical with the knowledge of this thread. The custom class had a public readonly property which returned another instance of the same class. The validator walks down all properties of the class (even if you do not do any validation at all) and gets the value. If your class return a new instance of the same class this happens again and again and... It looks like the StartPoint property in the Geography class has this very same problem. https://msdn.microsoft.com/en-us/library/system.data.spatial.dbgeography.startpoint(v=vs.110).aspx

Nico Timmerman
  • 1,427
  • 12
  • 12
0

If you're having this same problem from an Mvc application, you may be interested in this answer. It won't suit everyone, but as I was purely interested in latitude & longitude, my eventual solution to this problem was not to ignore DbGeography properties, but rather instruct the model binder how to validate them properly, or at least how I wanted them validated. This approach allows standard validation attribute properties to work, and allowed me to use regular Html controls for latitude and longitude. I validate them as a pair, as ultimately they are being bound to a single DbGepgraphy property on your model class, so either one on it's own is not enough to be considered valid. Also, I think this requires a reference to the Microsoft.SqlServer.Types Nuget package that corresponds to the target version of SQL Server being used, but you're probably referencing that already.

using System;
using System.Data.Entity.Spatial;
using System.Web.Mvc;

...

public class CustomModelBinder : DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var latitudePropertyValue = bindingContext.ValueProvider.GetValue(string.Concat(bindingContext.ModelName, ".", nameof(DbGeography.Latitude)));
        var longitudePropertyValue = bindingContext.ValueProvider.GetValue(string.Concat(bindingContext.ModelName, ".", nameof(DbGeography.Longitude)));

        if (!string.IsNullOrEmpty(latitudePropertyValue?.AttemptedValue)
            && !string.IsNullOrEmpty(longitudePropertyValue?.AttemptedValue))
        {
            if (decimal.TryParse(latitudePropertyValue.AttemptedValue, out decimal latitude)
                && decimal.TryParse(longitudePropertyValue.AttemptedValue, out decimal longitude))
            {
                // This is not a typo - longitude does come before latitude here
                return DbGeography.FromText($"POINT ({longitude} {latitude})", DbGeography.DefaultCoordinateSystemId);
            }
        }

        return null;
    }
}

Then a custom provider which uses it:

using System;
using System.Data.Entity.Spatial;
using System.Web.Mvc;

...

public class CustomModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(Type modelType)
    {
        if (modelType == typeof(DbGeography))
        {
            return new CustomModelBinder();
        }

        return null;
    }
}

Then in Application_Start() in Global.asax.cs:

ModelBinderProviders.BinderProviders.Add(new CustomModelBinderProvider());

Then in my model this allows me to just use the regular RequiredAttribute:

[Display(Name = "Location")]
[Required(ErrorMessage = "You must specify a location")]
public DbGeography Location { get; set; }

And finally, to use this in a view, I created display and editor templates. In ~/Views/Shared/DisplayTemplates/DbGeography.cshtml:

@model System.Data.Entity.Spatial.DbGeography
@Html.LabelFor(m => m.Latitude), 
@Html.LabelFor(m => m.Longitude)

In ~/Views/Shared/EditorTemplates/DbGeography.cshtml:

@model System.Data.Entity.Spatial.DbGeography
@Html.TextBoxFor(m => m.Latitude), 
@Html.TextBoxFor(m => m.Longitude)

And then I can use this editor template in any other regular view like so:

@Html.LabelFor(m => m.Location)
@Html.EditorFor(m => m.Location)
@Html.ValidationMessageFor(m => m.Location, "", new { @class = "error-message" })

You could also use hidden fields in your editor view and add some JavaScript to your template to build a Google map, for example, provided the map script also sets the values of the hidden fields again when a coordinate is selected.

Breeno
  • 3,007
  • 2
  • 31
  • 30
0

If you're having this issue in WebAPI 5.2.3, the only method for fixing this that worked for me is using a variation of a technique I've described here: https://stackoverflow.com/a/40534310/2001934

Basically I used a custom required attribute on DbGeography properties in my models, that in MVC would work entirely like the normal Required Attribute, but in WebApi I added an additional attribute adapter which always replaces the list of client validation rules with a new, empty list, so that no validation is performed in WebApi model binding for those same properties.

Only bit missing from this answer was how to replace the existing ModelValidatorProvider in WebApi:

DataAnnotationsModelValidationFactory factory = (p, a) => new DataAnnotationsModelValidator(
    new List<ModelValidatorProvider>(), new CustomRequiredAttribute()
);

DataAnnotationsModelValidatorProvider provider = new DataAnnotationsModelValidatorProvider();
provider.RegisterAdapterFactory(typeof(CustomRequiredAttribute), factory);

GlobalConfiguration.Configuration.Services.Replace(typeof(ModelValidatorProvider), provider);
Breeno
  • 3,007
  • 2
  • 31
  • 30