You are viewing a preview of this lesson. Sign in to start learning
Back to ASP.NET with .NET 10

Validation & Documentation

Implement robust validation and OpenAPI documentation

Validation & Documentation in ASP.NET

Building robust APIs requires more than just functional endpointsβ€”you need comprehensive validation and clear documentation. Master input validation, model binding, and API documentation with free flashcards and hands-on examples. This lesson covers data annotations, custom validators, FluentValidation, OpenAPI/Swagger integration, and best practices for creating self-documenting APIs that prevent errors and enhance developer experience.

Welcome to API Validation & Documentation πŸ›‘οΈ

When building production APIs with ASP.NET and .NET 10, two critical aspects often separate amateur implementations from professional ones: robust validation and comprehensive documentation. Validation protects your system from bad data, while documentation ensures other developers (including future you!) can understand and use your endpoints correctly.

Think of validation as the bouncer at a nightclubβ€”checking IDs, enforcing dress codes, and keeping troublemakers out. Documentation is the menu at a restaurantβ€”telling customers what's available, what ingredients are included, and what to expect.

πŸ’‘ Why This Matters: A 2023 study found that 40% of API failures stem from invalid input data, and poorly documented APIs increase integration time by 300%. Let's fix both problems!

Core Concepts: Input Validation πŸ”

Understanding Model Binding and Validation

In ASP.NET, model binding automatically maps HTTP request data (from routes, query strings, headers, or body) to C# objects. Validation ensures this data meets your business rules before processing.

The validation pipeline works like this:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         VALIDATION PIPELINE                 β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                             β”‚
β”‚  πŸ“₯ HTTP Request                           β”‚
β”‚       ↓                                     β”‚
β”‚  πŸ”„ Model Binding                          β”‚
β”‚       ↓                                     β”‚
β”‚  βœ… Data Annotations Validation            β”‚
β”‚       ↓                                     β”‚
β”‚  πŸ”§ Custom Validators (IValidatableObject) β”‚
β”‚       ↓                                     β”‚
β”‚  🎯 FluentValidation (if configured)       β”‚
β”‚       ↓                                     β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”                       β”‚
β”‚  ↓                 ↓                        β”‚
β”‚ βœ… Valid          ❌ Invalid                β”‚
β”‚  β”‚                 β”‚                        β”‚
β”‚  ↓                 ↓                        β”‚
β”‚ Controller      400 Bad Request             β”‚
β”‚ Action          + ValidationProblemDetails  β”‚
β”‚                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Data Annotations: The Quick Win πŸš€

Data Annotations are attributes you place on model properties to define validation rules declaratively. They're built into .NET and require zero additional configuration.

Common validation attributes:

Attribute Purpose Example
[Required] Field must have a value Required username
[StringLength] String length constraints Password 8-100 chars
[Range] Numeric range Age between 0-120
[EmailAddress] Valid email format user@example.com
[RegularExpression] Pattern matching Phone number format
[Compare] Match another property Password confirmation
[Url] Valid URL format Website address
[CreditCard] Valid credit card number Payment processing

Custom Validation: Beyond the Basics 🎯

When built-in attributes aren't enough, you have three escalation paths:

  1. Custom Validation Attributes - Reusable validation logic
  2. IValidatableObject Interface - Complex multi-property validation
  3. FluentValidation Library - Separation of concerns, testability, advanced scenarios

When to use each:

Approach Best For Complexity
Data Annotations Simple, single-property rules ⭐ Low
Custom Attributes Reusable business rules ⭐⭐ Medium
IValidatableObject Cross-property validation within a model ⭐⭐ Medium
FluentValidation Complex rules, dependency injection, async validation ⭐⭐⭐ High

πŸ’‘ Pro Tip: Start with Data Annotations for 80% of cases. Graduate to FluentValidation when you need dependency injection in validators or want to keep models clean of validation logic.

Core Concepts: API Documentation πŸ“š

OpenAPI Specification (Swagger)

The OpenAPI Specification (formerly Swagger) is the industry standard for describing RESTful APIs. It's a JSON/YAML format that describes:

  • Available endpoints
  • HTTP methods
  • Request/response formats
  • Authentication requirements
  • Error responses

Swashbuckle is the most popular library for generating OpenAPI documentation in ASP.NET. It:

  1. πŸ” Scans your controllers and models
  2. πŸ“ Generates OpenAPI JSON
  3. 🎨 Provides Swagger UI (interactive documentation)
  4. πŸ§ͺ Enables API testing directly from the browser
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚      SWAGGER/OPENAPI WORKFLOW               β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                             β”‚
β”‚  πŸ“ Your Controllers & Models               β”‚
β”‚       ↓                                     β”‚
β”‚  πŸ” Swashbuckle Scans Code                 β”‚
β”‚       ↓                                     β”‚
β”‚  πŸ“„ Generates OpenAPI JSON                 β”‚
β”‚       ↓                                     β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”              β”‚
β”‚  ↓        ↓        ↓        ↓              β”‚
β”‚ 🎨 UI  πŸ“± Mobile πŸ€– Codegen πŸ“Š Tools       β”‚
β”‚Swagger  Client   OpenAPI   Postman         β”‚
β”‚  UI    Libraries Generator Collections      β”‚
β”‚                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

XML Comments: Documenting Your Code

ASP.NET can read XML documentation comments from your code and include them in the OpenAPI specification. This means your documentation lives right next to your code!

Enabling XML comments:

  1. Add to your .csproj:
<PropertyGroup>
  <GenerateDocumentationFile>true</GenerateDocumentationFile>
  <NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
  1. Configure Swashbuckle to use the XML file
  2. Write triple-slash comments above your actions

Response Types and Status Codes πŸ“Š

Good documentation explicitly declares what HTTP status codes your endpoint returns. Use [ProducesResponseType] attributes:

Status Code Meaning When to Use
200 OK Success GET, PUT, PATCH succeeded
201 Created Resource created POST successfully created resource
204 No Content Success, no body DELETE succeeded
400 Bad Request Validation failed Invalid input data
404 Not Found Resource doesn't exist GET/PUT/DELETE non-existent ID
409 Conflict Business rule violation Duplicate email, insufficient inventory
422 Unprocessable Entity Semantic validation failed Valid format, invalid business logic
500 Internal Server Error Server error Unhandled exceptions

🧠 Memory Device: "CRUD Status Codes"

  • Create β†’ 201 Created
  • Read β†’ 200 OK (or 404 Not Found)
  • Update β†’ 200 OK (or 204 No Content)
  • Delete β†’ 204 No Content (or 200 OK)

Example 1: Basic Data Annotations Validation πŸ“

Let's build a user registration endpoint with proper validation:

using System.ComponentModel.DataAnnotations;

namespace MyApi.Models;

public class RegisterUserRequest
{
    [Required(ErrorMessage = "Username is required")]
    [StringLength(50, MinimumLength = 3, 
        ErrorMessage = "Username must be between 3 and 50 characters")]
    [RegularExpression(@"^[a-zA-Z0-9_]+$", 
        ErrorMessage = "Username can only contain letters, numbers, and underscores")]
    public string Username { get; set; } = string.Empty;

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

    [Required(ErrorMessage = "Password is required")]
    [StringLength(100, MinimumLength = 8, 
        ErrorMessage = "Password must be at least 8 characters")]
    [RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]",
        ErrorMessage = "Password must contain uppercase, lowercase, digit, and special character")]
    public string Password { get; set; } = string.Empty;

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

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

    [Url(ErrorMessage = "Invalid URL format")]
    public string? WebsiteUrl { get; set; }
}

Controller using the model:

using Microsoft.AspNetCore.Mvc;

namespace MyApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    [HttpPost("register")]
    [ProducesResponseType(typeof(UserResponse), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    public IActionResult Register([FromBody] RegisterUserRequest request)
    {
        // ModelState.IsValid is automatically checked by [ApiController]
        // If validation fails, ASP.NET returns 400 with error details
        
        // Your registration logic here
        var userId = Guid.NewGuid();
        
        return CreatedAtAction(
            nameof(GetUser), 
            new { id = userId }, 
            new UserResponse { Id = userId, Username = request.Username }
        );
    }

    [HttpGet("{id}")]
    public IActionResult GetUser(Guid id)
    {
        // Implementation
        return Ok();
    }
}

public record UserResponse
{
    public Guid Id { get; init; }
    public string Username { get; init; } = string.Empty;
}

What happens when validation fails:

If you send invalid data:

{
  "username": "ab",
  "email": "not-an-email",
  "password": "weak",
  "confirmPassword": "different",
  "age": 5
}

ASP.NET automatically returns:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Username": ["Username must be between 3 and 50 characters"],
    "Email": ["Invalid email format"],
    "Password": ["Password must be at least 8 characters"],
    "ConfirmPassword": ["Passwords do not match"],
    "Age": ["Age must be between 13 and 120"]
  }
}

πŸ’‘ Key Point: The [ApiController] attribute enables automatic model validation. Without it, you'd need to manually check ModelState.IsValid.

Example 2: Custom Validation Attribute 🎯

Let's create a reusable validator for future dates (useful for appointments, event scheduling, etc.):

using System.ComponentModel.DataAnnotations;

namespace MyApi.Validation;

public class FutureDateAttribute : ValidationAttribute
{
    private readonly int _minimumDaysInFuture;

    public FutureDateAttribute(int minimumDaysInFuture = 0)
    {
        _minimumDaysInFuture = minimumDaysInFuture;
        ErrorMessage = _minimumDaysInFuture > 0 
            ? $"Date must be at least {_minimumDaysInFuture} days in the future"
            : "Date must be in the future";
    }

    protected override ValidationResult? IsValid(
        object? value, 
        ValidationContext validationContext)
    {
        if (value == null)
            return ValidationResult.Success; // Use [Required] separately

        if (value is not DateTime dateValue)
            return new ValidationResult("Invalid date format");

        var minimumDate = DateTime.UtcNow.AddDays(_minimumDaysInFuture);
        
        if (dateValue < minimumDate)
        {
            return new ValidationResult(
                ErrorMessage ?? $"Date must be at least {_minimumDaysInFuture} days in the future"
            );
        }

        return ValidationResult.Success;
    }
}

Using the custom attribute:

using MyApi.Validation;
using System.ComponentModel.DataAnnotations;

namespace MyApi.Models;

public class CreateAppointmentRequest
{
    [Required]
    [StringLength(200)]
    public string Title { get; set; } = string.Empty;

    [Required]
    [FutureDate(1)] // Must be at least 1 day in the future
    public DateTime ScheduledDate { get; set; }

    [Required]
    [Range(15, 480)] // 15 minutes to 8 hours
    public int DurationMinutes { get; set; }
}

Testing the validator:

// Valid request
var validRequest = new CreateAppointmentRequest
{
    Title = "Client Meeting",
    ScheduledDate = DateTime.UtcNow.AddDays(2),
    DurationMinutes = 60
};

// Invalid - date is today
var invalidRequest = new CreateAppointmentRequest
{
    Title = "Client Meeting",
    ScheduledDate = DateTime.UtcNow, // ❌ Fails validation
    DurationMinutes = 60
};

πŸ”§ Try This: Create a [PastDate] attribute for birthdates or historical events. The logic is nearly identicalβ€”just reverse the comparison!

Example 3: FluentValidation for Complex Rules πŸš€

For complex validation scenarios, FluentValidation provides a cleaner, more testable approach:

Installation:

dotnet add package FluentValidation.AspNetCore

Model (clean, no validation attributes):

namespace MyApi.Models;

public class CreateOrderRequest
{
    public string CustomerEmail { get; set; } = string.Empty;
    public List<OrderItem> Items { get; set; } = new();
    public string ShippingAddress { get; set; } = string.Empty;
    public string? PromoCode { get; set; }
}

public class OrderItem
{
    public Guid ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal PricePerUnit { get; set; }
}

Validator class:

using FluentValidation;

namespace MyApi.Validators;

public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderRequestValidator()
    {
        RuleFor(x => x.CustomerEmail)
            .NotEmpty().WithMessage("Email is required")
            .EmailAddress().WithMessage("Invalid email format")
            .MaximumLength(255);

        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("Order must contain at least one item")
            .Must(items => items.Count <= 50)
                .WithMessage("Order cannot exceed 50 items");

        RuleForEach(x => x.Items)
            .SetValidator(new OrderItemValidator());

        RuleFor(x => x.ShippingAddress)
            .NotEmpty()
            .MinimumLength(10)
            .MaximumLength(500);

        RuleFor(x => x.PromoCode)
            .Matches(@"^[A-Z0-9]{6,12}$")
                .WithMessage("Promo code must be 6-12 uppercase alphanumeric characters")
            .When(x => !string.IsNullOrEmpty(x.PromoCode));

        // Cross-property validation
        RuleFor(x => x)
            .Must(order => CalculateTotal(order.Items) >= 10)
            .WithMessage("Order total must be at least $10")
            .WithName("Order");
    }

    private decimal CalculateTotal(List<OrderItem> items)
    {
        return items.Sum(i => i.Quantity * i.PricePerUnit);
    }
}

public class OrderItemValidator : AbstractValidator<OrderItem>
{
    public OrderItemValidator()
    {
        RuleFor(x => x.ProductId)
            .NotEmpty().WithMessage("Product ID is required");

        RuleFor(x => x.Quantity)
            .GreaterThan(0).WithMessage("Quantity must be positive")
            .LessThanOrEqualTo(100).WithMessage("Maximum quantity is 100");

        RuleFor(x => x.PricePerUnit)
            .GreaterThan(0).WithMessage("Price must be positive")
            .LessThan(100000).WithMessage("Price seems unreasonably high");
    }
}

Registration in Program.cs:

using FluentValidation;
using FluentValidation.AspNetCore;
using MyApi.Validators;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

// Register FluentValidation
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddFluentValidationClientsideAdapters();
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderRequestValidator>();

var app = builder.Build();
app.MapControllers();
app.Run();

Controller (no changes needed!):

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    [HttpPost]
    [ProducesResponseType(StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
    {
        // Validation happens automatically!
        // If invalid, ASP.NET returns 400 with error details
        
        var orderId = Guid.NewGuid();
        return CreatedAtAction(nameof(GetOrder), new { id = orderId }, orderId);
    }

    [HttpGet("{id}")]
    public IActionResult GetOrder(Guid id) => Ok();
}

Advantages of FluentValidation:

βœ… Separation of concerns (validation logic separate from models) βœ… Testable validators βœ… Dependency injection support βœ… Complex conditional rules βœ… Async validation (e.g., checking database) βœ… Reusable rule sets

πŸ’‘ Pro Tip: Use FluentValidation when you need to inject services (like database repositories) into validators to check uniqueness or business rules.

Example 4: Complete Swagger/OpenAPI Setup πŸ“–

Let's create a fully documented API endpoint with Swagger:

Installation:

dotnet add package Swashbuckle.AspNetCore

Program.cs configuration:

using Microsoft.OpenApi.Models;
using System.Reflection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();

// Configure Swagger/OpenAPI
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Version = "v1",
        Title = "My API",
        Description = "A comprehensive API for managing products and orders",
        Contact = new OpenApiContact
        {
            Name = "Support Team",
            Email = "support@myapi.com",
            Url = new Uri("https://myapi.com/support")
        },
        License = new OpenApiLicense
        {
            Name = "MIT License",
            Url = new Uri("https://opensource.org/licenses/MIT")
        }
    });

    // Include XML comments
    var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
    options.IncludeXmlComments(xmlPath);

    // Add security definition (if using JWT)
    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Description = "JWT Authorization header using the Bearer scheme",
        Name = "Authorization",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.ApiKey,
        Scheme = "Bearer"
    });

    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                }
            },
            Array.Empty<string>()
        }
    });
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI(options =>
    {
        options.SwaggerEndpoint("/swagger/v1/swagger.json", "My API v1");
        options.RoutePrefix = string.Empty; // Serve Swagger at root URL
    });
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

Documented controller:

using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;

namespace MyApi.Controllers;

/// <summary>
/// Manages product catalog operations
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class ProductsController : ControllerBase
{
    /// <summary>
    /// Retrieves a product by its unique identifier
    /// </summary>
    /// <param name="id">The product's unique identifier</param>
    /// <returns>The requested product</returns>
    /// <remarks>
    /// Sample request:
    /// 
    ///     GET /api/products/3fa85f64-5717-4562-b3fc-2c963f66afa6
    ///     
    /// </remarks>
    /// <response code="200">Returns the requested product</response>
    /// <response code="404">Product not found</response>
    [HttpGet("{id}")]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
    public ActionResult<ProductDto> GetProduct(Guid id)
    {
        // Implementation
        var product = new ProductDto
        {
            Id = id,
            Name = "Sample Product",
            Price = 29.99m,
            InStock = true
        };
        
        return Ok(product);
    }

    /// <summary>
    /// Creates a new product
    /// </summary>
    /// <param name="request">Product creation details</param>
    /// <returns>The newly created product</returns>
    /// <remarks>
    /// Sample request:
    /// 
    ///     POST /api/products
    ///     {
    ///         "name": "Wireless Mouse",
    ///         "description": "Ergonomic wireless mouse with 6 buttons",
    ///         "price": 24.99,
    ///         "categoryId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
    ///     }
    ///     
    /// </remarks>
    /// <response code="201">Product created successfully</response>
    /// <response code="400">Invalid input data</response>
    [HttpPost]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    public ActionResult<ProductDto> CreateProduct(
        [FromBody] CreateProductRequest request)
    {
        var productId = Guid.NewGuid();
        var product = new ProductDto
        {
            Id = productId,
            Name = request.Name,
            Price = request.Price,
            InStock = true
        };
        
        return CreatedAtAction(
            nameof(GetProduct), 
            new { id = productId }, 
            product
        );
    }
}

/// <summary>
/// Product data transfer object
/// </summary>
public record ProductDto
{
    /// <summary>
    /// Unique product identifier
    /// </summary>
    public Guid Id { get; init; }
    
    /// <summary>
    /// Product name
    /// </summary>
    public string Name { get; init; } = string.Empty;
    
    /// <summary>
    /// Product price in USD
    /// </summary>
    public decimal Price { get; init; }
    
    /// <summary>
    /// Indicates if product is currently in stock
    /// </summary>
    public bool InStock { get; init; }
}

/// <summary>
/// Request model for creating a new product
/// </summary>
public record CreateProductRequest
{
    /// <summary>
    /// Product name (3-200 characters)
    /// </summary>
    [Required]
    [StringLength(200, MinimumLength = 3)]
    public string Name { get; init; } = string.Empty;
    
    /// <summary>
    /// Detailed product description
    /// </summary>
    [StringLength(2000)]
    public string? Description { get; init; }
    
    /// <summary>
    /// Product price (must be positive)
    /// </summary>
    [Required]
    [Range(0.01, 1000000)]
    public decimal Price { get; init; }
    
    /// <summary>
    /// Category identifier
    /// </summary>
    [Required]
    public Guid CategoryId { get; init; }
}

What you get:

  1. 🎨 Interactive UI at https://localhost:5001/ (if RoutePrefix is empty)
  2. πŸ“„ OpenAPI JSON at https://localhost:5001/swagger/v1/swagger.json
  3. πŸ§ͺ Test endpoints directly from the browser
  4. πŸ“š Auto-generated documentation from XML comments
  5. πŸ”’ Security definitions (JWT token input field)

πŸ€” Did You Know? Major tech companies like Microsoft, Google, and Stripe use OpenAPI specifications. Tools like Postman can import your swagger.json to auto-generate API collections!

Common Mistakes to Avoid ⚠️

1. Forgetting [ApiController] Attribute

❌ Wrong:

[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    [HttpPost]
    public IActionResult Create(CreateUserRequest request)
    {
        // BUG: Validation doesn't happen automatically!
        // Must manually check ModelState.IsValid
        var user = CreateUser(request);
        return Ok(user);
    }
}

βœ… Right:

[ApiController] // ← Enables automatic validation
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    [HttpPost]
    public IActionResult Create(CreateUserRequest request)
    {
        // Validation happens automatically
        // If invalid, returns 400 before this code runs
        var user = CreateUser(request);
        return Ok(user);
    }
}

2. Validation Attributes Without Error Messages

❌ Wrong:

public class UserDto
{
    [Required] // Generic error: "The Username field is required"
    [StringLength(50)] // Generic error
    public string Username { get; set; }
}

βœ… Right:

public class UserDto
{
    [Required(ErrorMessage = "Please provide a username")]
    [StringLength(50, MinimumLength = 3, 
        ErrorMessage = "Username must be 3-50 characters")]
    public string Username { get; set; }
}

3. Not Documenting Response Types

❌ Wrong:

[HttpGet("{id}")]
public IActionResult GetUser(Guid id)
{
    // Swagger doesn't know what this returns!
    return Ok(user);
}

βœ… Right:

[HttpGet("{id}")]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public ActionResult<UserDto> GetUser(Guid id)
{
    // Swagger knows exact return types
    return Ok(user);
}

4. Mixing Validation Approaches

❌ Wrong:

// Data annotations AND FluentValidation on same model
public class UserDto
{
    [Required] // Data annotation
    public string Username { get; set; }
}

// Also has FluentValidation validator
public class UserDtoValidator : AbstractValidator<UserDto> { ... }
// Result: Validation runs twice, confusing error messages

βœ… Right: Choose one approach per model:

// Option 1: Data annotations only
public class SimpleDto
{
    [Required]
    public string Name { get; set; }
}

// Option 2: FluentValidation only (clean model)
public class ComplexDto
{
    public string Name { get; set; } // No attributes
}
public class ComplexDtoValidator : AbstractValidator<ComplexDto> 
{
    public ComplexDtoValidator()
    {
        RuleFor(x => x.Name).NotEmpty();
    }
}

5. Not Enabling XML Documentation File

❌ Wrong: Write XML comments but forget to enable generation:

/// <summary>
/// Gets a user // This won't appear in Swagger!
/// </summary>
[HttpGet("{id}")]
public IActionResult GetUser(Guid id) => Ok();

βœ… Right: Enable in .csproj:

<PropertyGroup>
  <GenerateDocumentationFile>true</GenerateDocumentationFile>
  <NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>

6. Returning 200 OK for All Errors

❌ Wrong:

[HttpPost]
public IActionResult Create(UserDto dto)
{
    if (!IsValid(dto))
        return Ok(new { error = "Invalid data" }); // ❌ Wrong status!
    
    return Ok(user);
}

βœ… Right:

[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public IActionResult Create(UserDto dto)
{
    // Validation handled automatically by [ApiController]
    var user = CreateUser(dto);
    return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
}

7. Complex Regex Without Explanation

❌ Wrong:

[RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$")]
public string Password { get; set; }
// Users have no idea what the requirement is!

βœ… Right:

[RegularExpression(
    @"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$",
    ErrorMessage = "Password must be at least 8 characters with uppercase, lowercase, digit, and special character")]
public string Password { get; set; }

Key Takeaways 🎯

πŸ“‹ Quick Reference Card: Validation & Documentation

Concept Key Points
Data Annotations β€’ Use for simple validation
β€’ [Required], [Range], [EmailAddress], [StringLength]
β€’ Add ErrorMessage for user-friendly errors
Custom Validators β€’ Inherit ValidationAttribute
β€’ Override IsValid method
β€’ Reusable across projects
FluentValidation β€’ Separation of concerns
β€’ Dependency injection support
β€’ Complex/async validation
β€’ More testable
Swagger/OpenAPI β€’ Use Swashbuckle.AspNetCore
β€’ Enable XML documentation
β€’ Add [ProducesResponseType] attributes
β€’ Include security definitions
Status Codes β€’ 200 OK - Success
β€’ 201 Created - POST success
β€’ 400 Bad Request - Validation fail
β€’ 404 Not Found - Resource missing
Best Practices β€’ Always use [ApiController]
β€’ Document all endpoints
β€’ Use proper HTTP status codes
β€’ Choose one validation approach
β€’ Write clear error messages

Remember:

  1. πŸ›‘οΈ Validation is security - Never trust client input
  2. πŸ“– Documentation is maintenance - Future you will thank you
  3. 🎯 Be specific - Generic errors frustrate users
  4. πŸ§ͺ Test validation - Write unit tests for custom validators
  5. ⚑ Fail fast - Validate early in the request pipeline

πŸ“š Further Study

  1. Official ASP.NET Documentation: Model validation in ASP.NET Core
  2. FluentValidation Documentation: https://docs.fluentvalidation.net/
  3. OpenAPI Specification: https://swagger.io/specification/

Next Steps: Practice building a complete CRUD API with full validation and Swagger documentation. Try implementing async validators that check database constraints (like unique email addresses). Experiment with custom response formats using ProblemDetails middleware. Happy coding! πŸ’»βœ¨