How to use OData with ASP.NET Core Web API

In this article, let's look at how we can add OData capabilities to a simple Web API using the latest OData package and ASP.NET Core as our stack.

OData (Open Data Protocol) is an ISO/IEC approved, OASIS standard that defines a set of best practices for building and consuming RESTful APIs.

It can help enhance an API to have extensive capabilities by itself, while we don’t need to worry much about the data processing and response transformations as a whole and instead concentrate only on building the business logic for the API.

OData adds one layer over the API treating the endpoint itself as a resource and adds the transformation capabilities via the URL.

In this article, let’s look at how we can create a simple API and add OData capabilities to it. We shall use the latest OData package and ASP.NET Core as our stack.

Getting Started – Designing a Web API

Let’s take the example of a Heroes API. Technically, this API fetches all the Heroes stored in a backend database to the client as required.

The API exposes two endpoints to the clients:

  1. one which returns all the Heroes available in the database
  2. second is a GetById() endpoint which returns a single Hero based on an input Id.

The API employs a UnitOfWork approach, where there is a single UnitOfWork instance that is injected into the API controllers.

This also encapsulates the DatabaseContext and exposes a HeroesRepository. It uses Entity Framework Core to connect to the backend, and for simplicity sake uses an SQLite database.

The HeroesRepository contains the persistence logic to connect to the database and fetch the records.

// WebHeroesController (API Layer)

namespace ODataHeroes.API.Controllers
{
    public class WebHeroesController 
        : Controller, IHeroesController
    {
        private readonly IHeroesRepository _db;

        public WebHeroesController(IUnitOfWork repo)
        {
            _db = repo.Heroes;
        }

        [HttpGet("api/Heroes")]
        public IActionResult Get()
        {
            var x = _db.GetAll().AsQueryable();
            return Ok(x);
        }

        [HttpGet("api/Heroes/{id}")]
        public IActionResult Get(int id)
        {
            return Ok(_db.GetHeroes(id));
        }
    }
}

// UnitOfWork

namespace ODataHeroes.Core.Data.Repositories
{
    public class UnitOfWork : IUnitOfWork
    {
        private readonly DatabaseContext _context;

        public UnitOfWork(DatabaseContext context)
        {
            _context = context;
        }

        public IHeroesRepository Heroes 
            => new HeroesRepository(_context);

        public void Commit()
        {
            _context.SaveChanges();
        }
    }
}

// HeroesRepository

namespace ODataHeroes.Core.Data.Repositories
{
    public class HeroesRepository 
        : Repository<Hero>, IHeroesRepository
    {
        public HeroesRepository(
            DatabaseContext context) : base(context)
        {
        }

        public HeroDto GetHeroes(int id)
        {
            var x = Get(id);

            if (x == null) return new HeroDto();

            return new HeroDto
            {
                Id = x.Id,
                Description = x.Description,
                HeroName = x.Name,
                AddedOn = x.AddedOn,
            };
        }
    }
}

Now we’re interested in adding another implementation of the same class with OData. To have a separation of the OData endpoint from the existing endpoint without disturbing the existing state, we shall add a new controller endpoint called HeroesController which shall now host the mapping for the OData to work.

As a better code practice, both the HeroesController and the WebHeroesController implement an abstraction IHeroesController which specifies the endpoint methods for both the implementations.

namespace ODataHeroes.Contracts.Controllers
{
    public interface IHeroesController
    {
        IActionResult Get();
        IActionResult Get(int id);
    }
}

Integrating OData – Getting Started

We shall start the OData integration by adding the necessary packages onto the project. OData provides us with a nuget package Microsoft.AspNetCore.OData which adds the necessary packages and libraries for setting up OData endpoint to our app.


> dotnet add package Microsoft.AspNetCore.OData

Note: The latest OData package is now bumped to v8.0.4, which fixes the Endpoint Routing issues post ASP.NET Core 3.1 and now supports .NET 5 as well. However there are significant changes in the way OData is configured in the solution and the Routes are decorated. The code snippets are now updated accordingly. You can still find the old MVC implementation available in the GitHub repository here.

We’ll begin by implementing the IHeroesController in our HeroesController class as below. There are no big differences when it comes to the logic; everything is pretty straight forward and same as the existing. There are two important things to notice here:

  1. The class extends ODataController instead of the typical Controller which a normal WebAPI Controller would extend.
  2. The routes are specified with normal Route attributes, but every Route has a prefix “odata”. We’ll see the reason as we move forward.
  3. There is an additional EnableQuery attribute on top of every GET API endpoint.
namespace ODataHeroes.API.Controllers.OData
{
    public class HeroesController 
        : ODataController, IHeroesController
    {
        private readonly IHeroesRepository _db;

        public HeroesController(IUnitOfWork repo)
        {
            _db = repo.Heroes;
        }

        [EnableQuery]
        [HttpGet("odata/Heroes")]
        [HttpGet("odata/Heroes/$count")]
        public IActionResult Get()
        {
            var x = _db.GetAll().AsQueryable();
            return Ok(x);
        }

        [EnableQuery]
        [HttpGet("odata/Heroes({id})")]
        [HttpGet("odata/Heroes/{id}")]
        public IActionResult Get(int id)
        {
            return Ok(_db.GetHeroes(id));
        }
    }
}

The attribute [EnableQuery] enables the endpoint to be queried by using the OData syntax. Within the Route attributes we can define how the OData endpoint can look like. We can also define the mandatory path parameters with the API.

This is particularly helpful when we have to enable OData on an API which works on a specific resource denoted by the parameter. With this simple configuration, we are done with the endpoint generation.

We need to configure the OData middleware along with setting up the parameters for the functions we have defined over the routes.

Configuring MVC Routing

Note: The below points are specific to ASP.NET Core 3.1 and OData 7.4.x package

One must keep in mind when integrating OData with dotnet core particularly for ASP.NET Core 3.1 and above an important change.

“Since dotnetcore3.0 and above use Endpoint routing in place of the conventional Routing used in aspnetcore2.2 and below, this is not compatible with the OData routing which works with conventional routing under the hood and is suseptible to issues in runtime.
Hence we would need to disable endpoint routing and use the good old routing.”

Simply put, OData routing isn’t working when we use the UseRouting() and UseEndpoints() setup. Hence, we would need to use our good old UseMvc() when configuring OData into the aspnetcore3.x project.

// ConfigureServices
services.AddOData();
services.AddMvc(options 
    => options.EnableEndpointRouting = false);

// Configure
IEdmModel model = EdmModelBuilder.Build();
app.UseOData(model);

app.UseMvc(builder =>
{
    builder
        .Select()
        .Expand()
        .Filter()
        .OrderBy()
        .MaxTop(1000)
        .Count();
    
    builder.MapODataServiceRoute(
        "odata", "odata", model);
    builder.MapRoute(
        name: "Default", 
        template: 
            "{controller=Home}/{action=Index}/{id?}");
    });

As mentioned above, we’re using the AddMvc() and UseMvc() methods which set the conventional Routing to the pipeline, along with options.EnableEndpointRouting = false which is mandatory for the dotnetcore runtime to disable endpoint routing.

We have also added the AddOData() and UseOData() methods to the pipeline for the OData middleware and services be configured onto the pipeline. The UseOData() method takes a parameter of type IEdmModel, where we configure our OData endpoints. Finally within the UseMvc() method, we map the OData service routes to the Mvc routing engine.

The method call builder.Select().Expand().Filter().OrderBy().MaxTop(1000).Count() ensures that all of the typical OData features (projection, filtering, aggregations and such) are enabled for the routes.

Note that these are the changes when NOT USING ENDPOINT routing, which was the case before 8.x, for the latest package that we’re using NOW it is done as below.

Configuring the OData through Endpoint Routing – The Latest and Recommended Approach

We first add OData services to the service pipeline via the ConfigureServices() method, and then add the OData middleware using the Configure() method.

namespace ODataHeroes.API
{
    public class Startup
    {
        public Startup(
            IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(
            IServiceCollection services)
        {
            services.AddDbContext<DatabaseContext>((builder) =>
            {
                builder.UseSqlite(
                    Configuration.GetConnectionString("DefaultConnection"),
                    (x) => x.MigrationsAssembly("ODataHeroes.Migrations"));
            });

            services.AddScoped<
                IUnitOfWork, UnitOfWork>();

            services.AddControllers()
                .AddOData(opt => 
                    opt.AddRouteComponents("odata", EdmModelBuilder.Build()));
        }

        public void Configure(
            IApplicationBuilder app, 
            IWebHostEnvironment env)
        {
            app.UseHttpsRedirection();
            app.UseRouting();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }
}

The latest OData supports Endpoint routing, so we just add the AddOData() chained to AddControllers() within the ConfigureServices() method and within the AddOData() method we pass a lambda for configuration.

In this we call AddRouteComponents() which takes in two parameters: “odata” which shall be the route prefix for OData to trigger and the second is an EdmModel instance which we create in the below section.

Building the OData Model

Within the EdmModelBuilder.Build() method, which is a custom class we have written for separation of the configuration logic, we have the following:

namespace ODataHeroes.API
{
    public static class EdmModelBuilder
    {
        public static IEdmModel Build()
        {
            // create OData builder instance
            var builder = new ODataConventionModelBuilder();
            
            // map the entityset which is the type returned
            // from the endpoint onto the OData pipeline
            // the string parameter is the name of the controller 
            // which supplies the data of type HeroModel in this case
            
            var heroes = builder
                .EntitySet<Hero>("Heroes")
                .EntityType.HasKey(x => x.Id);
            
            heroes
                .Count()
                .Filter()
                .OrderBy()
                .Expand()
                .Select()
                .Page(100, 100);

            // return the fully configured builder model 
            // on which the OData library shall be built
            
            return builder.GetEdmModel();
        }
    }
}

The approach is quite self-explanatory. We define an EntitySet, which takes in the name of the Controller that acts as DataSource for OData. In our case it was “HeroesController” so the name we pass is “Heroes” (since while routing “Heroes” is the path picked up for HeroesController). Following this is the setup of supported operations. We don’t have a Top() method directly, instead we use Page() which takes the params of maximum top values and page size.

Finally we return the EdmModel that is built on the EdmModelBuilder.

One thing to be noted here is the line of code


builder.EntitySet<Hero>("Heroes")
  .EntityType.HasKey(x => x.Id);

where we have defined an entity key Id on to the model Hero. Whereas the Hero class looks like below:

namespace ODataHeroes.Migrations.Entities
{
    public class Hero
    {
        [Key]
        public int Id { get; set; }
        public string Name { get; set; }
        public DateTime AddedOn { get; set; }
        public string Description { get; set; }
    }
}

OData expects the entity model on top of which the query processing shall happen to have a key which ensures data integrity. If we don’t have a Key in our model and when we try to configure OData we would get the below error:


An exception of type 'System.InvalidOperationException' occurred in Microsoft.AspNetCore.OData.dll 
but was not handled in user code: 'The entity set 'Heroes' is based on 
type 'ODataHeroes.Migrations.Entities.Hero' that has no keys defined.'

Hence it is mandatory that the model which our API returns must bear a unique Id attribute with a [Key] attribute on which the OData shall be configured.

We have now completed our entire setup of an OData endpoint for an existing API. To confirm that the OData is configured properly, we run the below endpoint which returns the configuration metadata based on what we have defined in our IEdmModel.

GET https://localhost:5001/odata/$metadata

<edmx:Edmx xmlns_edmx="http://docs.oasis-open.org/odata/ns/edmx" Version="4.0">
<edmx:DataServices>
<Schema xmlns="http://docs.oasis-open.org/odata/ns/edm" 
    Namespace="ODataHeroes.Migrations.Entities">
<EntityType Name="Hero">
<Key>
<PropertyRef Name="Id"/>
</Key>
<Property Name="Id" Type="Edm.Int32" Nullable="false"/>
<Property Name="Name" Type="Edm.String"/>
<Property Name="AddedOn" Type="Edm.DateTimeOffset" Nullable="false"/>
<Property Name="Description" Type="Edm.String"/>
</EntityType>
</Schema>
<Schema xmlns="http://docs.oasis-open.org/odata/ns/edm" Namespace="Default">
<EntityContainer Name="Container">
<EntitySet Name="Heroes" EntityType="ODataHeroes.Migrations.Entities.Hero"/>
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>

Next, to see OData in action we call a couple of endpoints as below to see how it works.

Using Simple Get with $select, $top

GET https://localhost:5001/odata/heroes?$select=Id,Name&$top=5

{
    "@odata.context": "https://localhost:5001/odata/$metadata#Heroes(Id,Name)",
    "value": [
        {
            "Id": 1,
            "Name": "Hero #1001"
        },
        {
            "Id": 2,
            "Name": "Hero #1659"
        },
        {
            "Id": 3,
            "Name": "Hero #1660"
        },
        {
            "Id": 4,
            "Name": "Hero #1661"
        },
        {
            "Id": 5,
            "Name": "Hero #1662"
        }
    ]
}

Get Count of Records by $count

GET https://localhost:5001/odata/heroes/$count

2000

Similarly, we can add the OData processing onto API with a route parameter as below. We send the id value the return data of which shall be put to processing in the way similar to how we have configured in our route.

Simple GetById() with $select

GET https://localhost:5001/odata/heroes(1000)?$select=Id,Name,Description

{
    "@odata.context": "https://localhost:5001/odata/$metadata#Heroes(Id,Name,Description)/$entity",
    "Id": 1000,
    "Name": "Hero #2000",
    "Description": "Saikyo no Hero #2000"
}

Buy Me A Coffee

Found this article helpful? Please consider supporting!

Conclusion and.. Boilerplate! 🥳

In this way, we can transform our existing API into OData API without having to disturb the existing logic, with a model which is not necessarily an Entity. OData provides a simple and ready made API solution with perks of filtering, selection and aggregations.

The code snippets used in this article are a part of the ODataHeroes boilerplate created to help developers getting into OData, to quickstart with a simple and ready-to-use solution that contains functioning code.

Please do leave a star if you find the solution helpful. The repository is available here. The master branch contains the latest OData library with .NET 5. If you’re looking for ASP.NET Core 3.1 with OData 7.4 implementation, its available here.


Buy Me A Coffee

Found this article helpful? Please consider supporting!

Ram
Ram

I'm a full-stack developer and a software enthusiast who likes to play around with cloud and tech stack out of curiosity. You can connect with me on Medium, Twitter or LinkedIn.

Leave a Reply

Your email address will not be published. Required fields are marked *