Card image cap

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

ASP.NET Core  • Posted 7 months ago

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.

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.

Getting Started - Inspecting Existing API:

Let's take the example of a Readers API with a single endpoint to fetch all Readers along with the User details, and try upgrading it to OData. 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.

# API Controller #

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());
        }
    }
}

# Repository Implementation #

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 to fetch all the readers with an existing User accounts, where the entities are given out by a ReadersContext class. 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 ODataReadersController which shall now host the mapping for the OData to work.

As a better code practice, both the ReadersController and the ODataReadersController implement an abstraction IReadersController which specifies the endpoint methods for both the implementations.


namespace ODataCore3.API.Controllers
{
    public interface IReadersController
    {
        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

We'll begin by implementing the IReadersController in our ODataReadersController 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 ControllerBase which a normal API Controller would extend.
  2. The routes are specified with decorator ODataRoute instead of the standard Route decorator.
  3. There is an additional EnableQuery attribute on top of every GET API endpoint.

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 must keep in mind when integrating OData with dotnet core particularly for AspNetCore3.0 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.

Configuring the OData Routing - Startup class:

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?}");
            });
        }
    }
}

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.

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 ODataCore3.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 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; }
    }
}

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.'

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

Response:

<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:


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

Response:

{
  "@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. 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.

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

Response:

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

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.

The complete working project is available at: https://github.com/referbruv/odatasample

What is the difference between Response.Redirect() and Server.Transfer() ?
How do you handle errors Globally in ASP.NET Core?
How do you design a strongly-typed class for a configuration?
How can you bind a configuration section to an object?
When to use IOptionsMonitor?

asp.net core 3.1 odata odata .net core 3.1 .net core 3.1 odata odata asp.net core 3.1 odata net core 3 odata dotnet core 3.1 odata net core 3.1 asp.net core 3.0 odata odata .net core 3 odata asp.net core 3 mapodataserviceroute .net core 3 .net core 3 odata asp.net core 3 odata asp.net core odata odata core 3.1 net core 3.1 odata odata asp net core 3 odata dotnet core 3 odata .net core 3.0 net core 3 odata odata asp.net core .net core odata dotnet core 3.1 odata odata asp.net core 3.0 asp.net core web api odata odata .net core asp.net core odata routing asp.net core odata count not working asp.net core odata example odata dotnet core odata with .net core .net core 3.0 odata odata aspnet core odata enablequery .net core odata example asp net core odata without entity framework asp.net core odata swagger odata endpoint routing

We use cookies to provide you with a great user experience, analyze traffic and serve targeted promotions.   Learn More   Accept