Validation using Data Annotations in ASP.NET Core Web API

Validation using Data Annotations in ASP.NET Core Web API

In this article, I will discuss How to Implement Server Side Validation using Data Annotations in ASP.NET Core Web API Applications with Examples. Please read our previous article discussing How to Exclude Properties from Model Binding in ASP.NET Core Web API Applications with Examples.

What is Validation?

Validation refers to the process of ensuring that the data submitted by the client (usually through a request) meets the expected format, constraints, and rules defined by the application before being processed. It is essential in both client-side and server-side environments to ensure that data is correct, complete, and safe before performing operations like saving to the database or processing in business logic.

In the context of ASP.NET Core Web API, validation typically refers to checking the request data against a set of rules before it’s passed to the API’s business logic. These checks can include things like verifying that fields are not empty, ensuring numeric values fall within an acceptable range, checking that email addresses are valid, etc. This helps prevent invalid data from causing errors, inconsistencies, or security issues within the application.

Why Do We Need Validation in ASP.NET Core Web API?

ASP.NET Core Web API serves as a backend service that interacts with various clients, such as web browsers, mobile apps, and other services. Validation in this context is essential for several reasons:

  • Data Integrity: Ensures that the data stored in the database is correct, consistent, and meets the application’s requirements.
  • Improved User Experience: Providing meaningful validation feedback helps clients correct their inputs and reduces frustration.
  • Security: Validation helps prevent security vulnerabilities such as SQL Injection, Cross-site Scripting (XSS), or Cross-Site Request Forgery (CSRF) by sanitizing input.
  • Error Prevention: Reduces runtime errors by catching invalid data early in the request pipeline.
Types of Validation

Validations can generally be categorized into three types:

  • Client-Side Validation: This occurs in the client application (such as a browser or mobile app) before the request is sent to the server. This provides immediate feedback to users and reduces unnecessary network requests. However, it should never be the sole form of validation since clients can bypass it.
  • Server-Side Validation: This occurs on the server after the data is received from the client. It is crucial as client-side validation can be bypassed, whereas server-side validation is where we enforce business rules and data integrity.
  • Database Validation: Enforced at the database level using constraints such as NOT NULL, CHECK, UNIQUE, etc. It acts as the last line of defense.
Different Ways to Implement Server-Side Validations in ASP.NET Core Web API

ASP.NET Core Web API offers several methods for implementing server-side validations:

  • Model Validation Using Data Annotations: The most common and simplest way to validate data is using data annotation attributes on models. These attributes specify constraints for data, such as Required, Range, or StringLength.
  • Fluent Validation: This is a popular .NET library that allows more complex validation logic than data annotations, using a fluent API to define rules.
  • Manual Validation: In some cases, validation logic might need to be implemented manually in the controller or service layer, especially when the validation requires complex logic beyond what is available with annotations or Fluent Validation.
Data Annotation Attributes in ASP.NET Core Web API:

In ASP.NET Core Web API, Data Annotation Attributes enforce validation rules on model properties. These attributes ensure that the data submitted by clients adheres to the defined rules before it is processed or stored. Implementing server-side validations using data annotations enhances the robustness and reliability of your application.

ASP.NET Core Web API provides a variety of built-in data annotation attributes that you can use to validate data. The following are some of the most commonly used attributes:

[Required]

Ensures that a property is not null or empty. Applied to properties that must have a value. The syntax to use the [Required] attribute is given below:

[Required(ErrorMessage = "Name is required.")]
public string Name { get; set; }
[StringLength]

Specifies the minimum and/or maximum length of a string property. It is useful for validating input length constraints. The syntax to use the [StringLength] attribute is given below:

[StringLength(100, MinimumLength = 5, ErrorMessage = "Name must be between 5 and 100 characters.")]
public string Name { get; set; }
[MaxLength] / [MinLength]

MaxLength indicates the maximum length of a string or array type. MinLength indicates the minimum length. The syntax to use the [MaxLength] attribute is given below:

[MaxLength(100, ErrorMessage = "Description cannot exceed 100 characters.")]
public string Description { get; set; }
[Range]

Validates that a numeric property falls within a specified range. It is ideal for numerical data like age, price, etc. The syntax to use the [Range] attribute is given below:

[Range(1, 100, ErrorMessage = "Age must be between 1 and 100.")]
public int Age { get; set; }
[RegularExpression]

Validates that a property matches a specified regular expression pattern. It is commonly used for complex validations like phone numbers, zip codes, etc. The syntax to use the [RegularExpression] attribute is given below:

[RegularExpression(@"^\d{6}(-\d{4})?$", ErrorMessage = "Invalid Zip Code.")]
public string ZipCode { get; set; }
[EmailAddress]

Ensures that the property contains a valid email address. It is applied to email fields to validate the format. The syntax to use the [EmailAddress] attribute is given below:

[EmailAddress(ErrorMessage = "Invalid Email Address.")]
public string Email { get; set; }
[Compare]

Compares two properties to ensure they have the same value. It is commonly used for password confirmation fields. The syntax to use the [Compare] attribute is given below:

[Compare("Password", ErrorMessage = "Passwords do not match.")]
public string ConfirmPassword { get; set; }
[Phone]

Validates that the property contains a valid phone number. It is applied to phone number fields. The syntax to use the [Phone] attribute is given below:

[Phone(ErrorMessage = "Invalid Phone Number.")]
public string PhoneNumber { get; set; }
[Url]

Ensures that the property contains a valid URL. It is applied to URL fields. The syntax to use the [Url] attribute is given below:

[Url(ErrorMessage = "Invalid URL.")]
public string Website { get; set; }
[CreditCard]

Validates that the property contains a valid credit card number. Applied to credit card number fields. The syntax to use the [CreditCard] attribute is given below:

[CreditCard(ErrorMessage = "Invalid Credit Card Number.")]
public string CreditCardNumber { get; set; }

Implementing Server-Side Validations in ASP.NET Core Web API using Data Annotations

Let’s create a simple ASP.NET Core Web API application that demonstrates the usage of various data annotation attributes for server-side validations. First, create a new ASP.NET Core Web API project named ValidationDemo. Then, create a folder named Models in the project root directory.

Define the Model with Data Annotations

Create a User model with various properties decorated with different data annotation attributes. So, create a class file named User.cs within the Models folder and then copy and paste the following code:

using System.ComponentModel.DataAnnotations;

namespace ValidationDemo.Models
{
    public class User
    {
        [Required(ErrorMessage = "User ID is required.")]
        public int Id { get; set; }

        [Required(ErrorMessage = "Name is required.")]
        [StringLength(100, MinimumLength = 5, ErrorMessage = "Name must be between 5 and 100 characters.")]
        public string Name { get; set; }

        [Required(ErrorMessage = "Email is required.")]
        [EmailAddress(ErrorMessage = "Invalid Email Address.")]
        public string Email { get; set; }

        [Phone(ErrorMessage = "Invalid Phone Number.")]
        public string PhoneNumber { get; set; }

        [Required(ErrorMessage = "DateofBirth is required.")]
        [DataType(DataType.DateTime)]
        public DateTime DateofBirth { get; set; }

        [Range(18, 99, ErrorMessage = "Age must be between 18 and 99.")]
        public int Age { get; set; }

        [RegularExpression(@"^\d{8}(-\d{4})?$", ErrorMessage = "Invalid Zip Code.")]
        public string ZipCode { get; set; }

        [Url(ErrorMessage = "Invalid Website URL.")]
        public string Website { get; set; }

        [CreditCard(ErrorMessage = "Invalid Credit Card Number.")]
        public string CreditCardNumber { get; set; }

        [Required(ErrorMessage = "Password is required.")]
        [StringLength(100, MinimumLength = 6, ErrorMessage = "Password must be at least 6 characters.")]
        public string Password { get; set; }

        [Compare("Password", ErrorMessage = "Passwords do not match.")]
        public string ConfirmPassword { get; set; }
    }
}
Create the Controller

Next, we need to create a controller to handle HTTP requests and perform validations. So, create a new API Empty Controller named UsersController within the Controllers folder and then copy and paste the following code:

using Microsoft.AspNetCore.Mvc;
using ValidationDemo.Models;

namespace ValidationDemo.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class UsersController : ControllerBase
    {
        // In-memory list to store users for demonstration purposes
        private static readonly List<User> Users = new List<User>();

        // POST: api/Users
        [HttpPost]
        public IActionResult CreateUser([FromBody] User user)
        {
            // ModelState.IsValid checks all the data annotations
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            // Assign a new ID and add the user to the list
            user.Id = Users.Count + 1;
            Users.Add(user);

            return CreatedAtAction(nameof(GetUserById), new { id = user.Id }, user);
        }

        // GET: api/Users/{id}
        [HttpGet("{id}")]
        public IActionResult GetUserById(int id)
        {
            var user = Users.FirstOrDefault(u => u.Id == id);

            if (user == null)
                return NotFound();

            return Ok(user);
        }

        // GET: api/Users
        [HttpGet]
        public IActionResult GetAllUsers()
        {
            return Ok(Users);
        }
    }
}
Configure the Application

Ensure that the application is set up to handle JSON requests and responses properly and that validation errors are returned in a readable format. So, please modify the Program class as follows:

namespace ValidationDemo
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.

            builder.Services.AddControllers()
            .AddJsonOptions(options =>
            {
                // This will use the property names as defined in the C# model
                options.JsonSerializerOptions.PropertyNamingPolicy = null;
            });

            // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();

            app.UseAuthorization();

            app.MapControllers();

            app.Run();
        }
    }
}
Testing the Validations

You can test the API using tools like Postman or directly via Swagger UI. The API will respond with validation errors if you send an invalid request, such as missing required fields or providing incorrect formats.

POST https://localhost:{PORT}/api/Users

Request Body:
{
  "Name": "JD",
  "Email": "Invalid-Email",
  "PhoneNumber": "123xyz",
  "DateofBirth": "2025-01-28",
  "Age": 17,
  "ZipCode": "abcde",
  "Website": "Invalid-URL",
  "CreditCardNumber": "1234567890123456",
  "Password": "123",
  "ConfirmPassword": "321"
}
Response:
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Age": [
      "Age must be between 18 and 99."
    ],
    "Name": [
      "Name must be between 5 and 100 characters."
    ],
    "Email": [
      "Invalid Email Address."
    ],
    "Website": [
      "Invalid Website URL."
    ],
    "ZipCode": [
      "Invalid Zip Code."
    ],
    "Password": [
      "Password must be at least 6 characters."
    ],
    "PhoneNumber": [
      "Invalid Phone Number."
    ],
    "ConfirmPassword": [
      "Passwords do not match."
    ],
    "CreditCardNumber": [
      "Invalid Credit Card Number."
    ]
  },
  "traceId": "00-005e6f35668164b1b3d1d86106b84b08-ae263a2bac5e0045-00"
}
Custom Data Annotation in ASP.NET Core Web API:

Now, I want to remove the Age column and apply the Age validation on the Date of Birth property. There is no such built-in data annotation attribute available to handle this scenario. So, in a scenario like this, we need to create a custom data annotation attribute. Let us proceed and see how we can do this.

Creating a Custom Validation Attribute Involves:

Data Annotation Attributes in ASP.NET Core are primarily used to enforce validation rules on model properties. While there are several built-in attributes (like [Required], [Range], etc.), sometimes you need more specialized validations that aren’t covered by the existing attributes. In such cases, custom data annotation attributes come into play. To implement custom data annotation, we need to follow the following steps:

  • Inheriting from ValidationAttribute: Create a Custom data annotation class that should derive from the ValidationAttribute class.
  • Overriding the IsValid Method: Implement the validation logic inside the IsValid method.
  • Applying the Attribute to Model Properties: Use your custom attribute on the relevant properties within your models.
Create the Custom Attribute Class

First, create a new folder named Validators in your project to keep validation-related classes organized. Then, create a class file named AgeValidationAttribute.cs within the Validators folder and copy and paste the following code:

using System.ComponentModel.DataAnnotations;

namespace ValidationDemo.Validators
{
    // Validates that the age calculated from Date of Birth is within the specified range.
    public class AgeValidationAttribute : ValidationAttribute
    {
        private readonly int _minimumAge;
        private readonly int _maximumAge;

        // Initializes a new instance of the AgeValidationAttribute class
        // minimumAge: The minimum required age.
        // maximumAge: The maximum allowed age.
        public AgeValidationAttribute(int minimumAge = 18, int maximumAge = 99)
        {
            _minimumAge = minimumAge;
            _maximumAge = maximumAge;
            ErrorMessage = $"Age must be between {_minimumAge} and {_maximumAge} years.";
        }

        // Validates the specified value with respect to the current validation attribute.
        // value: The value of the object to validate.
        // validationContext: The context information about the validation operation
        // Returns an instance of ValidationResult
        protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
        {
            if (value == null)
            {
                return new ValidationResult("Date of Birth is required.");
            }

            if (!(value is DateTime dateOfBirth))
            {
                return new ValidationResult("Invalid Date of Birth format.");
            }

            var today = DateTime.Today;
            var age = today.Year - dateOfBirth.Year;

            // Adjust age if the birthday hasn't occurred yet this year
            if (dateOfBirth.Date > today.AddYears(-age)) 
                age--;

            if (age < _minimumAge || age > _maximumAge)
            {
                return new ValidationResult(ErrorMessage);
            }

            // Returning null indicates that the validation was successful
            return ValidationResult.Success;
        }
    }
}
Update the User Model

Next, update the User model by removing the Age property and applying the new AgeValidationAttribute to the DateofBirth property. So, please modify the User class as follows:

using System.ComponentModel.DataAnnotations;
using ValidationDemo.Validators;

namespace ValidationDemo.Models
{
    public class User
    {
        [Required(ErrorMessage = "User ID is required.")]
        public int Id { get; set; }

        [Required(ErrorMessage = "Name is required.")]
        [StringLength(100, MinimumLength = 5, ErrorMessage = "Name must be between 5 and 100 characters.")]
        public string Name { get; set; }

        [Required(ErrorMessage = "Email is required.")]
        [EmailAddress(ErrorMessage = "Invalid Email Address.")]
        public string Email { get; set; }

        [Phone(ErrorMessage = "Invalid Phone Number.")]
        public string PhoneNumber { get; set; }

        [Required(ErrorMessage = "DateofBirth is required.")]
        [DataType(DataType.DateTime)]
        [AgeValidation(18, 99)]
        public DateTime DateofBirth { get; set; }

        [RegularExpression(@"^\d{8}(-\d{4})?$", ErrorMessage = "Invalid Zip Code.")]
        public string ZipCode { get; set; }

        [Url(ErrorMessage = "Invalid Website URL.")]
        public string Website { get; set; }

        [CreditCard(ErrorMessage = "Invalid Credit Card Number.")]
        public string CreditCardNumber { get; set; }

        [Required(ErrorMessage = "Password is required.")]
        [StringLength(100, MinimumLength = 6, ErrorMessage = "Password must be at least 6 characters.")]
        public string Password { get; set; }

        [Compare("Password", ErrorMessage = "Passwords do not match.")]
        public string ConfirmPassword { get; set; }
    }
} 

Note: No changes are required from the controller.

Invalid Request Example

POST https://localhost:{PORT}/api/Users

{
    "Name": "JD",
    "Email": "invalid-email",
    "PhoneNumber": "123xyz",
    "DateofBirth": "2025-01-28",
    "ZipCode": "abcde",
    "Website": "Invalid-URL",
    "CreditCardNumber": "1234567890123456",
    "Password": "123",
    "ConfirmPassword": "321"
}

Response Body:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Name": [
      "Name must be between 5 and 100 characters."
    ],
    "Email": [
      "Invalid Email Address."
    ],
    "Website": [
      "Invalid Website URL."
    ],
    "ZipCode": [
      "Invalid Zip Code."
    ],
    "Password": [
      "Password must be at least 6 characters."
    ],
    "DateofBirth": [
      "Age must be between 18 and 99 years."
    ],
    "PhoneNumber": [
      "Invalid Phone Number."
    ],
    "ConfirmPassword": [
      "Passwords do not match."
    ],
    "CreditCardNumber": [
      "Invalid Credit Card Number."
    ]
  },
  "traceId": "00-14e139ed23f402ff1869dd63bd229be9-78581311273bced7-00"
}
How Do We Return Custom 400 Bad Request Response?

By default, the [ApiController] attribute enables automatic model state validation and returns a Problem Details response when validation fails. To override this default behavior and return a custom response in ASP.NET Core Web API, we need to configure the ApiBehaviorOptions appropriately. So, please modify the Program class as follows:

using Microsoft.AspNetCore.Mvc;

namespace ValidationDemo
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.

            builder.Services.AddControllers()
            .ConfigureApiBehaviorOptions(options =>
            {
                // Override the default invalid model state response
                // InvalidModelStateResponseFactory: This delegate is invoked when model validation fails.
                // By overriding it, we can control the structure and content of the validation error responses.
                options.InvalidModelStateResponseFactory = context =>
                {
                    // Extract the error messages from the model state
                    var errors = context.ModelState
                        .Where(e => e.Value?.Errors.Count > 0)
                        .Select(e => new
                        {
                            Field = e.Key,

                            // Option 1: Use only the first error message
                            // Error = e.Value.Errors.FirstOrDefault()?.ErrorMessage

                            // Join multiple error messages into a single string separated by semicolons
                            Error = string.Join("; ", e.Value?.Errors.Select(x => x.ErrorMessage ?? string.Empty) ?? Array.Empty<string>())
                        }).ToArray();

                    // Create a custom error response object
                    var errorResponse = new
                    {
                        //The HTTP status code (400 for Bad Request).
                        StatusCode = 400,

                        // A general message indicating that validation failed.
                        Message = "Validation Failed",

                        // An array containing details about each validation error, including the field name and associated error messages.
                        Errors = errors
                    };

                    return new BadRequestObjectResult(errorResponse)
                    {
                        // Ensures the response is returned as application/json.
                        ContentTypes = { "application/json" }
                    };
                };
            })
            .AddJsonOptions(options =>
            {
                // Use the property names as defined in the C# model
                options.JsonSerializerOptions.PropertyNamingPolicy = null;
            });

            // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();

            app.UseAuthorization();

            app.MapControllers();

            app.Run();
        }
    }
}

Now, run the application, and you should get a proper error response. Test the endpoint using the previous invalid data, and this time, you should get the following response:

{
  "StatusCode": 400,
  "Message": "Validation Failed",
  "Errors": [
    {
      "Field": "Name",
      "Error": "Name must be between 5 and 100 characters."
    },
    {
      "Field": "Email",
      "Error": "Invalid Email Address."
    },
    {
      "Field": "Website",
      "Error": "Invalid Website URL."
    },
    {
      "Field": "ZipCode",
      "Error": "Invalid Zip Code."
    },
    {
      "Field": "Password",
      "Error": "Password must be at least 6 characters."
    },
    {
      "Field": "DateofBirth",
      "Error": "Age must be between 18 and 99 years."
    },
    {
      "Field": "PhoneNumber",
      "Error": "Invalid Phone Number."
    },
    {
      "Field": "ConfirmPassword",
      "Error": "Passwords do not match."
    },
    {
      "Field": "CreditCardNumber",
      "Error": "Invalid Credit Card Number."
    }
  ]
}
Using Strongly-Typed Models for Error Responses

While anonymous types are quick, defining strongly typed models for your error responses can improve maintainability and readability. So, create a class file named ValidationErrorResponse.cs within the Models folder and then copy and paste the following code:

namespace ValidationDemo.Models
{
    // Represents a validation error response.
    public class ValidationErrorResponse
    {
        public int StatusCode { get; set; }
        public string Message { get; set; } = string.Empty;
        public List<FieldError> Errors { get; set; } = new List<FieldError>();
    }

    // Represents an error for a specific field.
    public class FieldError
    {
        public string Field { get; set; } = string.Empty;
        public string Error { get; set; } = string.Empty;
    }
}
Update Program.cs to Use Strongly-Typed Models

Next, please modify the Program class as follows:

using Microsoft.AspNetCore.Mvc;
using ValidationDemo.Models;

namespace ValidationDemo
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.

            builder.Services.AddControllers()
            .ConfigureApiBehaviorOptions(options =>
            {
                // Override the default invalid model state response
                // InvalidModelStateResponseFactory: This delegate is invoked when model validation fails.
                // By overriding it, we can control the structure and content of the validation error responses.
                options.InvalidModelStateResponseFactory = context =>
                {
                    // Extract the error messages from the model state
                    var errors = context.ModelState
                        .Where(e => e.Value?.Errors.Count > 0)
                        .Select(e => new FieldError
                        {
                            Field = e.Key,

                            // Option 1: Use only the first error message
                            // Error = e.Value.Errors.FirstOrDefault()?.ErrorMessage

                            // Join multiple error messages into a single string separated by semicolons
                            Error = string.Join("; ", e.Value?.Errors.Select(x => x.ErrorMessage ?? string.Empty) ?? Array.Empty<string>())
                        }).ToList();

                    // Create a custom error response object
                    var errorResponse = new ValidationErrorResponse
                    {
                        //The HTTP status code (400 for Bad Request).
                        StatusCode = 400,

                        // A general message indicating that validation failed.
                        Message = "Validation Failed",

                        // An array containing details about each validation error, including the field name and associated error messages.
                        Errors = errors
                    };

                    return new BadRequestObjectResult(errorResponse)
                    {
                        // Ensures the response is returned as application/json.
                        ContentTypes = { "application/json" }
                    };
                };
            })
            .AddJsonOptions(options =>
            {
                // Use the property names as defined in the C# model
                options.JsonSerializerOptions.PropertyNamingPolicy = null;
            });

            // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();

            app.UseAuthorization();

            app.MapControllers();

            app.Run();
        }
    }
}

With the above changes in place, run the application, and it should work as expected.

By using data annotation attributes in ASP.NET Core Web API, we can efficiently enforce server-side validations to ensure data integrity and consistency. The example above demonstrates how to implement various validations, handle validation errors, and provide meaningful feedback to API consumers. This approach not only simplifies the validation logic but also centralizes it within the model, promoting cleaner and more maintainable code.

In the next article, I will discuss Automapper in ASP.NET Core Web API with Examples. In this article, I explain how to implement Server-side Validation using data annotations in ASP.NET Core Web API with examples. I hope you enjoy this article, “Validation using data annotations in ASP.NET Core Web API.”

Leave a Reply

Your email address will not be published. Required fields are marked *