SiBrit
SiBrit

Reputation: 1530

How do I use ValidationAttribute or IModelBinder to ensure that an enum property exists in the JSON body and has a valid value?

Given a model that looks like this:

public enum Types
{
    Emergency,
    Notice,
    Event
}

public class Message
{
    public Types Type { get; set; }
    public string Title { get; set; }
}

And a controller endpoint like this:

[HttpPost]
public async Task<ActionResult> CreateMessage(Message newMessage)
{
    return Ok();
}

If the JSON payload looks like this:

{
    "type" : "emergency",
    "title" : "Burst pipe"
}

Then a 200 response is received (newMessage.Type is Emergency).

If the JSON payload looks like this:

{
    "type" : 1,
    "title" : "Burst pipe"
}

Then a 200 response is received (newMessage.Type is Notice).

If the JSON payload looks like this:

{
    "title" : "Burst pipe"
}

Then it should return a 400 response indicated that Type is required.

If the JSON payload looks like this:

{
    "type" : "spam",
    "title" : "Burst pipe"
}

Then it should return a 400 response indicated that Type is invalid.

If the JSON payload looks like this:

{
    "type" : 5,
    "title" : "Burst pipe"
}

Then it should return a 400 response indicated that Type is invalid.

Note that I am using a custom JsonConvertor to change the string value to the enum value.

To try and achieve this I've use various Validation attributes, custom validation attributes, model binders and custom model binders, none of which give me the expected results.

Mostly, the problem stems from the default binding behaviour being to set an enum property to 0 if it is not supplied in the JSON. This makes using [Required] a pointless task. I can get around this by making Type a nullable property, but it is a mandatory property so cannot be null, so the model doesn't allow null either.

I also cannot start the Types enum with 1 instead of 0, to indicate that 0 means no enum property was included, as all enums start on 0.

I had hoped that I could have the model binding be applied to the property only, and not the entire model or any of the other models used for other controller actions, but it seems that as the custom ModelBinderProvider has to be added to the ModelBinderProviders collection of the Controllers, it is used all the time.

It also seems that I would have to write reflection code to handle the single property that I want bound differently, but then I still need the binding code for the other properties or my model is incomplete.

What I don't understand is why the .NET validation doesn't handle this out-of-the-box.

It's pretty straight forward, with just 2 rules:

  1. The property is defined in the JSON body,
  2. The property has a value within the range of defined values for the enum.

Upvotes: 1

Views: 57

Answers (1)

Zhi Lv
Zhi Lv

Reputation: 21343

How do I use ValidationAttribute or IModelBinder to ensure that an enum property exists in the JSON body and has a valid value?

According to your description, you can create a custom model binder to achieve it. Refer to the following sample code:

  1. Based on your application vertion to install the Microsoft.AspNetCore.Mvc.NewtonsoftJson package, in this sample, I create a Asp.net 8 Web Api application, so I use the 8.0.8 version. We will use it to create custom JSON converter.

    NewtonsoftJosn Version

  2. Create a custom model binder ("EnumModelBinder") with the follow code:

     //required the following reference
     //using Newtonsoft.Json;
     //using Newtonsoft.Json.Linq;
     public class EnumModelBinder : IModelBinder
     {
         public Task BindModelAsync(ModelBindingContext bindingContext)
         {
             // Clear any existing model state errors
             bindingContext.ModelState.Clear();
             if (bindingContext == null)
             {
                 throw new ArgumentNullException(nameof(bindingContext));
             }
    
             // Read the body content
             var request = bindingContext.HttpContext.Request;
             using (var reader = new StreamReader(request.Body))
             {
                 var body = reader.ReadToEndAsync().Result;
    
                 if (string.IsNullOrEmpty(body))
                 {
                     return Task.CompletedTask; // No value provided
                 }
                 var jsonObject = JObject.Parse(body);
    
                 //check the EnumProperty is exist
                 if (jsonObject.ContainsKey("type"))
                 {
    
                     var enumvalue = jsonObject["type"];
    
                     //check whether the input value (string or int) has their corresponding enum values 
                     var isvalid = IsValidEnum(enumvalue);
                     if (!isvalid)
                     {
                         bindingContext.ModelState.TryAddModelError("type", "Invalid value for enum.");
                         bindingContext.Result = ModelBindingResult.Failed();
                     }
                     else
                     {
                         //// Deserialize the body to your model if needed
                         var model = JsonConvert.DeserializeObject<Message>(body);
                         bindingContext.Result = ModelBindingResult.Success(model);
                     }
                 }
                 else
                 {
                     bindingContext.ModelState.TryAddModelError(bindingContext.ModelMetadata.ModelType.Name, "type is required.");
                     bindingContext.Result = ModelBindingResult.Failed();
                 }
             }
             return Task.CompletedTask;
         }
         public bool IsValidEnum(JToken input)
         {
    
             if (input.Type == JTokenType.Integer)
             {
                 return Enum.IsDefined(typeof(Types), (int)input);
             }
             else if (input.Type == JTokenType.String)
             {
                 string strValue = (string)input;
                 return Enum.TryParse(typeof(Types), strValue, out _);
             }
             return false;
         }
     } 
    
  3. Create a custom Json Converter as below:

     //using Newtonsoft.Json;
     public class CustomEnumConverter<T> : JsonConverter where T : struct, Enum
     {
         public override bool CanConvert(Type objectType)
         {
             return objectType == typeof(T) || objectType == typeof(string) || objectType == typeof(int);
         }
    
         public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
         {
             if (reader.TokenType == JsonToken.String)
             {
                 string enumText = reader.Value.ToString();
                 if (Enum.TryParse<T>(enumText, true, out var enumValue))
                 {
                     return enumValue;
                 }
             }
             else if (reader.TokenType == JsonToken.Integer)
             {
                 int enumInt = Convert.ToInt32(reader.Value);
                 if (Enum.IsDefined(typeof(T), enumInt))
                 {
                     return (T)Enum.ToObject(typeof(T), enumInt);
                 }
             }
    
             throw new JsonSerializationException($"Unable to convert {reader.Value} to enum {typeof(T).Name}");
         }
    
         public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
         {
             // Serialize as string or int based on the enum type
             if (value is Enum enumValue)
             {
                 // Serialize as string
                 writer.WriteValue(enumValue.ToString());
             }
             else
             {
                 // Handle cases where the value might be an int
                 writer.WriteValue(Convert.ToInt32(value));
             }
         }
     }
    
  4. In the Program.cs file, configure the application to use Newtonsoft.Json:

     builder.Services.AddControllers().AddNewtonsoftJson();
    
  5. In the API controller use the ModelBinder attribute to specify the custom model binder for just that type or action.

     // POST api/<ToDoController>
     [HttpPost("/testenum")]
     public IActionResult Post([FromBody][ModelBinder(BinderType = typeof(EnumModelBinder))] Message message)
     { 
         return Ok(message);
     }
    
  6. After running the application, the result as below:

    test Result

Upvotes: 0

Related Questions