0

I have an ASP.NET Core 8 Web API built with C#, and I'd like to add some validations to the request body for PUT operations. One of it is to ensure that requests coming to my API is matching exactly my UpdateProductRequest model structure.

See sample code snippet below:

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

    public string Description { get; set; }
}
[HttpPut]
public async Task<IActionResult> UpdateProduct([Required][FromRoute] int productId, [Required][FromBody] UpdateProductRequest updateProductRequest) 
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    // ... do stuff

    return Accepted();
}

Now, here are some scenarios I'd like to handle:

Valid:

{
   "name": "Product A"
}
{
   "name": "Product A",
   "description": "some product description"    
}

Invalid:

{
   "name": "Product A",
   "descriptionOfProduct": "some data"
}
{
   "name": "Product A",
   "somethingElse": "some data"
}
{
   "name": "Product A",
   "": "some data"
}

This is also invalid but will be handled just fine by the ModelState.IsValid

{
   "names": "Product A",
   "description": "some product description"    
}

I have tried a few things already like writing a custom checker to read the request body using a StreamReader, but I'm getting an error reading the JSON from the request body:

The input does not contain any JSON tokens. Expected the input to start with a valid JSON token, when isFinalBlock is true

var jsonString = await new StreamReader(bindingContext.HttpContext.Request.Body).ReadToEndAsync();

// Deserialize the JSON into a dictionary
var jsonDoc = JsonDocument.Parse(jsonString);
var jsonElement = jsonDoc.RootElement;

// Get the properties of the model type
var modelProperties = typeof(T).GetProperties();
var modelPropertyNames = new HashSet<string>(modelProperties.Select(p => p.Name.ToLowerInvariant()));

// Check for extra properties
foreach (var property in jsonElement.EnumerateObject())
{
     if (!modelPropertyNames.Contains(property.Name.ToLowerInvariant()))
     {
         bindingContext.ModelState.AddModelError(property.Name, $"The property '{property.Name}' is not allowed.");
     }
}

Any idea what I'm doing wrong or any suggestions how to validate this properly and efficiently? Please help as I'm going around in circles with this validation problems.

5
  • 1
    Why do you care if they pass extra properties? Commented Mar 6 at 2:49
  • @mjwills - fair point. Is it not a good practice to ensure an API only accepts valid requests? Or am I over engineering this? I'm trying to reject requests firsthand that includes unnecessary properties or possibly malicious data into my API, so it won't flood my backend queue with "seems" valid requests. Understand any properties outside my Model structure won't map to anything, anyway but what is the common/recommended practice for these scenarios? Commented Mar 6 at 4:25
  • 1
    In the context of most JSON endpoints, extra unnecessary stuff in the payload still qualifies as "valid". Commented Mar 6 at 4:39
  • @Mr.Developer you're overcomplicating it. Just take what you need and check if they are valid or not. If you API is public then you can't prevent bad request with only validation. Commented Mar 6 at 4:46
  • What about "name": "" - valid or no? Also, if this kind of validation is not working as you expect, I'd recommend considering FluentValidation. Commented Mar 6 at 7:39

2 Answers 2

0

Based on Ayesh Nipun's answer

you should call EnableBuffering(); in a middleware in program.cs:

app.UseHttpsRedirection();
....
app.UseAuthorization();
app.Use(async (context, next) =>
{
    context.Request.EnableBuffering();
    await next.Invoke();
});
app.MapControllers();
.....

The filter:

public class ValidateJsonBodyFilter : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        
       
        context.HttpContext.Request.Body.Position = 0;
        using var reader = new StreamReader(context.HttpContext.Request.Body);
        var body = await reader.ReadToEndAsync();
       

        if (string.IsNullOrWhiteSpace(body))
        {
            context.Result = new BadRequestObjectResult("Request body cannot be empty.");
            return;
        }

        try
        {
            var jsonDoc = JsonDocument.Parse(body);
            var jsonElement = jsonDoc.RootElement;

            // Get expected properties from the model
            var modelProperties = typeof(UpdateProductRequest).GetProperties()
                                           .Select(p => p.Name.ToLowerInvariant())
                                           .ToHashSet();

            var errors = new Dictionary<string, string>();

            // Validate incoming properties
            foreach (var property in jsonElement.EnumerateObject())
            {
                if (!modelProperties.Contains(property.Name.ToLowerInvariant()))
                {
                    errors[property.Name] = $"The property '{property.Name}' is not allowed.";
                }
            }

            if (errors.Any())
            {
                context.Result = new BadRequestObjectResult(errors);
                return;
            }
        }
        catch (JsonException)
        {
            context.Result = new BadRequestObjectResult("Invalid JSON format.");
            return;
        }

        await next();
    }
}

Register it :

builder.Services.AddScoped<ValidateJsonBodyFilter>();

Action:

[ServiceFilter< ValidateJsonBodyFilter>]
public async Task<IActionResult> UpdateProduct([Required][FromRoute] int productId, [Required][FromBody] UpdateProductRequest updateProductRequest)
{



    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }



    return Accepted();

}

Result:

enter image description here

Sign up to request clarification or add additional context in comments.

1 Comment

Fantastic, that worked! Thanks @Ruikai
0

Reading the request body directly inside the model binder can be problematic because the request body stream can only be read once. Since built-in model binding already reads the request body to bind it to updateProductRequest, attempting to reread it results in an empty JSON

The solution is to use an Action Filter

public class ValidateJsonBodyFilter<T> : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        // Read the request body as a JSON document
        context.HttpContext.Request.EnableBuffering();
        using var reader = new StreamReader(context.HttpContext.Request.Body);
        var body = await reader.ReadToEndAsync();
        context.HttpContext.Request.Body.Position = 0; // Reset stream position

        if (string.IsNullOrWhiteSpace(body))
        {
            context.Result = new BadRequestObjectResult("Request body cannot be empty.");
            return;
        }

        try
        {
            var jsonDoc = JsonDocument.Parse(body);
            var jsonElement = jsonDoc.RootElement;

            // Get expected properties from the model
            var modelProperties = typeof(T).GetProperties()
                                           .Select(p => p.Name.ToLowerInvariant())
                                           .ToHashSet();

            var errors = new Dictionary<string, string>();

            // Validate incoming properties
            foreach (var property in jsonElement.EnumerateObject())
            {
                if (!modelProperties.Contains(property.Name.ToLowerInvariant()))
                {
                    errors[property.Name] = $"The property '{property.Name}' is not allowed.";
                }
            }

            if (errors.Any())
            {
                context.Result = new BadRequestObjectResult(errors);
                return;
            }
        }
        catch (JsonException)
        {
            context.Result = new BadRequestObjectResult("Invalid JSON format.");
            return;
        }

        await next();
    }
}

And then, use it in the PUT action

[HttpPut]
[ServiceFilter(typeof(ValidateJsonBodyFilter<UpdateProductRequest>))]
public async Task<IActionResult> UpdateProduct(
    [Required] [FromRoute] int productId,
    [Required] [FromBody] UpdateProductRequest updateProductRequest)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    // Process request...
    return Accepted();
}

1 Comment

I tried this but still getting an empty string from await reader.ReadToEndAsync();

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.