Card image cap

Working with OData - Integrating an Existing ASP.NET Core 3.x API

Off late, there has been a tremendous surge in demand for APIs and data endpoints which can effectively produce desired dataset along with features empowering the clients to pull data via these APIs and be able to transform the dataset into whatever form required for them. And there are several libraries such as OData, GraphQL and such which have been pioneering in providing such effective libraries. In this article let's talk about how we can empower an existing API by adding OData capabilities to it, developed in ASP.NET Core 3.1. Let's also talk about the things which might come in our way, and how we can elegantly handle structuring our endpoint classes.

A little about OData:

OData (Open Data Protocol) is an ISO/IEC approved, OASIS standard that defines a set of best practices for building and consuming RESTful APIs. OData 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 capabilites via the URL. More about OData can be learnt in their website odata.org

Into the Hands-on:

For our example, Let's assume we have our Readers API with a single endpoint to fetch all Readers along with the User details. Here we have a one-to-one mapping between two entities: Reader and User. And our API endpoint joins details from both User and Reader entities and returns a model containing selected fields from both the entities. The API follows a Repository pattern approach wherein the business logic or the data logic is encapsulated under a ReadersRepo class which is injected into the Controller via IoC.


namespace ODataCore3.API.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ReadersController : ControllerBase, IReadersController
    {
        IReadersRepo repo;
        public ReadersController(IReadersRepo repo)
        {
            this.repo = repo;
        }

        [HttpGet]
        public IActionResult Get(int id)
        {
            return Ok(this.repo.GetReaders(id));
        }

        public IActionResult Get()
        {
            return Ok(this.repo.GetReaders());
        }
    }
}

And the ReadersRepo is defined as below:


namespace ODataCore3.API.Providers
{
    public class ReadersRepo : IReadersRepo
    {
        private IReadersContext context;

        public ReadersRepo(IReadersContext context)
        {
            this.context = context;
        }

        public IQueryable<ReaderModel> GetReaders()
        {
            var res = this.context.Users.Join(this.context.Readers, u => u.Id, r => r.UserId, (u, r) => new ReaderModel
            {
                UserName = u.UserName,
                EmailAddress = u.EmailAddress,
                ReaderName = r.Name,
                ReaderAddedOn = r.AddedOn,
                Description = r.Description,
                IsReaderActive = u.IsActive,
                Id = r.Id
            });

            return res;
        }

        public IQueryable<ReaderModel> GetReaders(int id)
        {
            return this.GetReaders().Where(x => x.Id == id);
        }
    }
}

We use a Linq JOIN method for our processing, where the entities are given out by a ReadersContext class. Now this API at its existing state is pretty much working fine and we want to add OData on top of this. In order to have a separation of the OData endpoint from the existing endpoint without disturbing the existing state, I added a new controller called ODataReadersController which shall now host the mapping for the OData to work. And to enforce the structure of the existing ReadersController onto the new endpoint, I extracted an interface out of the ReadersController and made the ODataReadersController implement it.


namespace ODataCore3.API.Controllers
{
    public interface IReadersController
    {
        IActionResult Get();
        IActionResult Get(int id);
    }
}

Next, I shall start my OData integration by adding the OData library for dotnet core by adding the package.


\ODataCore3.API> dotnet add package Microsoft.AspNetCore.OData

This package adds the necessary packages and libraries for setting up OData endpoint to our app. Next, the ODataReadersController is designed as below. There are no big differences when it comes to the logic; everything is pretty straight forward and same as the existing. The only change is the way the Controller is configured to extend ODataController in place of the ControllerBase of the Mvc package and the Routing.


namespace ODataCore3.API.Controllers
{
    public class ODataReadersController : ODataController, IReadersController
    {
        private IReadersRepo repo;

        public ODataReadersController(IReadersRepo repo)
        {
            this.repo = repo;
        }

        [HttpGet]
        [EnableQuery]
        [ODataRoute("AllReaders()")]
        public IActionResult Get()
        {
            return Ok(this.repo.GetReaders());
        }

        [HttpGet]
        [EnableQuery]
        [ODataRoute("ReadersById(id={id})")]
        public IActionResult Get(int id)
        {
            return Ok(this.repo.GetReaders(id));
        }
    }
}

The attribute [EnableQuery] enables the endpoint to be queried by using the OData syntax. And by [ODataRoute] we can define how the OData endpoint can look like along with how we can pass mandatory path parameters to 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. Now we need to configure the OData middleware along with setting up the parameters for the functions we have defined over the routes.

One thing to keep in mind when integrating OData with dotnet core particularly for the versions 3.0 and above is that 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.

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


namespace ODataCore3.API
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddScoped<IReadersContext, ReadersContext>();
            services.AddScoped<IReadersRepo, ReadersRepo>();
            services.AddOData();
            services.AddMvc(options => options.EnableEndpointRouting = false);
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            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?}");
            });
        }
    }
}

We can see that 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.

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


namespace ODataCore3.API
{
    internal static class EdmModelBuilder
    {
        internal 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 ReaderModel in this case
            builder.EntitySet<ReaderModel>("ODataReaders").EntityType.HasKey(x => x.Id);

            // configure a function onto the builder, AllReaders 
            // which is same as the name provided in the ODataRoute
            var fnAllReaders = builder.Function("AllReaders");

            // define what type the function returns; here it is of type ReaderModel
            fnAllReaders.ReturnsCollectionFromEntitySet<ReaderModel>("ODataReaders");

            // configure a function onto the builder, ReadersById 
            // which is same as the name provided in the ODataRoute
            var fnReadersById = builder.Function("ReadersById");

            // since this function takes a parameter of type id, 
            // define what type the parameter accepts and 
            // the identifier same as the one mentioned within the route
            fnReadersById.Parameter<int>("id");

            // define what type the function returns; here it is of type ReaderModel
            fnReadersById.ReturnsCollectionFromEntitySet<ReaderModel>("ODataReaders");

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

One thing to be noted here is the line of code


builder.EntitySet<ReaderModel>("ODataReaders").EntityType.HasKey(x => x.Id);

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


namespace ODataCore3.API.Models
{
    public class ReaderModel
    {
        [Key]
        public int Id { get; set; }
        public string UserName { get; set; }
        public string EmailAddress { get; set; }
        public string ReaderName { get; set; }
        public DateTime ReaderAddedOn { get; set; }
        public bool IsReaderActive { get; set; }
        public string Description { get; set; }
    }
}

One can question why we have an Id field which is also defined as a primary key (denoted by the attribute [Key]) in a plain model class. This is because 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 'ODataReaders' is based on type 'ODataCore3.API.Controllers.ReaderModel' that has no keys defined.'

And hence a [Key] attribute is necessary for a model on which the OData shall be configured.

With this, we have completed our entire setup of an OData endpoint for an existing API. When we run the API, to confirm that the OData is fully setup, we can run the below endpoint which returns the configuration metadata based on what we have defined in our IEdmModel.

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

Which returns something like below:


<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="ODataCore3.API.Models">
            <EntityType Name="ReaderModel">
                <Key>
                    <PropertyRef Name="Id" />
                </Key>
                <Property Name="Id" Type="Edm.Int32" Nullable="false" />
                <Property Name="UserName" Type="Edm.String" />
                <Property Name="EmailAddress" Type="Edm.String" />
                <Property Name="ReaderName" Type="Edm.String" />
                <Property Name="ReaderAddedOn" Type="Edm.DateTimeOffset" Nullable="false" />
                <Property Name="IsReaderActive" Type="Edm.Boolean" Nullable="false" />
                <Property Name="Description" Type="Edm.String" />
            </EntityType>
        </Schema>
        <Schema xmlns="http://docs.oasis-open.org/odata/ns/edm" Namespace="Default">
            <Function Name="AllReaders">
                <ReturnType Type="Collection(ODataCore3.API.Models.ReaderModel)" />
            </Function>
            <Function Name="ReadersById">
                <Parameter Name="id" Type="Edm.Int32" Nullable="false" />
                <ReturnType Type="Collection(ODataCore3.API.Models.ReaderModel)" />
            </Function>
            <EntityContainer Name="Container">
                <EntitySet Name="ODataReaders" EntityType="ODataCore3.API.Models.ReaderModel" />
                <FunctionImport Name="AllReaders" Function="Default.AllReaders" EntitySet="ODataReaders" IncludeInServiceDocument="true" />
                <FunctionImport Name="ReadersById" Function="Default.ReadersById" EntitySet="ODataReaders" IncludeInServiceDocument="true" />
            </EntityContainer>
        </Schema>
    </edmx:DataServices>
</edmx:Edmx>

And when we call the AllReaders endpoint with OData processing such as selective projection, which should return all the readers with only projected information:

https://localhost:5001/odata/allreaders?$select=UserName,EmailAddress,ReaderName


{
  "@odata.context": "https://localhost:5001/odata/$metadata#ODataReaders(UserName,EmailAddress,ReaderName)",
  "value": [
    {
      "UserName": "User#101",
      "EmailAddress": "user.101@abc.com",
      "ReaderName": "Reader#1001"
    },
    {
      "UserName": "User#102",
      "EmailAddress": "user.102@abc.com",
      "ReaderName": "Reader#1002"
    },
    {
      "UserName": "User#103",
      "EmailAddress": "user.103@abc.com",
      "ReaderName": "Reader#1003"
    },
    {
      "UserName": "User#104",
      "EmailAddress": "user.104@abc.com",
      "ReaderName": "Reader#1004"
    },
    {
      "UserName": "User#105",
      "EmailAddress": "user.105@abc.com",
      "ReaderName": "Reader#1005"
    }
  ]
}

Similarly, we can add the OData processing onto API with a route parameter as below:

https://localhost:5001/odata/readersbyid(id=1001)?$select=UserName,EmailAddress,ReaderName


{
  "@odata.context": "https://localhost:5001/odata/$metadata#ODataReaders(UserName,EmailAddress,ReaderName)",
  "value": [
    {
      "UserName": "User#101",
      "EmailAddress": "user.101@abc.com",
      "ReaderName": "Reader#1001"
    }
  ]
}

Observe that 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.

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.

Published 3 days ago

Sponsored Links
We use cookies to improve user experience. Learn More