Working With OData in ASP.NET Core | Pluralsight
Mar 25, 2021 • 3 Minute Read
Introduction to OData
According to the people who designed it, OData (the open data protocol), is "the best way to REST".
REST, as you may know, is an architectural style. It provides us with a set of constraints and sub constraints, like the fact that client-server interaction should be stateless, the fact that each message must include enough information that describes how to process the message, and so on.
But REST is not a standard. Next to that it also leaves quite a few things open for us, developers, to decide upon. Take resource naming guidelines, for example. You've undoubtedly seen resources named like "api/employee/1" (singular noun approach), "api/employees/1" (plural noun approach), "api/meaninglessname/1" or "api/nomeaning/123/evenlessmeaning/234" - all of these examples can be ways to refer the exact same employee resource.
While being compliant to REST, some of these are not exactly developer-friendly, especially when the project you're working on consists of a mix of all these different resource naming styles. This is just a simple example. Once more exotic constraints like HATEOAS need to be implemented in your RESTful system, deciding on how to define the contracts for those leaves even more open for interpretation.
That's where OData comes in. OData is, essentially, a way to try and standardize REST. It's an open protocol that allows the creating and consumption of queryable and interoperable RESTful APIs in a simple and standard way. It describes things like which HTTP method to use for which type of request, which status codes to return when, but also: URL conventions.
And it also includes information on querying data - filtering and paging, but also: calling custom functions and actions, working with batch requests, and more.
The current version is v4, which consists of 2 main parts.
Part 1 is the protocol itself, which contains descriptions of the headers to use, the potential response codes, definitions on what an entity is, a singleton, derived, expanded or projected entities and so on, including how a request & response should look.
Part 2 is all about those URL conventions.
A part of this article will deal with the ability to easily query data, one of the main drivers for companies to adopt OData-implementing frameworks. To enable that it's important to agree upon what a URL to query data should look like. That's what's described in that part of the standard.
Prerequisites - Adding the Correct NuGet Packages
The first thing you'll want to do, after starting a new API project, is adding the necessary OData NuGet package. The one you want is Microsoft.AspNetCore.OData. This is supported for .NET Core and for .NET 5. Version 7.x of the package is for .NET Core, version 8.x is for .NET 5. At the moment of writing, the .NET 5 version is still in preview mode, but chances are that by the time you're reading this it will be out of preview. While preparing for this article and a refresh to my OData course I worked with both the 7.x and 8.x packages and the differences are absolutely minimal. For this article I'll use the .NET 5 version, but what you'll read here applies to both unless mentioned otherwise.
After adding the package, the Entity Data Model should be defined.
Defining an Entity Data Model
The Entity Data Model, or EDM, is the abstract data model that is used to describe the data exposed by an OData service. You could consider this the "heart" of your OData services.
If you've worked with Entity Framework Core, this concept will sound familiar - the EDM is not at all exclusive to OData, EF Core works on something likewise. It was introduced as a set of concepts that describe the structure of data, regardless of its stored form. The EDM makes the stored form of data irrelevant to application design and development. And, because entities and relationships describe the structure of data as it is used in an application (not its stored form), they can evolve as an application evolves.
You can see an example of that here. This is an EDM that exposes a set of people and a set of vinyl records.
public class AirVinylEntityDataModel
{
public IEdmModel GetEntityDataModel()
{
var builder = new ODataConventionModelBuilder();
builder.Namespace = "AirVinyl";
builder.ContainerName = "AirVinylContainer";
builder.EntitySet<Person>("People");
builder.EntitySet<VinylRecord>("VinylRecords");
return builder.GetEdmModel();
}
}
Once the EDM has been defined, we need to register it. To do so, we should call into AddOData on the services collection in the ConfigureServices method on our Startup class. This registers OData services on the IoC container, and it accepts a delegate to configure ODataOptions. One of the things we can do through that is register the EDM model by calling into AddModel and passing through a model prefix (which will shine through in the route) and a model instance.
public void ConfigureServices(IServiceCollection services)
{
// … other code …
services.AddOData(opt => opt.AddModel(
"odata",
new AirVinylEntityDataModel().GetEntityDataModel()));
}
This is the only place in this article where I have to point out a change between v8.x and v7.x. In v7.x, setting this up was still split up across 2 pieces of code. You'd first register the necessary services on the container via a call into AddOData.
public void ConfigureServices(IServiceCollection services)
{
// … other code …
services.AddOData();
}
In the Configure method you'd additionally add a call into MapODataRoute to map routes to OData controllers, and that's where the EDM is injected.
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapODataRoute(
"AirVinyl OData",
"odata",
new AirVinylEntityDataModel().GetEntityDataModel());
});
Regardless of the version you're using, you should now be able to run your application and surf to the application root, followed by /odata. That "odata" prefix is coming from when we registered the Odata model.
GET https://localhost:44376/odata/
This gives you the service document.
{
"@odata.context": "https://localhost:44376/odata/$metadata",
"value": [
{
"name": "People",
"kind": "EntitySet",
"url": "People"
},
{
"name": "VinylRecords",
"kind": "EntitySet",
"url": "VinylRecords"
}
]
}
Add $metadata to the URL, and you get the metadata document.
<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
<edmx:DataServices>
<Schema Namespace="AirVinyl" xmlns="http://docs.oasis-open.org/odata/ns/edm">
<EntityType Name="Person">
<Key>
<PropertyRef Name="PersonId" />
</Key>
<Property Name="PersonId" Type="Edm.Int32" Nullable="false" />
<Property Name="Email" Type="Edm.String" />
<Property Name="FirstName" Type="Edm.String" Nullable="false" />
<Property Name="LastName" Type="Edm.String" Nullable="false" />
<Property Name="DateOfBirth" Type="Edm.DateTimeOffset" Nullable="false" />
<Property Name="Gender" Type="AirVinyl.Gender" Nullable="false" />
<Property Name="NumberOfRecordsOnWishList" Type="Edm.Int32" Nullable="false" />
<Property Name="AmountOfCashToSpend" Type="Edm.Decimal" Nullable="false" />
<NavigationProperty Name="VinylRecords" Type="Collection(AirVinyl.VinylRecord)" />
</EntityType>
<EntityType Name="VinylRecord">
<Key>
<PropertyRef Name="VinylRecordId" />
</Key>
<Property Name="VinylRecordId" Type="Edm.Int32" Nullable="false" />
<Property Name="Title" Type="Edm.String" Nullable="false" />
<Property Name="Artist" Type="Edm.String" Nullable="false" />
<Property Name="CatalogNumber" Type="Edm.String" />
<Property Name="Year" Type="Edm.Int32" />
<Property Name="PressingDetailId" Type="Edm.Int32" />
<Property Name="PersonId" Type="Edm.Int32" />
<NavigationProperty Name="PressingDetail" Type="AirVinyl.PressingDetail">
<ReferentialConstraint Property="PressingDetailId" ReferencedProperty="PressingDetailId" />
</NavigationProperty>
<NavigationProperty Name="Person" Type="AirVinyl.Person">
<ReferentialConstraint Property="PersonId" ReferencedProperty="PersonId" />
</NavigationProperty>
</EntityType>
<EntityType Name="PressingDetail">
<Key>
<PropertyRef Name="PressingDetailId" />
</Key>
<Property Name="PressingDetailId" Type="Edm.Int32" Nullable="false" />
<Property Name="Grams" Type="Edm.Int32" Nullable="false" />
<Property Name="Inches" Type="Edm.Int32" Nullable="false" />
<Property Name="Description" Type="Edm.String" Nullable="false" />
</EntityType>
<EnumType Name="Gender">
<Member Name="Female" Value="0" />
<Member Name="Male" Value="1" />
<Member Name="Other" Value="2" />
</EnumType>
<EntityContainer Name="AirVinylContainer">
<EntitySet Name="People" EntityType="AirVinyl.Person">
<NavigationPropertyBinding Path="VinylRecords" Target="VinylRecords" />
</EntitySet>
<EntitySet Name="VinylRecords" EntityType="AirVinyl.VinylRecord">
<NavigationPropertyBinding Path="Person" Target="People" />
</EntitySet>
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>
This metadata document is important. As you can see, this is a representation of the data model that describes the data and operations exposed by our still-to-be-implemented OData service. We can find the People and VinylRecords & related entity types described here, amongst other things. It's documents like these that allow for easy integration & data generation scenarios: client applications can read out this document and, from interpreting it, learn how to interact with this OData service. Generating DTOs or even full clients for interaction with this service are possible thanks to this. Stepping back to REST, of which OData is an implementation, this document helps with conforming to the HATEOAS subconstraint.
Creating an OData service
To define an OData service, simply derive from ODataController. This is a base class for OData controllers that support writing and reading data using the OData formats. It derives from ControllerBase, so it still supports most of the controller-related actions we'd expect when building an API, but instead of using the default routing, it supports OData routing principles - more on how those URLs look later. The same goes for serialization: rather than using the default ASP.NET Core formatters, OData-specific formatters are used. On top of that two additional methods are available to create action results to respond to POST and action results to respond to manipulations, like PUT, PATCH or MERGE. So that's what this controller enables and contains.
public class PeopleController : ODataController
{
private readonly AirVinylDbContext _airVinylDbContext;
public PeopleController(AirVinylDbContext airVinylDbContext)
{
_airVinylDbContext = airVinylDbContext
?? throw new ArgumentNullException(nameof(airVinylDbContext));
}
[HttpGet]
[ODataRoute("People")]
public IActionResult Get()
{
return Ok(_airVinylDbContext.People);
}
[HttpGet]
[ODataRoute("People/({key})")]
public IActionResult Get(int key)
{
var people = _airVinylDbContext.People.Where(p => p.PersonId == key);
if (!people.Any())
{
return NotFound();
}
return Ok(SingleResult.Create(people));
}
}
The code you see is an implementation of that with two actions: one to get all people, one to get one specific person. You can see that I explicitly state that these are to be routed to when the GET method is used by attributing them with the HttpGet attribute. This isn't strictly necessary: conventions are in place, a method named "Get" will automatically be mapped to the http GET method, and the "People" part from the controller name will be assumed as part of the route - so the ODataRoute attribute isn't strictly necessary either. That said: Microsoft advises to refrain from convention-based routing for APIs and instead use attribute-based routing, so I prefer not to rely on constraints.
To get all people, a GET request should be sent to scheme://host:port/odata/People.
GET scheme://host:port/odata/People
Accept: application/json
This returns a list of people. To get one specific person, a GET request should be sent to scheme://host:port/odata/People(1), where "1" is the person's id. You can see those URL syntax guidelines shine through here: these types of identifiers are surrounded with rounded brackets and are not in a URL fragment of their own.
GET scheme://host:port/odata/People(1)
Accept: application/json
{
"@odata.context": "https://localhost:44376/odata/$metadata#People/$entity",
"PersonId": 1,
"Email": "[email protected]",
"FirstName": "Kevin",
"LastName": "Dockx",
"DateOfBirth": "1981-05-05T00:00:00+02:00",
"Gender": "Male",
"NumberOfRecordsOnWishList": 10,
"AmountOfCashToSpend": 300.00
}
This returns one specific person. By manipulating the Accept header and passing through a value for odata.metadata, additional metadata about this person can be requested. Potential values are none, minimal and full.
GET scheme://host:port/odata/People(1)
Accept: application/json;odata.metadata=full
{
"@odata.context": "https://localhost:44376/odata/$metadata#People/$entity",
"@odata.type": "#AirVinyl.Person",
"@odata.id": "https://localhost:44376/odata/People(1)",
"@odata.editLink": "People(1)",
"PersonId": 1,
"Email": "[email protected]",
"FirstName": "Kevin",
"LastName": "Dockx",
"[email protected]": "#DateTimeOffset",
"DateOfBirth": "1981-05-05T00:00:00+02:00",
"[email protected]": "#AirVinyl.Gender",
"Gender": "Male",
"NumberOfRecordsOnWishList": 10,
"[email protected]": "#Decimal",
"AmountOfCashToSpend": 300.00,
"[email protected]": "https://localhost:44376/odata/People(1)/VinylRecords/$ref",
"[email protected]": "https://localhost:44376/odata/People(1)/VinylRecords"
}
What you see here is the result of requesting full metadata for one person. From this information, clients can learn how to interact with this person and what is allowed in regards to interaction. A good example is the odata.editLink in the response: if this is returned, a client knows that the resource can be edited and how to do it. It could react to that knowledge by showing or hiding an edit button in its UI. The hypertext in the metadata thus drives the state of the client application. This, again, is HATEOAS at work.
Manipulating resources through OData
Of course, creating and manipulating resources is possible as well.
[HttpPost]
[ODataRoute("People")]
public IActionResult CreatePerson([FromBody] Person person)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
// add the person to the People collection
_airVinylDbContext.People.Add(person);
_airVinylDbContext.SaveChanges();
// return the created person
return Created(person);
}
A request to create a resource "Person" should be a POST request to /People with the person in the request body. A 201 Created with the created resource in the response body must be returned when the person was successfully created. By allowing us to pass through the actual type via the odata.type annotation, OData goes beyond what most web APIs offer. Typically, APIs require us to pass through the data format via the Content-Type header - application/json, for example. By going beyond that and allowing us to specify the actual type, OData improves the contract between client and server.
CREATE scheme://host:port/People
Accept: application/json
Content-Type: application/json
{
"@odata.type":"AirVinyl.Entities.Person",
"FirstName":"Emma",
"LastName":"Smith",
"Email": "[email protected]",
"Gender":"Female",
"DateOfBirth": "1980-01-30"
}
As far as updating is concerned, OData allows using PUT for updates but encourages using PATCH. According to the HTTP standard, PUT should only be used for full updates, and applications typically have more need for partial updates. That's what PATCH is for: it allows passing through a change set.
[HttpPatch]
[ODataRoute("People({key})")]
public IActionResult PartiallyUpdatePerson(int key, [FromBody] Delta<Person> patch)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var currentPerson = _airVinylDbContext.People
.FirstOrDefault(p => p.PersonId == key);
if (currentPerson == null)
{
return NotFound();
}
patch.Patch(currentPerson);
_airVinylDbContext.SaveChanges();
return NoContent();
}
Important to notice here is the Delta that's accepted as action parameter and deserialized from the request body: this represents a set of changes that has to be applied to the person, or, in other words, a set of changes the person has to be patched with. Effectively applying this change set is done through the patch.Patch(currentPerson) statement.
To partially update a person, a PATCH request is sent to host:port/odata/People/(1) with the change set in the request body. A successful request results in a 204 No Content or a 200 Ok with the updated person in the response body.
PATCH scheme://host:port/odata/People(1)
Accept: application/json
Content-Type: application/json
{
"FirstName": "Nick",
"Email": "[email protected]"
}
Supporting delete is pretty straightforward.
[HttpDelete]
[ODataRoute("People({key})")]
public IActionResult DeletePerson(int key)
{
var currentPerson = _airVinylDbContext.People
.FirstOrDefault(p => p.PersonId == key);
if (currentPerson == null)
{
return NotFound();
}
_airVinylDbContext.People.Remove(currentPerson);
_airVinylDbContext.SaveChanges();
return NoContent();
}
Deleting a person can be achieved by sending a DELETE request to the resource you want to delete. A successful request results in a 204 No Content.
DELETE scheme://host:port/odata/People(1)
Accept: application/json
Not everything comes out of the box…
Important to know is that it's not because we're using Microsoft's OData packages that we get all OData functionality out of the box. You should consider these helper classes for building OData services - but there's still a bit of work we have to do ourselves. For example: the OData standard describes how to get raw property values. The response for getting a person's first name would not contain a JSON representation of the first name as such:
GET scheme://host:port/odata/People(1)/FirstName/$value
{
"firstName": "Kevin"
}
But only the actual first name, as such:
Kevin
This is not included out of the box, we have to implement support for this ourselves. The code below shows one way of doing that.
[HttpGet]
[ODataRoute("People({key})/FirstName/$value")]
public IActionResult GetPersonPropertyRawValue(int key)
{
var person = _airVinylDbContext.People
.FirstOrDefault(p => p.PersonId == key);
if (person == null)
{
return NotFound();
}
var url = HttpContext.Request.GetEncodedUrl();
var propertyToGet = new Uri(url).Segments[^2].TrimEnd('/');
if (!person.HasProperty(propertyToGet))
{
return NotFound();
}
var propertyValue = person.GetValue(propertyToGet);
if (propertyValue == null)
{
return NoContent();
}
return Ok(propertyValue.ToString());
}
And it works the other way around as well: when you want to implement a certain piece of functionality, make sure you stick to what is allowed by the OData standard if you want to stay compliant.
In regards to that: a lot is allowed by the OData standard. Let's look into a few of its more useful features.
Querying Data
Querying data is where OData really shines. Out of the box we get a huge amount of functionality that would take a lot of time to implement from scratch. To enable querying, apply the EnableQuery attribute to an entity set resource like People:
[EnableQuery]
public IActionResult Get()
{
return Ok(_airVinylDbContext.People);
}
A lot of query options exist: we can apply filters, select specific fields, expand entities so its children are included (multiple levels deep), order and page, and so on. Important to know is that each query option must explicitly be enabled. Say we want to allow selecting individual fields when getting people: next to applying the EnableQuery attribute, we must state that the select option is enable application-wide by calling into .Select when configuring the OData services in the ConfigureServices method on the Startup class:
services.AddOData(opt =>
opt.AddModel(
"odata",
new AirVinylEntityDataModel().GetEntityDataModel())
.Select());
From this moment on we can select individual fields by passing the ones we want through via query string:
GET scheme://host:port/odata/People?$select=Email,FirstName
Accept: application/json
This returns a set of data as such:
{
"@odata.context": "https://localhost:44376/odata/$metadata#People(Email,FirstName)",
"value": [
{
"Email": "[email protected]",
"FirstName": "Kevin"
},
{
"Email": "[email protected]",
"FirstName": "Sven"
},
{
"Email": "[email protected]",
"FirstName": "Nele"
},
{
"Email": "[email protected]",
"FirstName": "Nils"
},
{
"Email": "[email protected]",
"FirstName": "Tim"
}
]
}
This isn't just some projection on the data that’s being returned in the response body. Behind the scenes, Microsoft's OData packages will create dynamic LINQ queries to manipulate what's requested from the database, resulting in only those exact fields that are requested being selected from the database. In other words: we're not only saving bandwidth by not sending fields that we don't need to the client, we're also improving performance on a database level by not requesting columns we don't need.
Expanding is another query option that's very useful. First, we should enable it:
services.AddOData(opt =>
opt.AddModel(
"odata", new AirVinylEntityDataModel().GetEntityDataModel())
.Select().Expand());
After that, sending a request as such:
GET scheme://host:port/odata/People?$expand=VinylRecords
Accept: application/json
will result in each person's vinyl records being returned in the response body. We're not restricted to one level deep either. We can ask to expand on vinyl records, and expand each vinyl record with its pressing detail entity:
GET scheme://host:port/odata/People?$expand=VinylRecords($expand=PressingDetail)
Accept: application/json
This will result in a result set with, for each person, the vinyl records being returned and for each vinyl record the pressing details being returned. There's nothing stopping us from combining this with the select query option as such:
GET scheme://host:port/odata/People?$select=Email,FirstName&$expand=VinylRecords($select=Title;$expand=PressingDetail($select=Grams))
This time, only the email and firstname fields will be selected for each person, only the title field will be selected for each vinyl record, and only the grams field will be selected for each pressing detail.
And these query options go on and on, right up to the ability to pass through calculations in a filter via the URL. A book could be written detailing all the possible options - in fact, it kind of has been written: is the URL syntax part of the OData protocol. Without going into detail, here are a few additional requests that are valid:
Ordering:
GET scheme://host:port/odata/People?$orderby=Gender desc, Email desc&$expand=VinylRecords($select=Title;$orderby=Title)
Paging:
GET scheme://host:port/odata/People?$top=5&$skip=15
Filtering:
GET scheme://host:port/odata/People?$expand=VinylRecords($filter=Year ge 2000)
Batch Processing
The final feature I'd like to touch upon in this article is batch processing. A pretty common requirement in applications is executing a set of requests in one go instead of sending over each request separately. A good example is a client application that allows manipulating a few entities (create one, update one, delete a few, …) after which the user clicks a "save" button. Only on save, the requests are sent to the OData API. Another example would be allowing a client to get multiple unrelated resources in one go. Sending all of those requests one by one is not very efficient: it creates a lot of unnecessary overhead by negotiating each of them separately, including the related connection.
To solve this, OData allows sending through a set of changes in one go by POSTing it as a multipart message to the OData service root while adding the $batch postfix. To enable this feature we need an object that can handle such a request. Microsoft's OData packages include such an object: the DefaultODataBatchHandler. After newing it up, we can pass it through when configuring the Odata services in the ConfigureServices method on the Startup class:
var batchHandler = new DefaultODataBatchHandler();
services.AddOData(opt =>
opt.AddModel("odata",
new AirVinylEntityDataModel().GetEntityDataModel(),
batchHandler)
.Select().Expand());
An example of a batch request that will combine getting 2 people's vinyl records in one go can be seen here:
POST scheme://host:port/odata/$batch
Content-Type: multipart/mixed;boundary=batch_a8ba333b-34b7-4699-bdfa-af8d46dbbcbf
--batch_a8ba333b-34b7-4699-bdfa-af8d46dbbcbf
Content-Type: application/http
Content-Transfer-Encoding:binary
GET https://localhost:44736/odata/People(1)/VinylRecords HTTP/1.1
Accept: application/json
--batch_a8ba333b-34b7-4699-bdfa-af8d46dbbcbf
Content-Type: application/http
Content-Transfer-Encoding:binary
GET https://localhost:44736/odata/People(2)/VinylRecords HTTP/1.1
Accept: application/json
--batch_a8ba333b-34b7-4699-bdfa-af8d46dbbcbf--
Important to notice is that the batch request starts with two dashed followed by a boundary value: this is a unique value of our own choosing, for example: batch_ a8ba333b-34b7-4699-bdfa-af8d46dbbcbf, and it is used to separate each request from each other in the request body. It's passed through via the Content-Type header as well so the server knows which boundary value to look for when processing this message.
Each of the requests in this message is processed one by one, and the combined results are returned in one response message.
What else is there?
The abilities OData offers don't stop here. Some other useful features are the ability to call custom functions, request singletons, work with derived and open types and generate client applications from an Odata specification. All of this and more is covered in my OData courses