ASP.NET Core Web API : can I do validation with ModelState.IsValid with a [FromBody] object

88 views Asked by At

Was wondering if it is possible to use Model.IsValid with an ASP.NET Core Web API that takes in a JSON body as [FromBody] object from the controller argument.

Or should I just write my own validation methods? What is the direction to go in? How do I approach validation for this style of Web API, that really doesn't use MVC or Blazor; it just takes in GET and POST requests from a client UI web module (more than likely).

Granted this API is created to be agnostic framework wise, in the sense that it doesn't expect a model to be coming from a view... And I didn't come up with this design, so I am unfortunately stuck with it. We basically take in the raw JSON and use Newtonsoft methods to parse the request body into a C# class (DTO, COJO or whatever you want to call it).

  • Should I just write my own validation methods somewhere in the project?
  • Is there a third-party library out there that allows me to annotate my parameters in my classes...similar to the model validation concept/mechanism.
  • Or is there a pattern or best practice to handle this type of ASP.NET Core Web API?   I have always been taught that you should do parameter/field validation in the UI as well as the middleware / API / webservice level.

Here is a basic example of how a controller method looks in the project I am working on:

[HttpPost]
[Route("SubmitNewRecord")]
public ActionResult SubmitNewRecord([FromBody] object PostValues)
{
    ExampleAPI.Model.MyInfo submitNewRecordDataRequest = null;

    if (PostValues != null)
    {
        // Newtonsoft.Json.Linq;
        JObject PostObject = (JObject)PostValues;

        // Converting the raw incoming object PostValues to MyInfo class object:
        submitNewRecordDataRequest = JsonConvert.DeserializeObject<ExampleAPI.Model.MyInfo>(PostObject.ToString());
    }
    else
    {
        response.ResultCode = "-12";
        response.ResultMessage = "Incoming message was empty (null) - " + DateTime.Now;

        _logger.LogCritical("Incoming message was empty (null)- " + DateTime.Now);
        return new JsonResult(response);
    }

    // HERE I would like to use Model.IsValid...
    // just putting a Model.IsValid check here is not working obviously.
    if (ModelState.IsValid)  
    {  
        // Do stuff with the new MyInfo DTO (submitNewRecordDataRequest)
    } 
    else 
    {
        // throw error and send special response message.
    }        
}

Here is a sample data transfer object (DTO), I call it this because we aren't using Entity Framework, or any other ORM framework; just grabbing stored procedures from a database.

using System.ComponentModel.DataAnnotations;

namespace MyApi.DTOs
{
    public class MyInfo 
    {
        public int Id { get; set; }

        [Required]
        public string Name { get; set; }

        public decimal Price { get; set; }

        [Range(0, 999)]
        public double Weight { get; set; }

        [Required]
        [StringLength(1000)]
        public string Annotation{ get; set; }
    }
}

Anyway... I don't know what the original designer was thinking, and he didn't document a specific approach to do object validation in the API. What is the best practice for an API using Newtonsoft like this? Apologies, I am new to this style of API...and I am probably using some of the wrong terminology.

Also this is the way we send posts, Its old school and yeah there is probably a better way:

    $.ajax({
        url: url,
        type: 'POST',
        headers: { "X-CSRF": "1" },
        async: true,
        cache: false,
        data: data,
        dataType: "json",
        contentType: "application/json; charset=utf-8",
        timeout: tmout,
        success: fsuccess,
        error: ferror
    });

Note that the type is JSON and the contentType is: application/json; charset charset=utf-8...might make a difference.

1

There are 1 answers

1
Power Mouse On

I would suggest to make a more generic approach as have a base controller to make all error responses same:

namespace App.Controllers;

    public abstract class BaseController : Controller
    {
        protected HttpContext CurrentHttpContext;
        protected ILogger Logger;
        protected ILoggerFactory LoggerFactory;

        public BaseController(
                                IHttpContextAccessor httpContextAccessor, 
                                ILoggerFactory loggerFactory, 
            )
        {
            CurrentHttpContext = httpContextAccessor.HttpContext;
            LoggerFactory = loggerFactory;
        }

         /// <summary>
        /// Generate response with list of custom errors
        /// </summary>
        /// <param name="CustomErrors"></param>
        /// <param name="method"></param>
        /// <param name="route"></param>
        /// <param name="errorCode"></param>
        /// <returns></returns>
        protected ObjectResult ReturnApiCustomError(List<string> CustomErrors, string method = "", string route = "", int errorCode = 2000) =>
            StatusCode(500, new Model.ApiError
            {
                ErrorCode = errorCode,
                ErrorMessages = CustomErrors.ToArray(),
                Method = method,
                Route = route,
                ErrorCount = CustomErrors.Count,
            });

        public override void OnActionExecuting(ActionExecutingContext context)
        {
            if (!ModelState.IsValid)
            {
                var modelerrors = new List<string>();
                ModelState
                   .ToList()
                   .ForEach(m =>
                   {
                       List<string> errors = ModelState
                                                   .Keys.Where(k => k == m.Key)
                                                   .Select(k => ModelState[k].Errors)
                                                   .First().Select(e => e.ErrorMessage).ToList();
                       errors.ForEach(er => modelerrors.Add($"{m.Key} : {er}"));
                   });
                context.Result = ReturnApiCustomError(modelerrors, $"{this.GetType().Name}.{this.ControllerContext.RouteData.Values["action"].ToString()}", context.HttpContext.Request.Path.Value);
            }
            base.OnActionExecuting(context);
        }

    }

then in your controller

[Route("api/[controller]")]
[ApiController]
[AllowAnonymous]
public class StackOverflowYourController : BaseController
{

}

and

[HttpPost]
public async Task<IActionResult> SubmitNewRecord(MyInfo request)
{
  if (ModelState.IsValid)
  {
    return await Task.Run(()=> Ok("Validation pass"));
  }
  else
  {
    return BadRequest();
  }
}

now. with this request:

{
  "Id": 0,
  "Name": null,
  "Price": 0,
  "Weight": 999,
  "Annotation": "string"
}

the response would be

{
  "ErrorCode": 2000,
  "Route": "/api/StackOverflow",
  "Method": "StackOverflow.SubmitNewRecord",
  "ExceptionMessage": null,
  "ErrorCount": 1,
  "ErrorMessages": [
    "Name : The Name field is required."
  ]
}