Web API Development in .NET Core

This article covers the basic topic of API Development.

Here are the breakdown of each topic :

  1. Create a simple Web API project to perform CRUD
  2. Defining HTTP GET, POST, PUT, and DELETE methods
  3. Attribute routing
  4. Perform CRUD operation with API to database
  5. Calling Web API using C# Code
  6. Versioning
  7. Error Handling with Middleware and Custom Exception
  8. Authentication and Authorization
  9. Logging

1. Create a simple Web API project

Create Simple Web API project

The dummy scenario is there is an e-commerce website that uses Web API to perform daily operations. Following is the simple SKU class.

public class SKU
 {
     public int SKUId { get; set; }
     public string? SKUName { get; set; }
     public int SKUQuantity { get; set; }
     public double Price { get; set; }
 }

2. Defining HTTP GET, POST, PUT, and DELETE methods

I created a controller named SKUController to perform CRUD. In the controller, I created a dummy read-only SKU list.

  private static List<SKU> _skuList = new List<SKU>
        {
            new SKU { SKUId = 1, SKUName = "Item1", SKUQuantity = 10, Price = 99.99 },
            new SKU { SKUId = 2, SKUName = "Item2", SKUQuantity = 5, Price = 49.99 }
        };

For HTTP GET, the code is quite simple. Just put the [HttpGet] attribute on top of the method. Use Linq to filter lookup SKU by id.

[HttpGet]
public IActionResult GetAll()
{
    return Ok(_skuList);
}

 [HttpGet("{id}")]
 public IActionResult GetById(int id)
 {
     var sku = _skuList.FirstOrDefault(s => s.SKUId == id);
     if (sku == null)
     {
         return NotFound("SKU not found");
     }
     return Ok(sku);
 }

I’m using Postman to trigger my API. Make sure you select GET, and you will get the dummy SKU list.

Defining HTTP GET methods

For HTTP POST, put the [HttpPost] attribute on top of the method. The expected class is SKU, with the attribute [FromBody]. This attribute directs .NET Core to read this data and map it to the specified parameter.

 // POST: api/sku 
[HttpPost]
 public IActionResult Create([FromBody] SKU newSku)
 {
     if (newSku == null || string.IsNullOrWhiteSpace(newSku.SKUName))
     {
         return BadRequest("Invalid SKU data");
     }

     newSku.SKUId = _skuList.Count > 0 ? _skuList.Max(s => s.SKUId) + 1 : 1;
     _skuList.Add(newSku);
     return CreatedAtAction(nameof(GetById), new { id = newSku.SKUId }, newSku);
 }

In the Postman, select POST and provide the new SKU data in JSON format.

Defining HTTP POST method

For HTTP PUT, put the [HttpPut] attribute on top of the method. The {id} indicates this API expects a parameter named id. Use Linq to find the desired update SKU , filter by Id.

[HttpPut("{id}")]
 public IActionResult Update(int id, [FromBody] SKU updatedSku)
 {
     var sku = _skuList.FirstOrDefault(s => s.SKUId == id);
     if (sku == null)
     {
         return NotFound("SKU not found");
     }

     if (updatedSku == null || string.IsNullOrWhiteSpace(updatedSku.SKUName))
     {
         return BadRequest("Invalid SKU data");
     }

     sku.SKUName = updatedSku.SKUName;
     sku.SKUQuantity = updatedSku.SKUQuantity;
     sku.Price = updatedSku.Price;

     return NoContent();
 }

For HTTP DELETE, put the [HttpDelete] attribute on top of the method. Use Linq to find the desired deleted SKU and filter by Id.

[HttpDelete("{id}")]
 public IActionResult Delete(int id)
 {
     var sku = _skuList.FirstOrDefault(s => s.SKUId == id);
     if (sku == null)
     {
         return NotFound("SKU not found");
     }

     _skuList.Remove(sku);
     return NoContent();
 }

In Postman, select DELETE and provide Id to delete a SKU.

Defining HTTP DELETE method

3. Attribute routing

You may realize that the different CRUD operations of SKUs also trigger the same url, which is https://localhost:7248/api/SKU on my laptop. Can we use more meaningful names for different operations?

Yes, it’s called attribute routing. Just provide the [ApiController] and [Route(“api/[controller]”)] . So now the base route became api/SKU

[ApiController]
    [Route("api/[controller]")] 
    public class SKUController : Controller
    {//...

For each method, provide the method name in brackets. For example, I named it “GetAllSKU” for the API name to get all SKU, as well as my other method.

[HttpGet("GetAllSKU")]
public IActionResult GetAllSKU()

[HttpGet("GetSKUById/{id}")]
public IActionResult GetById(int id)

[HttpPost("CreateSKU")]
public IActionResult CreateSKU([FromBody] SKU newSku)

[HttpPut("UpdateSKU/{id}")]
public IActionResult UpdateSKU(int id, [FromBody] SKU updatedSku)

[HttpDelete("DeleteSKU/{id}")]
public IActionResult DeleteSKU(int id)

So, the URL in Postman became: https://localhost:7248/api/SKU/GetAllSKU

Json data

Please bear in mind that the route name and method name can be different, which will also work for URL /api/SKU/GetAllSKU even if I change the method name.

[HttpGet("GetAllSKU")]
public IActionResult GetAllSKUSomeOtherName()

4. Perform CRUD operation with API to database

I’m using the EF Core database for my Web API.

First, create a DB context. Declare the SKUs as table name, with class SKU.

 public class AppDbContext : DbContext
 {
     public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

     public DbSet<SKU> SKUs { get; set; }
 }

Configure the connection string Program.cs with a connection string name DefaultConnection. This line also registers AppDbContext via the Dependency Injection container of .NET Core.

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

In appsetting.json, declare the connection string and database name

"ConnectionStrings": {
    "DefaultConnection": "Server=CHEEHOU\\SQLEXPRESS;Database=WebApiDemo;Trusted_Connection=True;TrustServerCertificate=True"
}

After successfully creating the database, we can try to perform CRUD operation to DB via Web API. In order to distinguish it, I created another controller named SKUWithDBController.

Since we already register AppDbContext in Program.cs , so we can create the database instance in the constructor of my new controller.

 public class SKUWithDBController : Controller
 {
     private readonly AppDbContext _context;

     public SKUWithDBController(AppDbContext context)
     {
         _context = context;
     }

We can access the database using _context instance, for example

  [HttpGet("GetAllSKU")]
  public IActionResult GetAllSKU()
  {
      return Ok(_context.SKUs.ToList());
  }

Same with Create, Update, and Delete.

 public IActionResult CreateSKU([FromBody] SKU newSku)
 {
       _context.SKUs.Add(newSku);
       _context.SaveChanges();
     return CreatedAtAction(nameof(GetSKUById), new { id = newSku.SKUId }, newSku);
 }

  public IActionResult UpdateSKU(int id, [FromBody] SKU updatedSku)
  {
     
      var existingSku =   _context.SKUs.Find(id);
       
      existingSku.SKUName = updatedSku.SKUName;
      existingSku.SKUQuantity = updatedSku.SKUQuantity;
      existingSku.Price = updatedSku.Price;

       _context.SaveChanges();
      return NoContent();
}

public IActionResult DeleteSKU(int id)
{
    var sku = _context.SKUs.Find(id);
    
    _context.SKUs.Remove(sku);
    _context.SaveChanges();
    return NoContent();
}

5. Calling Web API using C# Code

So far I have demo called the Web API using a tool like Postman, now I will demo how to called the Web API using code and handle the data from or to Web API.

First, I host my Web API to my local IIS, with the local URL: http://localhost/MyWebApi/api/SKUWithDB. In real life, we need to put the base url of the Web API that we would like to integrate or trigger.

Then, I created a simple console program to perform CRUD to DB via Web API.

First, I declared a HttpClient and put the base url of the API.


HttpClient client = new HttpClient
{
    BaseAddress = new Uri("http://localhost/MyWebApi/api/SKUWithDB/")
};

This is the source code of creating SKU by using HttpClient to trigger the creating SKU API and using HttpResponseMessage to capture the returned data.

void CreateSku()
{


    var sku = new SKU
    {
        SKUName = "SKU created with C# code",
        SKUQuantity = 1,
        Price = 10.0,

    };

    string json = JsonConvert.SerializeObject(sku);
    var content = new StringContent(json, Encoding.UTF8, "application/json");

    HttpResponseMessage response = client.PostAsync("createsku", content).GetAwaiter().GetResult();
    if (response.IsSuccessStatusCode)
    {
        Console.WriteLine("SKU created successfully.");
    }
    else
    {
        Console.WriteLine($"Error: {response.StatusCode}");
    }
}

As you can see from the code,

  • I created an object from SKU class, populated the information.
  • Then, I used JsonConvert from the Newtonsoft.Json package to serialize the object
  • Finally, I use PostAsync to call the API by putting the correct route name, “create SKU.”

The same thing goes for GetSKUById, I use GetAsync and hardcoded the ID to 7 in this example.

HttpResponseMessage response =  client.GetAsync("GetSKUById/7").GetAwaiter().GetResult();

For UpdateSKU, I used PutAsync.

 var sku = new SKU
 {
   SKUQuantity=7,
    SKUName="Update the name",//SKU created with C# code

 };

 string json = JsonConvert.SerializeObject(sku);
 var content = new StringContent(json, Encoding.UTF8, "application/json");

 HttpResponseMessage response =  client.PutAsync("UpdateSKU/7", content).GetAwaiter().GetResult();

And DeleteAsync for DeleteSKU.

   HttpResponseMessage response =  client.DeleteAsync("deletesku/7").GetAwaiter().GetResult(); 

Please keep in mind that although this is a synchronous method, I used PostAsyncGetAsync because these methods are inherently asynchronous. I use .GetAwaiter().GetResult()to synchronously block the asynchronous method to complete.

6. Versioning

The business requirements are rapidly changing, so our API also needs to be changed to adapt to these changes. However, similar to mobile apps, not all users tend to update their app when updates are available. Therefore, to address this, we need to maintain multiple versions of the API to ensure backward compatibility.

Let's say the business requires a new feature for a new SKU, so the properties of the SKU class will have new properties called NewFeature .

public class SKU
 {
     public int SKUId { get; set; }
     public string? SKUName { get; set; }
     public string? NewFeature { get; set; }//new feature
     public int SKUQuantity { get; set; }
     public double Price { get; set; }
 }

We use versioning to maintain 2 versions of API, where

  • New version of CreateSKU will have a new feature
  • Old version of CreateSKU will not have a new feature

You do not need to create a new controller to perform versioning, but for the sake of clarity, I created another controller named SKUWithVersioningController, which the method is exactly similar to the previous controller.

As you can see, I put the version attribute and new route format on the controller class.


    [ApiController]
    [ApiVersion("1.0")]
    [ApiVersion("2.0")]  // This can support multiple versions
    [Route("api/v{version:apiVersion}/[controller]")]
    public class SKUWithVersioningController : Controller
    {

For the old version of CreateSKU, I set attribute [MapToApiVersion(“1.0”)], and the method name also needs to be changed since we have more than one method.

  [HttpPost("CreateSKU")]
  [MapToApiVersion("1.0")]  // This method will be mapped to version 1.0
  public IActionResult CreateSKU_v1([FromBody] SKU newSku)
  {//the rest of the codes are the same

For the new version, I set the attribute [MapToApiVersion(“2.0”)], and we put some dummy logic in it to distinguish the new version of the API.


        [HttpPost("CreateSKU")]
        [MapToApiVersion("2.0")]  // This method will be mapped to version 2.0
        public IActionResult CreateSKU_v2([FromBody] SKU newSku)
        {
            // assign new feature here
          newSku.NewFeature = "This has a new feature!";//new logic
//the rest of the codes are the same

In Program.cs, we also need to register the API versioning services.

builder.Services.AddApiVersioning(options =>
{
    options.AssumeDefaultVersionWhenUnspecified = true;  
    options.DefaultApiVersion = new ApiVersion(1, 0);    // Set default API version to 1.0
    options.ApiVersionReader = new HeaderApiVersionReader("api-version");  
});

So, in Postman, the URL to called

  • Old version API: https://localhost:7248/api/v1/SKUWithVersioning/CreateSKU
  • New version API: https://localhost:7248/api/v2/SKUWithVersioning/CreateSKU

We can write a logic to detect if the user’s App has been updated before routing them to a new version of the URL.

The SKU created with the latest version of API will show the value for NewFeature the column.

SKU created the latest version of API

7. Error Handling with Middleware and Custom Exception

Error handling in a Web API ensures that errors are caught, processed, and returned to the client in a consistent and user-friendly format. There are a few key approaches to perform error handling, and I only focus on middleware and custom exception handling.

Middleware

I created a middleware GlobalExceptionMiddleware to handle the error globally.

 public class GlobalExceptionMiddleware
 {
     private readonly RequestDelegate _next;
     private readonly ILogger<GlobalExceptionMiddleware> _logger;

     public GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
     {
         _next = next;
         _logger = logger;
     }

     public async Task InvokeAsync(HttpContext context)
     {
         try
         {
             await _next(context);
         }
         catch (Exception ex)
         {
             _logger.LogError(ex, "An unhandled exception occurred.");
             await HandleExceptionAsync(context, ex);
         }
     }

     private Task HandleExceptionAsync(HttpContext context, Exception exception)
     {
         context.Response.ContentType = "application/json";

         context.Response.StatusCode = exception switch
         {
             ApplicationException => StatusCodes.Status400BadRequest,
             KeyNotFoundException => StatusCodes.Status404NotFound,
             _ => StatusCodes.Status500InternalServerError
         };

         var response = new
         {
             message = exception.Message,
             detail = context.Response.StatusCode == StatusCodes.Status500InternalServerError
                 ? "An unexpected error occurred. Please try again later."
                 : null
         };

         var task = context.Response.WriteAsJsonAsync(response);
         context.Response.Body.Flush(); // Ensure the response body is sent
         return task;
     }


 }

And register it to Program.cs

app.UseMiddleware<GlobalExceptionMiddleware>();

The middleware handles exceptions in API controller actions and any service invoked during the HTTP request lifecycle.

I create a dummy action in the controller that throws a key not found exception.


        [HttpGet("GetError1")]
        public IActionResult GetError1()
        {
            throw new KeyNotFoundException("SKU not found!");
             
        }

When triggering it via Postman, it will return error in a proper JSON format

JSON Format

Custom Exception

For custom exceptions, I created a custom exception class

 public class MyCustomException : ApplicationException
 {
    

Include it in the middleware

 context.Response.StatusCode = exception switch
 {
     MyCustomException => 999,//i purposely put 999 as error code
//other codes

I create a dummy action in the controller that throws my custom exception


        [HttpGet("GetError2")]
        public IActionResult GetError2()
        {
            throw new MyCustomException("My Custom Exception!");

        }

Call the dummy GetError2 action and get the customized error.

void GetError()
{
    HttpResponseMessage response = client.GetAsync("GetError2").GetAwaiter().GetResult();

    if (!response.IsSuccessStatusCode)
    {
        
        string errorDetails = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
        Console.WriteLine($"Error Response: {errorDetails}");
        Console.WriteLine($"Status Code: {(int)response.StatusCode}");
    }

}

Error response

We can also customize the response returned from API. First, create a custom response class.

 public class APIResponse<T>
 {
     public bool Success { get; set; }       
     public string Message { get; set; }      
     public T Data { get; set; }              
     public object Errors { get; set; }   
 }

Then update the response type in HandleExceptionAsync of the middleware from anonymous object to strongly typed APIResponse<object>.

 private Task HandleExceptionAsync(HttpContext context, Exception exception)
 {
    //...some codes

     var response = new APIResponse<object>
     {
         Success = false,
         Message = exception.Message,
         Data = null,
         Errors = context.Response.StatusCode == StatusCodes.Status500InternalServerError
  ? new { detail = "An unexpected error occurred. Please try again later." }
  : null
     };

     var task = context.Response.WriteAsJsonAsync(response);
     context.Response.Body.Flush(); // Ensure the response body is sent
     r

Again, we can populate the corresponding information into the object while handling the error.

[HttpGet("GetError3")]
  public IActionResult GetError3()
  {
      return NotFound(new APIResponse<object>
      {
          Success = false,
          Message = "This is customize error response",
          Data = null
      });

  }

The response will be like this:

Error Response

8. Authentication and Authorization

Authentication and authorization are crucial in any Web API to secure endpoints and control access.

To demonstrate how to use authentication and authorization in .Net Web API, I created an action that needs the user to be authenticated first before accessing it. I put an attribute [Authorize]on it. If I’m authorized, I will able to see the text “This is a secure endpoint.”.


        [Authorize]
        [HttpGet("Secure")]
        public IActionResult GetSecureData()
        {
            return Ok("This is a secure endpoint.");
        }

However, since I’m not authorized, I get the error “401 unauthorize” when trying to access it.

In order to access it correctly, I need to be authenticated with a JWT token. Here is how to do it.

First, create a login model.

public class LoginModel
{
    public string Username { get; set; }
    public string Password { get; set; }
}

Then, in the appsetting.json , set the JWT settings.


    "JwtSettings": {
        "Key": "MyVerySecureAndLongSecretKey12345", 
        "Issuer": "MyIssuer",
        "Audience": "MyAudience",
        "DurationInMinutes": 60
    }

Then, create a helper class to generate a token.

public class TokenService
{
    private readonly IConfiguration _configuration;

    public TokenService(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    public string GenerateToken(string username, string role)
    {
        var jwtSettings = _configuration.GetSection("JwtSettings");
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings["Key"]));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var claims = new[]
        {
        new Claim(ClaimTypes.Name, username),
        new Claim(ClaimTypes.Role, role)
    };

        var token = new JwtSecurityToken(
            issuer: jwtSettings["Issuer"],
            audience: jwtSettings["Audience"],
            claims: claims,
            expires: DateTime.Now.AddMinutes(double.Parse(jwtSettings["DurationInMinutes"])),
            signingCredentials: creds
        );

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

I created another controller named AuthController to mockup a login, once ‘successfully login’, generate the token in the Login method.


        [HttpPost("login")]
        public IActionResult Login([FromBody] LoginModel login)
        {
            // Mock user authentication
            if (login.Username == "admin" && login.Password == "password")
            {
                var token = _tokenService.GenerateToken(login.Username, "Admin");
                return Ok(new { Token = token });
            }
            return Unauthorized();
        }

In Program.cs , register the

  • registerTokenService
  • register the JWT settings
  • configure the authentication and authorization
builder.Services.AddScoped<TokenService>();

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        var jwtSettings = builder.Configuration.GetSection("JwtSettings");
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwtSettings["Issuer"],
            ValidAudience = jwtSettings["Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings["Key"]))
        };
    });

builder.Services.AddAuthorization();

app.UseAuthentication();
app.UseAuthorization();

Now, I use Postman to perform a mockup login by calling the Login method under AuthController, I get a JWT token once I ‘successfully login’

Login

I copy the JWT token, put it in the Token column under the Authorization tab, and call again the secured method, this time since I’m authorized so I can access it and see the text “This is a secure endpoint.”

Authorization

This is how you include the token in header before you called a secured API with C# code.

string loginUrl = "http://localhost/MyWebApi/api/Auth/login";//our mockup login api

 LoginModel login = new LoginModel
 {
     Username = "admin",//
     Password = "password"
 };



 var jsonPayload = System.Text.Json.JsonSerializer.Serialize(login);
 var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");

 // Create HttpClient
 using HttpClient client = new HttpClient();

 // Send POST request to Login API
 HttpResponseMessage loginresponse =  client.PostAsync(loginUrl, content).GetAwaiter().GetResult(); 


 if (loginresponse.IsSuccessStatusCode)
 {
     // Parse the response to extract the JWT token
     string responseString =   loginresponse.Content.ReadAsStringAsync().GetAwaiter().GetResult();
     var responseObject = JsonConvert.DeserializeObject<JwtResponse>(responseString);

     Console.WriteLine("JWT Token: " + responseObject.Token);

     // Use the token for subsequent requests
     client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", responseObject.Token);

     // Example: Call a protected endpoint
     HttpResponseMessage secureResponse =   client.GetAsync("http://localhost/MyWebApi/api/SKUWithDB/Secure").GetAwaiter().GetResult();
     string message =   secureResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult();

     Console.WriteLine("Secure API Response: " + message);
 }

 public class JwtResponse
 {
     public string Token { get; set; }
 }

Secure endpoints

9. Logging

Logging is very crucial, especially for future support. Log files are able to provide a lot of information should the Web API have any issues after it is deployed into a production environment.

I’m using Serilog. After installing the NuGet Package, configure it in Program.cs.

// Configure Serilog
Log.Logger = new LoggerConfiguration() 
    .WriteTo.File("Logs/log-.txt", rollingInterval: RollingInterval.Day) // Log to a file
    .CreateLogger();

builder.Host.UseSerilog(); // Replace default logging with Serilog

Then inject it into the constructor, and now you can log information.

  public class SKUWithDBController : Controller
  {
      private readonly AppDbContext _context;
      private readonly ILogger<SKUWithDBController> _logger;

      public SKUWithDBController(AppDbContext context, ILogger<SKUWithDBController> logger)
      {
          _context = context;
          _logger = logger;
      }

 public IActionResult GetSKUById(int id)
 {
     _logger.LogInformation("Successfully fetched data.");// log information here
//omitted codes

Now, the information is logged.

Fetched data

Since we already configured the Iloggr in the middleware, any exception will automatically logged as well.

   public GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
     {
         _next = next;
         _logger = logger;
     }

        public async Task InvokeAsync(HttpContext context)
        {
            try
            {
                await _next(context);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "An unhandled exception occurred."+ ex.Message);

So when I trigger the dummy error method that throws an exception, the exception message will also be automatically logged.

Dummy

You may have my source from from my Github.


Similar Articles