18 min read

Create a CRUD Blazor Webassembly API Controller

Create a CRUD Blazor Webassembly API Controller

In this tutorial we’ll create and consume a CRUD API Controller in Blazor WebAssembly. This is focused on the main HTTP Request types: Create, Read, Update and Delete. By the end of this video, you will know how to create an API with all the basic endpoints, to give them a custom URL route and to consume it from your Blazor WebAssembly components using HttpClient.

As part of this tutorial we’ll create a coffee dashboard where we’ll be able to see a predefined list of coffees (from our API). Also, we’ll be able to edit them too.

It is worth noting that while the UI won’t change much as a result of our edit within our dashboard, the concepts still apply with no exception. This is because we’re not using any form of a database, and any change to the data will not be reflected back on the UI.

The completed code in this tutorial can be found on GitHub here: iulianoana/blazor-wasm-crud-api, specifially in the after folder (before is a starter version).

Bellow is the video version of this tutorial, on YouTube, so check that out or carry on with the article.

https://youtu.be/aU-skkVpjII

Contents:

  1. Create a new Blazor WebAssembly Application​
  2. Project Setup
  3. Setup the API Controller
  4. Create CRUD API Methods
  5. Create a Razor Component in Blazor
  6. Set-up variables
  7. Declare Component Functions
  8. Add the UI mark-up for the list
  9. Add the EditForm

1. Create a new Blazor WebAssembly Application

In Visual Studio, click Create new project and choose Blazor WebAssembly, or search for it. Give it a name (in our case, we call ours CoffeePicker) and a path to your directory.

Very important here is to choose the ASP.NET Core hosted option from the following screen, as shown bellow. Finally click Create.

2. Project Setup

Create a business model/class to hold instances of our coffees. This should be done in the CoffeePicker.Shared project. As the project just generated consists of three projects: Client, Server and Shared. The Client one holds our Razor components, the Server holds the API controllers and the Shared consists of the business models.

namespace CoffeePicker.Shared
{
    public class Coffee
    {
        public int Id { get; set; }
        [Required]
        public string Title { get; set; }
        [Required]
        public string Description { get; set; }
    }
}

The above properties are there to hold a very simple coffee object with only the title and the description.

3. Setup the API Controller

Within the Server project > Controllers, right-click and create a new Empty API Controller. We’ve called it CoffeeController. Feel free to remove the api/ extension from the URL Route. This will allow us to consume API methods only by stating the name of the controller plus the method name, i.e: coffee/list instead of api/coffee/list. Also ensure your API controller class inherits from ControllerBase.

Here is how the top of the class should look:

[Route("[controller]")]
[ApiController]
public class CoffeeController : ControllerBase

Here we’ll create the list of coffees at the top of the class, as follows:

private List<Coffee> Coffees = new()
{
    new() { Id = 1, Title = "Black", Description = "Black coffee is as simple as it gets with ground coffee beans steeped in hot water, served warm. And if you want to sound fancy, you can call black coffee by its proper name: cafe noir." },
    new() { Id = 2, Title = "Latte", Description = "As the most popular coffee drink out there, the latte is comprised of a shot of espresso and steamed milk with just a touch of foam. It can be ordered plain or with a flavor shot of anything from vanilla to pumpkin spice." },
    new() { Id = 3, Title = "Cappuccino", Description = "Cappuccino is a latte made with more foam than steamed milk, often with a sprinkle of cocoa powder or cinnamon on top. Sometimes you can find variations that use cream instead of milk or ones that throw in flavor shot, as well." },
    new() { Id = 4, Title = "Americano", Description = "With a similar flavor to black coffee, the americano consists of an espresso shot diluted in hot water." },
    new() { Id = 5, Title = "Espresso", Description = "An espresso shot can be served solo or used as the foundation of most coffee drinks, like lattes and macchiatos." },
    new() { Id = 6, Title = "Doppio", Description = "A double shot of espresso, the doppio is perfect for putting extra pep in your step." },
    new() { Id = 7, Title = "Cortado", Description = "Like yin and yang, a cortado is the perfect balance of espresso and warm steamed milk. The milk is used to cut back on the espresso’s acidity." },
    new() { Id = 8, Title = "Red Eye", Description = "Named after those pesky midnight flights, a red eye can cure any tiresome morning. A full cup of hot coffee with an espresso shot mixed in, this will definitely get your heart racing." },
    new() { Id = 9, Title = "Galão", Description = "Originating in Portugal, this hot coffee drink is closely related to the latte and cappuccino. Only difference is it contains about twice as much foamed milk, making it a lighter drink compared to the other two." },
    new() { Id = 10, Title = "Lungo", Description = "A lungo is a long-pull espresso. The longer the pull, the more caffeine there is and the more ounces you can enjoy." }
};

4. Create CRUD API Methods

Following, as the heading implies, let’s lay out the basic CRUD methods, Create, Read (List and Get), Update and Delete.

The first one is the Create method. This will allow us to add new coffees to our list.

[HttpPost]
public ActionResult Create(Coffee coffee)
{
    coffee.Id = Coffees.Count + 1;
    Coffees.Add(coffee);
    var newCoffee = Coffees.Find(o => o.Id == coffee.Id);
    return Ok(newCoffee);
}

The first line represents the http method type, also known as the verb, which is a Post. This method, and every one in this API, will return an ActionResult and take in a Coffee model. This will be passed into the request from our Razor component.

The logic is as follows: Increment the id of the coffee model (so that it’s not null), then add it to the coffee list. Finally, find the newly added coffee object and return it in an Ok response.

Following, the Get:

[HttpGet("{id}")]
public ActionResult Get(int id)
{
    var coffee = Coffees.Find(o => o.Id == id);
    if (coffee is not null)
    {
        return Ok(coffee);
    }
    return NotFound("Coffeee not found");
}

The method now is Get and we have also specified that this request will take in an id in between the curly braces, (“{id}”). In between those parentheses next to HttpGet, we can specify a custom route such as [HttpGet(“GetMyCoffee/{id}”)] and therefore, the final request route would be coffee/getmycoffee/3.

This takes in the id of the coffee requested, finds it and if it’s not null, returns it. Otherwise it’ll return a Not Found status (404) with a custom message, if you wish.

Next up is List:

[HttpGet("List")]
public ActionResult List()
{
    return Ok(Coffees);
}

This is potentially the easiest method, only returns the list of coffees. The method type is Get, represented by HttpGet. However, we’ve defined a custom route of List in between the parentheses.

It’s worth noting, in a real-world scenario, your method will take in some filter object and will apply those filters. But here we keep things simple.

Next is the Update:

[HttpPut]
public ActionResult Update(Coffee newCoffee)
{
    var oldCoffee = Coffees.FirstOrDefault(o => o.Id == newCoffee.Id);
    if (oldCoffee is not null)
    {
        oldCoffee.Title = newCoffee.Title;
        oldCoffee.Description = newCoffee.Description;
        return Ok(newCoffee);
    }
    return NotFound("Coffee not found");
}

Here we’ve set the method type to Put and this is taking in a Coffee object which is the updated version of the entry we wish to update. The same logic is applied, grabbing the old version of the coffee object from the list on line 4, then updating its properties and returning it. If not found, same as previously, return a 404 not found response.

Last but not least, Delete:

[HttpDelete("{id}")]
public ActionResult Delete(int id)
{
    var coffee = Coffees.FirstOrDefault(o => o.Id == id);
    if (coffee is not null)
    {
        Coffees.Remove(coffee);
        return Ok();
    }
    return NotFound("Coffee not found");
}

The method type is Delete and it takes in an id, specified both in the route and as arguments in the method signature. Again, finding it than removing it.

Before moving on, just one thing I wanted to note, another way of specifying a route is the [Route] attribute under the method type (above the function definition). So, in this case the Delete could look like this:

[HttpDelete]
[Route("{id}")]
public ActionResult Delete(int id)

Finally our complete API looks like this:

[Route("[controller]")]
[ApiController]
public class CoffeeController : ControllerBase
{
private List<Coffee> Coffees = new()
{
    new() { Id = 1, Title = "Black", Description = "Black coffee is as simple as it gets with ground coffee beans steeped in hot water, served warm. And if you want to sound fancy, you can call black coffee by its proper name: cafe noir." },
    new() { Id = 2, Title = "Latte", Description = "As the most popular coffee drink out there, the latte is comprised of a shot of espresso and steamed milk with just a touch of foam. It can be ordered plain or with a flavor shot of anything from vanilla to pumpkin spice." },
    new() { Id = 3, Title = "Cappuccino", Description = "Cappuccino is a latte made with more foam than steamed milk, often with a sprinkle of cocoa powder or cinnamon on top. Sometimes you can find variations that use cream instead of milk or ones that throw in flavor shot, as well." },
    new() { Id = 4, Title = "Americano", Description = "With a similar flavor to black coffee, the americano consists of an espresso shot diluted in hot water." },
    new() { Id = 5, Title = "Espresso", Description = "An espresso shot can be served solo or used as the foundation of most coffee drinks, like lattes and macchiatos." },
    new() { Id = 6, Title = "Doppio", Description = "A double shot of espresso, the doppio is perfect for putting extra pep in your step." },
    new() { Id = 7, Title = "Cortado", Description = "Like yin and yang, a cortado is the perfect balance of espresso and warm steamed milk. The milk is used to cut back on the espresso’s acidity." },
    new() { Id = 8, Title = "Red Eye", Description = "Named after those pesky midnight flights, a red eye can cure any tiresome morning. A full cup of hot coffee with an espresso shot mixed in, this will definitely get your heart racing." },
    new() { Id = 9, Title = "Galão", Description = "Originating in Portugal, this hot coffee drink is closely related to the latte and cappuccino. Only difference is it contains about twice as much foamed milk, making it a lighter drink compared to the other two." },
    new() { Id = 10, Title = "Lungo", Description = "A lungo is a long-pull espresso. The longer the pull, the more caffeine there is and the more ounces you can enjoy." }
};
// Create
[HttpPost]
public ActionResult Create(Coffee coffee)
{
    coffee.Id = Coffees.Count + 1;
    Coffees.Add(coffee);
    var newCoffee = Coffees.Find(o => o.Id == coffee.Id);
    return Ok(newCoffee);
}
// Read
[HttpGet("{id}")]
public ActionResult Get(int id)
{
    var coffee = Coffees.Find(o => o.Id == id);
    if (coffee is not null)
    {
        return Ok(coffee);
    }
    return NotFound("Coffeee not found");
}
// Read
[HttpGet("List")]
public ActionResult List()
{
    return Ok(Coffees);
}
// Update
[HttpPut]
public ActionResult Update(Coffee newCoffee)
{
    var oldCoffee = Coffees.FirstOrDefault(o => o.Id == newCoffee.Id);
    if (oldCoffee is not null)
    {
        oldCoffee.Title = newCoffee.Title;
        oldCoffee.Description = newCoffee.Description;
        return Ok(newCoffee);
    }
    return NotFound("Coffee not found");
}
// Delete
[HttpDelete("{id}")]
public ActionResult Delete(int id)
{
    var coffee = Coffees.FirstOrDefault(o => o.Id == id);
    if (coffee is not null)
    {
        Coffees.Remove(coffee);
        return Ok();
    }
    return NotFound("Coffee not found");
}
}

5. Create a Razor Component in Blazor

Let’s create a new Razor component in Client > Pages called Dashboard.cs. Right-click > Add > Razor component. Add the following to the top:

@page "/dashboard"
@inject HttpClient HttpClient
@using CoffeePicker.Shared

On line 1 we have added what is called a page directive in Blazor, @page. This represents the route to that page. This will be used for navigating to this page.

The @inject Razor directive, exposes the HttpClient class in our component using the dependency injection feature built-into ASP.NET Core. We need the HttpClient to execute HTTP Requests to our REST API.

Finally the @using is importing the Shared project into our component so we can use the Coffee object we’ve just created.

By default a Blazor project creates a project with 3 menu items:

So let’s add a 4th menu item to point to our newly created dashboard. This can be done in the following component: Client > Shared > NavMenu.razor:

<li class="nav-item px-3">
    <NavLink class="nav-link" href="dashboard">
        <span class="oi oi-basket" aria-hidden="true"></span> Coffees
    </NavLink>
</li>

Add this block inside the <ul> so that the side menu will have a pointer to our dashboard.

The <NavLink> Blazor built-in component takes in a href attribute which must specify the route to the page requested, in our case that is dashboard. Then the span is just to add an icon from Open Iconic.

Now, clicking on the new menu item should take you to the newly created page, our dashboard: localhost:5001/dashboard.

6. Set-up variables

Now, coming back to the Dashboard component, we need to add a few variables at the top of the @code section, to help us show and manipulate the data in the UI:

private List<Coffee> Coffees = new();
private Coffee SelectedCoffee;
private bool IsCreating;

We have created a List of Coffee objects called Coffee and we initialize it as an empty list by default. Then we have defined a Selected Coffee object so that we can click on a coffee and store it in a local variable until another one is selected. Lastly we’ve added a Boolean to inform the app whether the user is creating a coffee or editing an existing one. It will come in handy later. 

7. Declare Component Functions

The first one is OnInitializedAsync. This is a Razor Component Lifecycle event that can be overridden so that we can tap into the exact time the app is initializing.

All we do here is call a separate method called GetCoffees (further down) that executes a Get request to return the entire list of coffees when the page initializes.

The GetDetails is executed when a coffee from the list is selected explained in the next steps.

protected override async Task OnInitializedAsync()
{
    Coffees = await GetCoffees();
}
async Task GetDetails(int id)
{
    SelectedCoffee = new();
    SelectedCoffee = await HttpClient.GetFromJsonAsync<Coffee>($"Coffee/{id}");
}

As you see, we call GetFromJsonAsync with the type expected (between angle brackets <>) which is Coffee. This function takes in the route. Ensure you add the $ so that you can pass in the id of the coffee requested.

Following we need to add the HandleValidSubmit method that will send Post and Put requests as we update our coffees:

async Task HandleValidSubmit()
{
    HttpResponseMessage response;
    if (IsCreating)
    {
        response = await HttpClient.PostAsJsonAsync<Coffee>("Coffee", SelectedCoffee);
    }
    else
    {
        response = await HttpClient.PutAsJsonAsync<Coffee>("Coffee", SelectedCoffee);
    }
    if (response.IsSuccessStatusCode)
    {
        SelectedCoffee = await response.Content.ReadFromJsonAsync<Coffee>();
    }
}

Here’s where we put the IsCreating boolean to good use. If we’re creating a coffee then call the Create API method, otherwise, Update.

For Creating the coffee we call PostAsJsonAsync passing in the route, “coffee” and the SelectedCoffee, which is what the API Create method expects as a parameter.

For Updating the syntax is similar, other than we call PutAsJsonAsync because the Update method is a PUT type.

Finally if the request is successful, then assign the updated/created coffee to the SelectedCoffee variable, to have the latest snapshot of it stored. This is done by calling the ReadFromJsonAsync on the response.Content.

Following we’re adding 2 more methods, one to Delete the coffee selected and one to Get the entire list of coffees:

async Task DeleteCoffee()
{
    var response = HttpClient.DeleteAsync($"Coffee/{SelectedCoffee.Id}");
}
async Task<List<Coffee>> GetCoffees()
{
    return await HttpClient.GetFromJsonAsync<List<Coffee>>("Coffee/List");
}

For Delete, the method we’re using from HttpClient is DeleteAsync, to which we’re passing in the route with the selected coffee id as a URL parameter.

The GetCoffees is what is being called OnInitializedAsync. This calls the GetFromJsonAsync, specifies the expected return type, which is a list of coffee objects, and passes in the route which is “coffee/list”, as specified in our API controller.

Last but not least from @code section, is the ShowCreate method:

void ShowCreate()
{
    SelectedCoffee = new();
    IsCreating = true;
}

All this function does is initializes a new instance of the SelectedCoffee ready to be populated when creating a new coffee. Then sets the IsCreating flag to true.

8. Add the UI mark-up for the list

Now that we’ve finished with the code section, let’s start adding some mark-up so that we can display and manipulate some coffees:

<div class="col-12 col-sm-12 col-md-12 col-lg-8 col-xl-8">
    <div class="row">
        <div class="col"><h3>Coffees</h3></div>
        <div class="col">
            <button type="button" class="btn btn-info float-right" @onclick="ShowCreate">Create</button>
        </div>
    </div>
    <div class="row">
        @if (Coffees.Any())
        {
            @foreach (var coffee in Coffees)
            {
                <div class="col-12 col-sm-6 col-md-6 col-lg-6 col-xl-4 mh-25 mb-4">
                    <div class="card @(SelectedCoffee?.Id == coffee.Id ? "bg-light" : "")">
                        <div class="card-body">
                            <h5 class="card-title">#@coffee.Id @coffee.Title</h5>
                            <p class="card-text">@coffee.Description.Substring(0, 80) ...</p>
                            <a href="/dashboard" @onclick="() => GetDetails(coffee.Id)">More...</a>
                        </div>
                    </div>
                </div>
            }
        }
        else
        {
            <p>No coffees available</p>
        }
    </div>
</div>

In a Bootstrap  row, add the following mark-up. The first part, lines 2-7, show a heading and the Create button with an @onclick event bound to the ShowCreate method we’ve created previously.

The second part, lines 9-29, iterate over our list of coffees stored in Coffees  and displays them as simple Bootstrap cards. We’re able to access properties of the coffees with each iteration by adding the @ sign in html syntax, i.e: @coffee.Title. We can also embed C# syntax in this razor component by using the @ sign too, i.e: @if (Coffee.Any()).

Finally, we’re binding the GetDetails method to the onclick event of the Mode button, passing in the coffee id of that object. Therefore each card inside this loop will have different coffee objects with the properties specified in our REST API.

To run the app execute the bellow command in the Package Manager Console inside Visual Studio. Then access one of the 2 URLs exposed https://localhost:5001 or http://localhost:5000. Visual Studio now executes a hot-reload feature every time you save. All the UI changes within the Razor component will be reflected on the site opened.

dotnet watch run -p server

dotnet run would just run it without hot-reload, but watch adds that nice feature in. Then -p represents the default project (aka –project), which is the server project.

This is what you should see so far:

9. Add the EditForm

Finally, to allow users to edit the coffees, we need to add a Blazor EditForm. This will be wrapped inside a Bootstrap column

<div class="col-12 col-sm-12 col-md-4 col-lg-4 col-xl-4">
    @if (SelectedCoffee is not null)
    {
    <EditForm class="container" Model="SelectedCoffee" OnValidSubmit="HandleValidSubmit">
        <DataAnnotationsValidator />
        <ValidationSummary />
        <div class="form-group">
            <label for="title">Title</label>
            <InputText id="title" @bind-Value="SelectedCoffee.Title" placeholder="Coffee title..." class="form-control" />
        </div>
        <div class="form-group">
            <label for="description">Description</label>
            <InputTextArea id="description" @bind-Value="SelectedCoffee.Description" placeholder="Description..." class="form-control" rows="7" />
        </div>
        <div class="row">
            <div class="col">
                <button type="submit" class="btn btn-success">Save</button>
            </div>
            <div class="col">
                <button type="button" @onclick="DeleteCoffee" class="btn btn-danger float-right">Delete</button>
            </div>
        </div>
    </EditForm>
    }
    else
    {
        <p>No coffee selected</p>
    }
</div>

We’ve added an EditForm component with the Model attribute bound to our SelectedCoffee object. Then The OnValidSubmit event is referencing the HandleOnValidSubmit function we created previously. If the form is not valid, then the validation errors will be shown with the help of the 2 Validation components on lines 5 & 6.

The fields InputText and InputTextArea use the @bind-Value directive to edit the Title respectively the Description of the coffee.

Lastly we have the two butotns Save & Delete which call the methods required.

The final version of our Dashboard.cs component looks like this:

@page "/dashboard"
@inject HttpClient HttpClient
@using CoffeePicker.Shared
<div class="row">
    <div class="col-12 col-sm-12 col-md-12 col-lg-8 col-xl-8">
        <div class="row">
            <div class="col"><h3>Coffees</h3></div>
            <div class="col">
                <button type="button" class="btn btn-info float-right" @onclick="ShowCreate">Create</button>
            </div>
        </div>
        <div class="row">
            @if (Coffees.Any())
            {
                @foreach (var coffee in Coffees)
                {
                    <div class="col-12 col-sm-6 col-md-6 col-lg-6 col-xl-4 mh-25 mb-4">
                        <div class="card @(SelectedCoffee?.Id == coffee.Id ? "bg-light" : "")">
                            <div class="card-body">
                                <h5 class="card-title">#@coffee.Id @coffee.Title</h5>
                                <p class="card-text">@coffee.Description.Substring(0, 80) ...</p>
                                <a href="/dashboard" @onclick="() => GetDetails(coffee.Id)">More...</a>
                            </div>
                        </div>
                    </div>
                }
            }
            else
            {
                <p>No coffees available</p>
            }
        </div>
    </div>
    <div class="col-12 col-sm-12 col-md-4 col-lg-4 col-xl-4">
        @if (SelectedCoffee is not null)
        {
        <EditForm class="container" Model="SelectedCoffee" OnValidSubmit="HandleValidSubmit">
            <DataAnnotationsValidator />
            <ValidationSummary />
            <div class="form-group">
                <label for="title">Title</label>
                <InputText id="title" @bind-Value="SelectedCoffee.Title" placeholder="Coffee title..." class="form-control" />
            </div>
            <div class="form-group">
                <label for="description">Description</label>
                <InputTextArea id="description" @bind-Value="SelectedCoffee.Description" placeholder="Description..." class="form-control" rows="7" />
            </div>
            <div class="row">
                <div class="col">
                    <button type="submit" class="btn btn-success">Save</button>
                </div>
                <div class="col">
                    <button type="button" @onclick="DeleteCoffee" class="btn btn-danger float-right">Delete</button>
                </div>
            </div>
        </EditForm>
        }
        else
        {
            <p>No coffee selected</p>
        }
    </div>
</div>
@code {
    private List<Coffee> Coffees = new();
    private Coffee SelectedCoffee;
    private bool IsCreating;
    protected override async Task OnInitializedAsync()
    {
        Coffees = await GetCoffees();
    }
    async Task GetDetails(int id)
    {
        SelectedCoffee = new();
        SelectedCoffee = await HttpClient.GetFromJsonAsync<Coffee>($"Coffee/{id}");
    }
    async Task HandleValidSubmit()
    {
        HttpResponseMessage response;
        if (IsCreating)
        {
            response = await HttpClient.PostAsJsonAsync<Coffee>("Coffee", SelectedCoffee);
        }
        else
        {
            response = await HttpClient.PutAsJsonAsync<Coffee>("Coffee", SelectedCoffee);
        }
        if (response.IsSuccessStatusCode)
        {
            SelectedCoffee = await response.Content.ReadFromJsonAsync<Coffee>();
        }
    }
    async Task DeleteCoffee()
    {
        var response = HttpClient.DeleteAsync($"Coffee/{SelectedCoffee.Id}");
    }
    async Task<List<Coffee>> GetCoffees()
    {
        return await HttpClient.GetFromJsonAsync<List<Coffee>>("Coffee/List");
    }
    void ShowCreate()
    {
        SelectedCoffee = new();
        IsCreating = true;
    }
}

And the app looks like this:

When initialized the component executes the Get request to get the entire list of coffees. Then Clicking the More button will execute a Get request to return the selected coffee. Clicking Save will run a PUT request to Update the coffee. Delete will make a DELETE request to remove that coffee from the list. And Create will clear out the fields leaving you to create a new coffee.

I hope this tutorial has been valuable to you, consider signing up to the email list if it was. I send updates weekly when tutorials like this are available. No spam, I promise!

Until next time, stay safe!