Web API Development in .NET Core: A Practical Guide for Beginners
This article covers basic topic of API Development.
Here are the breakdown of each topic :
- Create a simple Web API project to performs CRUD
- Defining HTTP GET, POST, PUT, DELETE methods
- Attribute routing
- Perform CRUD operation with API to database
- Calling Web API using C# Code
- Versioning
- Error Handling with Middleware and Custom Exception
- Authentication and Authorization
- Logging
- Create a simple Web API project
The dummy scenario is there is an e-commerce website that use Web API to perform daily operation. 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, DELETE methods
I create 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.
For HTTP POST, put the [HttpPost]
attribute on top of the method. The expected class is SKU, with 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.
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 delete SKU , 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.
3. Attribute routing
You may realize the different CRUD operation of SKUs also trigger the same url, which is https://localhost:7248/api/SKU in my laptop. Can we use more meaningful name 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 bracket. For example, I named it to “GetAllSKU” for 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
Please bear in mind that the route name and method name can be different, which it will also works for URL /api/SKU/GetAllSKU even f I changed the method name
[HttpGet("GetAllSKU")]
public IActionResult GetAllSKUSomeOtherName()
4. Perform CRUD operation with API to database
I’m using 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 in Program.cs
, with connection string name DefaultConnection
. This line also register AppDbContext via Dependancy 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 create the database, we can try 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;
}
And 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 integrated 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 create SKU
by using HttpClient
to trigger the create 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. I created an object from SKU
class, populate the information.
ii. Then I used JsonConvert from Newtonsoft.Json package to serialize the object
iii. Finally I use PostAsync to call the API, by putting the correct route name “createsku”.
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 PostAsync
, GetAsync
because these methods are inherently asynchronous. I use .GetAwaiter().GetResult()
to synchronously blocking on 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 say the business require a new feature for new SKU, so the properties of SKU class will have a 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 version 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 new controller to perform versioning, but for the sake for clarity , I created another controller named SKUWithVersioningController
, which the method are exactly similar with 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 old version of CreateSKU
, I set attribute [MapToApiVersion(“1.0”)]
, and the method name also need to be change since we have more than 1 same 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 new version, I set the attribute [MapToApiVersion(“2.0”)],
and we put some dummy logic to distinguish the new version of 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 user’s App has been updated, before route them to new version of URL.
The SKU created with latest version of API will shows value for NewFeature
column.
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 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 handle exception in API controller actions, and any service invoked during the HTTP request lifecycle.
I create a dummy action in controller that throw key not found exception.
[HttpGet("GetError1")]
public IActionResult GetError1()
{
throw new KeyNotFoundException("SKU not found!");
}
When trigger it via Postman, it will returned error in a proper JSON format
Custom Exception
For custom exception, I created a custom exception class
public class MyCustomException : ApplicationException
{
public MyCustomException(string message) : base(message) { }
}
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 controller that throw my custom exception.
[HttpGet("GetError2")]
public IActionResult GetError2()
{
throw new MyCustomException("My Custom Exception!");
}
Call the dummy GetError2
action and get the customize 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}");
}
}
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
return task;
}
Again, we can populate the corresponding information into the object during handle the error.
[HttpGet("GetError3")]
public IActionResult GetError3()
{
return NotFound(new APIResponse<object>
{
Success = false,
Message = "This is customize error response",
Data = null
});
}
Response will be like this:
8. Authentication and Authorization
Authentication and authorization are crucial in any Web API to secure endpoints and control access.
To demo how to use authentication and authorization in .Net Web API, I created an action that need user to be authenticated first before can access 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 error “401 unauthorize” when trying to access it.
In order to access it correctly, I need to be authenticate with 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 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
- register
TokenService
- 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 used Postman to perform mockup login by calling the Login
method under AuthController
, I get a JWT token once I ‘successfully login’
I copy the JWT token, put it in the Token column under 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.”
his 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; }
}
9. Logging
Logging is very crucial especially for future support. Log files able to provide a lot of information should the Web API has any issues after it deployed into production environment.
I’m using Serilog, after install 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 in 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 was logged
Since we already configured the Iloggr in the middleware, so 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 throw exception, the exception message will also automatically logged.
You may have my source from from my Github.