Using Minimal APIs in ASP.NET Core Razor Pages

If you are using ASP.NET Core Razor Pages to develop your web application, you have already decided that the majority of your HTML will be generated on the server. Nevertheless, chances are that you will want to introduce some client side operations into the application to improve its user friendliness in places. If those operations involve data, you will probably want to work with JSON. From .NET 6, you can use a simplified minimal request handler API that works with JSON by default.

Up until .NET 6, your options for working with JSON in Razor Pages were pretty much restricted to using page handler methods to accept and return JSON, or adding Web API controllers to the application. Returning JsonResults from page handler methods work, but there is something a little hacky about this approach. Razor pages are intended for generating UI, not providing data services over HTTP. Every time you call a page handler method from script, the PageModel and all its dependencies are instantiated whether they are needed or not. You can use Web API controllers, of course, but they require some additional configuration to get them to work.

Working with minimal API request handlers is designed to provide a low complexity experience. You register request handlers with the Map[HttpMethod] method on WebApplication - MapPost, MapGet, MapPut, and so on, using a similar HTTP method based naming convention to the one that you use to register page handler methods in a PageModel class. Recall that an instance of the WebApplication type is returned from the builder.Build method call in Program.cs (discussed in my previous article). You pass in a route template and a route handler - a standard .NET Delegate that executes when the route matches. This can be a named function or a lambda expression that can take parameters. The route handler can be configured to return one of many built-in response types, including JSON, text, and files. The obvious omission from the built-in return types is HTML.  That’s what Razor Pages is for.

I'm going to explore this new feature by implementing a simple one-page CRUD app that revolves around the same Car model and service that I have used in previous posts.

I have a Car class defined in a folder called Models:

namespace MinimalAPIs.Models;
 
public class Car
{
    public int Id { getset; }
    public string Make { getset; }
    public string Model { getset; }
    public int Year { getset; }
    public int Doors { getset; }
    public string Color { getset; }
    public decimal Price { getset; }
}

I've got a simple service that contains some basic CRUD operations against a list of cars:

public class CarService : ICarService
{
    public List<Car> GetAll() => cars;
    public void Save(Car car)
    {
        if (car.Id != 0)
        {
            var carToUpdate = cars.FirstOrDefault(c => c.Id == car.Id);
            if (carToUpdate != null)
            {
                cars.Remove(carToUpdate);
            }
        }
        else
        {
            car.Id = cars.Max(c => c.Id) + 1;
        }
        cars.Add(car);
    } 
 
    public Car Get(int id) => cars.FirstOrDefault(c => c.Id == id);
 
 
    private List<Car> cars = new (){
        new Car { Id = 1, Make = "Audi", Model = "R8", Year = 2018, Doors = 2, Color = "Red", Price = 79995 },
        new Car { Id = 2, Make = "Aston Martin", Model = "Rapide", Year = 2014, Doors = 2, Color = "Black", Price = 54995 },
        new Car { Id = 3, Make = "Porsche", Model = " 911 991", Year = 2020, Doors = 2, Color = "White", Price = 155000 },
        new Car { Id = 4, Make = "Mercedes-Benz", Model = "GLE 63S", Year = 2021, Doors = 5, Color = "Blue", Price = 83995 },
        new Car { Id = 5, Make = "BMW", Model = "X6 M", Year = 2020, Doors = 5, Color = "Silver", Price = 62995 },
    };
}

The service implements this interface:

public interface ICarService
{
    List<Car> GetAll();
    Car Get(int id);
    void Save(Car car);
}

And it's registered as a singleton so that any state within the service is maintained:

builder.Services.AddSingleton<ICarService, CarService>();

The following declaration registers a request handler that responds to GET requests. It takes a route template, optionally some parameters and returns a result. The request handler is declared in Program.cs, just before the app.Run method call. If you are using the older Startup approach to configuration, the registration will go in the Configure method after the call to UseEndpoints:

app.MapGet("/api/cars",  (ICarService service) =>
{
    return Results.Ok(service.GetAll());
});

The parameter in this example is the ICarService, which is resolved from the DI container. Parameters are bound from a number of other sources:

  • route values
  • query string
  • request headers
  • body

We can explicitly specify the parameter binding source using one of the From* attributes: FromRoute, FromBody, FromHeader, FromQuery or FromServices. The Results.Ok method returns the value passed in to it serialised to JSON with a 200 status code. So we have an API that will repsonde to GET requests at /api/cars, and will return a collection of cars serialised to JSON. Let's add a page to call this endpoint and display the data. Here's some simple markup for a button and an unordered list:

<button class="btn btn-primary" id="get-cars">Get Cars</button> 

<ul class="results mt-3"></ul>

Next, we need some script that wires up a click event handler to the button, fetches the data from the API and populates the unordered list:

@section scripts{
    <script>
        const list = document.querySelector('ul.results');
        const getAll = document.getElementById('get-cars');
 
        getAll.addEventListener('click', () => {
            showCars();
        });
 
        const showCars = () => {
            list.innerHTML = '';
            fetch("/api/cars")
            .then(response => response.json())
            .then(cars => {
                for(let i = 0;i < cars.length;i++){
                    let item = document.createElement('li');
                    item.innerText =  `${cars[i].id} ${cars[i].make} ${cars[i].model}${cars[i].year} £${cars[i].price}`;
                    item.classList.add('edit-car');
                    item.dataset.id = cars[i].id;
                    item.dataset.bsToggle = 'modal';
                    item.dataset.bsTarget = '#car-modal';
                    item.addEventListener('click', (event) =>{
                        getCar(event.target.dataset.id);
                    });
                    list.appendChild(item);
                }
            });
        }
    </script>
}

This code uses the Fetch API to make a call to the API, and then iterates the returned data, assigning each car to a list item. While doing so, it adds some attributes to the list item that will be used to invoke a Bootstrap modal for editing the car later. It also adds a click event handler to each item that will call a method called getCar, which is yet to be defined. However, if you run the page and click the button, the list of cars appears:

Minimal API, Razor Pages

Next, we add a partial page to the Pages/Shared folder named _CarModal.cshtml. This will contain a form in a Bootstrap modal that we will use to add new cars and edit existing ones. The complete markup for the file is as follows:

@model Car
<div class="modal fade" tabindex="-1" role="dialog" id="car-modal">
    <div class="modal-dialog" role="document">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">Save Car</h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
            </div>
            <div class="modal-body">
                <input asp-for="Id" type="hidden">
                <div class="form-group">
                    <label asp-for="Make"></label>
                    <input asp-for="Make" class="form-control">
                </div>
                <div class="form-group">
                    <label asp-for="Model"></label>
                    <input asp-for="Model" class="form-control">
                </div>
                <div class="form-group">
                    <label asp-for="Year"></label>
                    <input asp-for="Year" class="form-control">
                </div>
                <div class="form-group">
                    <label asp-for="Doors"></label>
                    <input asp-for="Doors" class="form-control">
                </div>
                <div class="form-group">
                    <label asp-for="Color"></label>
                    <input asp-for="Color" class="form-control">
                </div>
                <div class="form-group">
                    <label asp-for="Price"></label>
                    <input asp-for="Price" class="form-control">
                </div>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-primary" data-bs-dismiss="modal" id="save-car">Save changes</button>
                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
            </div>
        </div>
    </div>
</div>

The model for this file is a Car. To avoid having to use the fully qualified reference to the Car type, we add a using directive to the _ViewImports file:

@using MinimalAPIs.Models

We use the partial tag helper to include the partial in the page, passing a new instance of Car to the model:

<partial name="_CarModal" model="new Car()"/>

Now let's implement the JavaScript getCar method that is called by the click event handlers on the list items:

const getCar = (id) => {
    fetch(`/api/car/${id}`)
    .then(response => response.json())
    .then(car => {
        document.getElementById('Id').value = car.id;
        document.getElementById('Model').value = car.model;
        document.getElementById('Make').value = car.make; 
        document.getElementById('Year').value = car.year;
        document.getElementById('Doors').value = car.doors; 
        document.getElementById('Color').value = car.color; 
        document.getElementById('Price').value = car.price; 
    });
}

This method makes a Fetch request to a new API endpoint at /api/car/, passing in the Id of the specific car obtained from the data-id attribute that was added to the list item when it was generated. The returned Car has its properties assigned to form controls in the modal. We need to define that endpoint and get it to return a Car instance, serialised to JSON. the following is added to Program.cs, just after the previous API:

app.MapGet("/api/car/{id:int}", (int id, ICarService service=>
{
    var car = service.Get(id);
    return car;
});

The route template takes a parameter representing a route value, just like the rest of Razor Pages. The parameter can optionally be constrained. This time, the return type of the API is a simple object rather than an IResult. By default, this will be serialised to JSON. Once all of this has been added, we can run the application, click the Get Cars button, and then click on a car in the list and see its details appear in the modal:

Razor Pages Minimal APIs

At the moment, the Save Changes button doesn't do anything other than close the modal. So we will add a reference to it in the script block:

const save = document.getElementById('save-car');

Then we wire up an event listener that calls a saveCar method:

save.addEventListener('click', () => {
    saveCar();
});

Then we add the saveCar method to the script block:

const saveCar = () => {
    const model = {
        id:  document.getElementById('Id').value,
        model: document.getElementById('Model').value,
        make: document.getElementById('Make').value,
        year: document.getElementById('Year').value,
        doors: document.getElementById('Doors').value,
        color: document.getElementById('Color').value,
        price: document.getElementById('Price').value,
    };
    fetch('/api/save',  {  
        method: model.id > 0 ? 'put' : 'post',
        headers: {
            "Content-type""application/json",
        },
        body: JSON.stringify(model)
    });
}

This method obtains values from the modal form and creates a model. The model is converted to a JSON string before it is passed to the body of a Fetch request. the content type is specified as JSON and the method used for the request depends on whether the model has an Id value greater than zero. If it does, we are editing a car and use the PUT method. Otherwise it's a new car so we use the POST method. This differentiation isn't essential, but it conforms to best practice for RESTful services, and it helps to illustrate another feature of the minimal request handler - the ability to support multiple HTTP methods.

Here is the handler defined for the /api/save route:

app.MapMethods("/api/save"new[] {"POST""PUT"}, (Car car, ICarService service=>
{
    service.Save(car);
    return Results.Ok();
});

This time, we use MapMethods to register the request handler, which enables us to define the HTTP methods that this handler supports. These are specified in the array that we pass into the second parameter, so this request handler supports both the POST and PUT methods.

One final step remains, and that is to add a button that invokes the modal form with empty inputs so that we can add new cars:

<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#car-modal">New Car</button>

Summary

I've only scratched the surface in this introduction, but minimal API request handlers are great for working with JSON in a .NET 6 Razor Pages application. They are lightweight and fast, and save you having to configure Web API controllers or hacking away at returning JsonResults in from a page handler method.

The complete code for this article is available at https://github.com/mikebrind/MinimalAPIs.