This tutorial is based off of the freeCodeCamp youtube video
.NET 5 REST API Tutorial - Build From Scratch With C#
which you can find here: https://www.youtube.com/watch?v=ZXdFisA_hOYThe below instructions are more of an overall guide to get setup and going while also explaining some new concepts that may not be familiar to developers who havent used much .Net or even C#.
We’ll use VSCode, C#, .NET, MongoDB, Docker and Kubernetes
Note, The root material uses .NET 5 but my guide is written for .NET 6 (It shouldnt matter too much), we’ll cover basic CRUD operations, dependency injections, asynchronous code and unit testing.
TL;DR
- Setup
- GET, POST, PUT, DELETE - Steps 1 - 4
- MongoDB
Getting Setup
Initialize project
dotnet new webapi -n <Name>
Setup self signed certificate with
dotnet dev-certs https --trust
In VSCode debuggger click RUN AND DEBUG and a window should open, if you have any issues check what Urls are defined in
launchSettings.json
just above "ASPNETCORE_ENVIRONMENT": "Development"
Then append
/swagger
to the end of the url, so http://localhost:5297/swagger
To stop the browser opening a new window everytime you restart the server, in
launch.json
delete serverReadyAction
Also delete
WeatherForecast.cs
, WeatherForecastController.cs
And to easily do builds, add the below to
tasks.json
at the bottom of the “build” task"group": { "kind": "build", "isDefault": true }
Now you can click
terminal
at the top of VSCode and Run build task
Entity, Repository, Controller GET
In the root create a folder called
Entities
, you can also call it domains or modelsCreate an entity, eg
item.cs
Here’s inventory for items a video game character might have:using System; namespace Catalog.Entities { public record Item { // Why record and not class? Records are immutable AND // two of them with the exact same properties will // evaluate true even if they're different instances public Guid Id { get; init; } // init is like set but with a built in constructor public string Name {get; init; } public decimal Price { get; init; } public DateTimeOffset CreatedDate { get; init; } } }
Create a temporary in-memory database.
Create a new root folder
Repositories
, where can copy this one as an example:using Catalog.Entities; namespace Catalog.Repositories { public class InMemItemsRepository { private readonly List<Item> items = new(){ // Data for the database // This is a new way of instancing values called 'Target typed new expression' new Item { Id = Guid.NewGuid(), Name = "Potion", Price = 9, CreatedDate = DateTimeOffset.UtcNow }, new Item { Id = Guid.NewGuid(), Name = "Iron Sword", Price = 20, CreatedDate = DateTimeOffset.UtcNow }, new Item { Id = Guid.NewGuid(), Name = "Bronze Shield", Price = 18, CreatedDate = DateTimeOffset.UtcNow } }; public IEnumerable<Item> GetItems() { // We can call this from anywhere to get all our data return items; } public Item? GetItem(Guid id) { // Use this anywhere to get an item via is Guid return items.Where(item => item.Id == id).SingleOrDefault(); // SingleOrDefault() Returns the item or null } } }
In the
Controller
folder, and add a new one, e.g ItemsController.cs
using Catalog.Entities; using Catalog.Repositories; using Microsoft.AspNetCore.Mvc; namespace Catalog.Controllers { [ApiController] // Adds some automatic functionality for us like errors and routing, it's called an attribute [Route("items")] // Automatically is: /items public class ItemsController : ControllerBase { // ControllerBase converts this into a controller class, ez private readonly InMemItemsRepository repository; public ItemsController() { repository = new InMemItemsRepository(); } [HttpGet] // GET /items, will call this method public IEnumerable<Item> GetItems() { var items = repository.GetItems(); return items; } } }
Now if you return to localhost:5297/swagger (port may change) and refresh, you’ll see the items GET end point. Click it to see the schema and click ‘try it out’ to receive the json payload:
// Schema [ { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "string", "price": 0, "createdDate": "2023-02-20T01:24:53.323Z" } ]
// Response Body [ { "id": "ecfcd1a1-775d-4d9b-a29e-e235713f14f4", "name": "Potion", "price": 9, "createdDate": "2023-02-20T01:24:53.279222+00:00" }, { "id": "8af67606-8657-4bac-8cb9-93c7143f32da", "name": "Iron Sword", "price": 20, "createdDate": "2023-02-20T01:24:53.279281+00:00" }, { "id": "23f1ae7c-be1b-493e-baf7-244905ea23ec", "name": "Bronze Shield", "price": 18, "createdDate": "2023-02-20T01:24:53.279281+00:00" } ]
You can also see it by visiting the url directly: http://localhost:5297/items
Now add the singular get Item to the controller, like so:
[HttpGet("{id}")] // GET /items/{id}, passing in the correct id retrieves the iem (Or null) public Item? GetItem(Guid id) { var item = repository.GetItem(id); return item; }
But wait! We generate new Guids in the temporary database every HTTP call so we could never actually search by the current values, we’ll fix that soon but first let’s add error handling for WHEN get requests fail:
[HttpGet("{id}")] public ActionResult<Item> GetItem(Guid id) { // Add ActionResult to allows us to return various status codes depending on errors var item = repository.GetItem(id); if (item is null) { return NotFound(); // Returns "404 not found" for us } return item; }
// Error status result { "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4", "title": "Not Found", "status": 404, "traceId": "00-932c46c865bc7b65576ca680e1cef734-1792c858297f7a2c-00" }
Interface and DTOs
At this point it might be good to create an interface to represent the methods that can acquire the items, in this case the GetItems() and GetItem(). Why? because we’ll eventually convert from in-memory to a seperate database and it’s good to have the ‘contract’ solidified. Also, if you don’t know, An interface is in a way a ‘type’ that designates methods that will always be available, classes then extend it and do their own thing. The interface doesn;t give the class the logic, it merely ensures all classes that extend it MUST have these defined methods.
Here’s an example Interface called
IItemsRepository.cs
:using Catalog.Entities; namespace Catalog.Repositories { public interface IItemsRepository { Item? GetItem(Guid id); IEnumerable<Item> GetItems(); } }
Then in the
InMemItemsRepository.cs
file we can extend the class, e.g public class InMemItemsRepository : IItemsRepository { ...
Optional: Setup Singleton for the in-memory database to fix GetItem()
This issue is caused by our database setup, in the real world you wouldnt deal with it, but if you want to solve continue reading
Since we want our in-memory data to persist across HTTP calls, we will want to add a singleton so that it remains the same data allways from when the app starts up. In the real world, you’d use a database so this isn’t necessary. To setup a singleton, add this in
Program.cs
:builder.Services.AddSingleton<IItemsRepository, InMemItemsRepository>();
Replace the instancing of the repository in `ItemController with this:
private readonly IItemsRepository repository; // Take singleton data public ItemsController(IItemsRepository repository) { this.repository = repository; // Dependency inject and bind to this class }
Data Transfer Objects are the contracts which we must ensure we fulfill for our clients so they always get the correctlt structured payload.
Add a new root folder
Dtos
with a file ItemDto.cs
and inside we’ll essentially just copy the Item.cs, but as we progress we’ll see this update:using System; namespace Catalog.Dto { // Added Dto public record ItemDto { // Added Dto public Guid Id { get; init; } public string Name {get; init; } public decimal Price { get; init; } public DateTimeOffset CreatedDate { get; init; } } }
To use it, refactor
ItemController.cs
like so:[HttpGet] public IEnumerable<ItemDto> GetItems() { // Dto return type var items = repository.GetItems().Select(item => new ItemDto { // Map every value Id = item.Id, Name = item.Name, Price = item.Price, CreatedDate = item.CreatedDate }); return items; }
We’d have to do this repeatedly on every endpoint that uses item though, so to make less repeated, create a new file in the root called
Extensions.cs
and put the mapping (For a single item) as shown:using Catalog.Dto; using Catalog.Entities; namespace Catalog { public static class Extensions { public static ItemDto AsDto(this Item item) { return new ItemDto { Id = item.Id, Name = item.Name, Price = item.Price, CreatedDate = item.CreatedDate }; } } }
Then refactor the controller, ensuring the returns types match too:
[HttpGet] public IEnumerable<ItemDto> GetItems() { var items = repository.GetItems().Select(item => item.AsDto()); // Convert each to Dto return items; } [HttpGet("{id}")] public ActionResult<ItemDto> GetItem(Guid id) { // Updated return type var item = repository.GetItem(id); if (item is null) { return NotFound(); } return item.AsDto(); // Convert one to Dto }
Now if you return to swagger and scroll down to schemas, you’ll see it says Dto
POST, PUT, DELETE
For every new endpoint:
1. Add/update to the interface in it’s repository
2. Add/update a method to the class where the interface(s) is used
3. Add a new Dto with data annotations
4. Add endpoint to the controller
1. From now on, start any new endpoint at the interface
IItemsRepository.cs
:public interface IItemsRepository { Item? GetItem(Guid id); IEnumerable<Item> GetItems(); void CreateItem(Item item); // New CREATE field }
2. You’ll notice our
InMemItemsRepository.cs
is broken since we haven’t implemented the create, so we can quickly add a create logic method:public void CreateItem(Item item) { items.Add(item); }
3. Create a new Dto file called
CreateItemDto.cs
with only the values needed (from clients) to update the server, add validation as needed:namespace Catalog.Dtos { public record CreateItemDto { [Required] // Data annotation, Returns status 400 if not provided public string Name {get; init; } [Required] [Range(1, 1000)] // Returns status 400 if our of range public decimal Price { get; init; } } }
4. Add a new HTTP endpoint to
ItemController.cs
which maps and adds the new item to the in-memory database in addition to returning not only that new resource but also a bunch of extra data including 201 created HTTP status:[HttpPost] // POST public ActionResult<ItemDto> CreateItem(CreateItemDto itemDto) { Item item = new(){ Id = Guid.NewGuid(), // generate ID Name = itemDto.Name, Price = itemDto.Price, CreatedDate = DateTimeOffset.UtcNow // generate Date }; // Save the new resource repository.CreateItem(item); // It's convention to return the created resource // CreatedAtAction adds "201 Created" // nameof is just a title, we could add anything // A new id is simply required, we may as well use the same one // item.AsDto() is our response payload object of the created item, converted to Dto return CreatedAtAction(nameof(GetItem), new { id = item.Id}, item.AsDto()); }
To make a PUT request, steps 1 - 3 are very similar, the only difference is we don’t need to return the new resource like we do with update, so here’s what we can do in the controller:
[HttpPut("{id}")] // PUT /items/3fa85f64-5717-4562-b3fc-2c963f66afa6 public ActionResult UpdateItem(Guid id, UpdateItemDto itemDto) { var existingItem = repository.GetItem(id); if (existingItem is null) { return NotFound(); } // Since itemDto is a record we can use 'with' to override existing immutable values and create a new item Item updatedItem = existingItem with { Name = itemDto.Name, Price = itemDto.Price }; repository.UpdateItem(updatedItem); return NoContent(); // Returns status 200 }
To make a DELETE, we only need to check the resource exists and then delete it by id:
[HttpDelete("{id}")] // DELETE /items/3fa85f64-5717-4562-b3fc-2c963f66afa6 public ActionResult DeleteItem(Guid id) { var existingItem = repository.GetItem(id); if (existingItem is null) { return NotFound(); } repository.DeleteItem(id); return NoContent(); }
MongoDB
Now we want to start storing our data in. apersistent enitity, in this case we’ll use MongoDB which effectively stores .json files for us. This is known as no-SQL so our C# code can read and write to the database directly, using a driver provided by mongoDB.
Install the driver
dotnet add package MongoDB.Driver
Then create a new file in the repositories folder titled
MongoDbItemsRepository.cs
Initial code should look something like this:
using Catalog.Entities; using MongoDB.Driver; using MongoDB.Bson; namespace Catalog.Repositories { public class MongoDbItemsRepository : IItemsRepository { private const string databaseName = "catalogue"; // Reusable name for our one database private const string collectionName = "items"; // Reusable name for our one collection private readonly IMongoCollection<Item> itemsCollection; // Collections are how MongoDB stores the .json files private readonly FilterDefinitionBuilder<Item> filterBuilder = Builders<Item>.Filter; // Creates a reusable set of criteria that is used to select documents from a collection based on specific conditions // The below will make more sense later once mongo is configured public MongoDbItemsRepository(IMongoClient mongoClient) { // Constructor IMongoDatabase database = mongoClient.GetDatabase(databaseName); // Instance our database itemsCollection = database.GetCollection<Item>(collectionName); // Instance our collection for each of the below methods } public void CreateItem(Item item) { itemsCollection.InsertOne(item); } public void DeleteItem(Guid id) { var filter = filterBuilder.Eq(item =>item.Id, id); itemsCollection.DeleteOne(filter); } public Item? GetItem(Guid id) { var filter = filterBuilder.Eq(item =>item.Id, id); return itemsCollection.Find(filter).SingleOrDefault(); } public IEnumerable<Item> GetItems() { return itemsCollection.Find(new BsonDocument()).ToList(); } public void UpdateItem(Item item) { var filter = filterBuilder.Eq(existingItem =>existingItem.Id, item.Id); itemsCollection.ReplaceOne(filter, item); } } }
Configure MongoDB
Make sure you’ve started Docker!
and restarted your terminal
and the server is running
Now we’ll actually setup MongoDB within a docker container. Ensure docker is installed then run
docker run -d --rm --name mongo -p 27017:27017 -v mongodbdata:/data/db mongo
-d
: Detached mode—rm
: Destroy container when we stop-p
: Exposed ports-v
: Creates a volume so when the container closes we dont lose the dataRun
docker ps
to see it runningNow to get our dotnet api connected to it, configure in
appsettings.json
:"MongoDbSettings": { "Host": "localhost", "Port": "27017" }
Next add a new
Settings
folder to the repo root with a file called MondoDbSettings.cs
Here, define the same configuration as properties with a useful connection string:
namespace Catalog.Settings { public class mongoDbSettings { public string Host { get; set; } public string Port { get; set; } public string ConnectionString { get{ return $"mongodb://{Host}:{Port}"; }} } }
To initialize the settings using the appsettings configuration, return to
program.cs
and create a new service which does so:builder.Services.AddSingleton<IMongoClient>(serviceProviders => { var settings = builder.Configuration.GetSection(nameof(mongoDbSettings)).Get<mongoDbSettings>(); return new MongoClient(settings.ConnectionString); }); // I honestly don't know what's happening here, but stuff is being initialized
Then if you previously used an in-memory database, just change:
builder.Services.AddSingleton<IItemsRepository, InMemItemsRepository>();
To:
builder.Services.AddSingleton<IItemsRepository, MongoDbItemsRepository>();
(Or add the ladder from scratch)
Then we want to add a serializer so mongoDB knows exactly what type of data is incoming, specifically for weird types like Guid and DateTimeOffset. Add these to
Program.cs
// By default, the Guid type is serialized as a binary value in BSON documents, // which can be less human-readable than a string representation. // Using this custom serializer, it will appear much more friendly and is a good idea to do this for many unique formats BsonSerializer.RegisterSerializer(new GuidSerializer(BsonType.String)); BsonSerializer.RegisterSerializer(new DateTimeOffsetSerializer(BsonType.String));
To visualize the data, I recomend installing the VSCode extension for MongoDB and add the connection string, e.g
mongodb://localhost:27017
This lets you browse the database files
Don’t forget to delete
InMemItemsRepository.cs
!Tasks, Async and Await
We can greatly speed up the speed of our API by pr0cessing multiple requests concurrently.
When making a method asynchronous, it’s convention to add that fact to the name, e.g in
IItemsRepository.cs
you would right click + rename symbol the Item GetItem(
method to Task GetItemAsync (
Do that for each of the methods in that file
Everywhere the repo is instanced will also need to change eg by adding the Task keyword and also changing the itemsCollection method to it’s async version, for example:
// Before public void CreateItemAsync(Item item) { itemsCollection.InsertOne(item); } // After public async Task CreateItemAsync(Item item) { await itemsCollection.InsertOneAsync(item); }
Do the same for Delete, Update, GetItem and GetItems
Last we’ll also need to update our HTTP endpoints in
ItemController.cs
For example, here’s how to update GetItems() by adding async, wrapping the return type in a Task, and awaiting then the get:
[HttpGet] // GET /items public async Task<IEnumerable<ItemDto>> GetItemsAsync() { // Note the () around the await, this is because .Select can't run immediately on a promise var items = (await repository.GetItemsAsync()).Select(item => item.AsDto()); return items; }
If you didn’t delete the
InMemItemsRepository.cs
you could also refactor it to handle async/await but you’ll need to do a little logic to handle actually waiting. Here’s the entire file for reference:InMemRepository.cs Async/Await
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Catalog.Api.Entities; namespace Catalog.Api.Repositories { public class InMemItemsRepository : IItemsRepository { private readonly List<Item> items = new() { new Item { Id = Guid.NewGuid(), Name = "Potion", Price = 9, CreatedDate = DateTimeOffset.UtcNow }, new Item { Id = Guid.NewGuid(), Name = "Iron Sword", Price = 20, CreatedDate = DateTimeOffset.UtcNow }, new Item { Id = Guid.NewGuid(), Name = "Bronze Shield", Price = 18, CreatedDate = DateTimeOffset.UtcNow } }; public async Task<IEnumerable<Item>> GetItemsAsync() { return await Task.FromResult(items); } public async Task<Item> GetItemAsync(Guid id) { var item = items.Where(item => item.Id == id).SingleOrDefault(); return await Task.FromResult(item); } public async Task CreateItemAsync(Item item) { items.Add(item); await Task.CompletedTask; } public async Task UpdateItemAsync(Item item) { var index = items.FindIndex(existingItem => existingItem.Id == item.Id); items[index] = item; await Task.CompletedTask; } public async Task DeleteItemAsync(Guid id) { var index = items.FindIndex(existingItem => existingItem.Id == id); items.RemoveAt(index); await Task.CompletedTask; } } }
Async is always the best thing to do, but also is it not the best thing to NOT do. Generally, you’ll want these things to always be asynchronous.