In-Depth
Use OData Containment To Make the Easy Connections in an ASP.NET Web App
The Open Data Protocol allows for creation and consumption of REST APIs. Sam Nasr demonstrates OData 4 in a sample app that demonstrates containment, a new feature for facilitating data access.
OData, also known as the Open Data Protocol, enables the creation and consumption of REST APIs. This allows data to be published and edited by Web clients using simple HTTP messages. Odata, currently at version 4, provides more features for facilitating data access, such as containment, which is a new feature allows a client to access a parent record and its children, all contained in the same result set.
To demonstrate OData containment, I will create a demo application using Visual Studio Ultimate 2013 Update 3. To start, I will create an ASP.Net Web Application, called ODContainment (see Figure 1).
Next, I'll select an Empty Web template with Web API folders and references, shown in Figure 2.
To add the required libraries to the solution, the OData NuGet package needs to be installed. This can be done by entering "Install-Package Microsoft.AspNet.Odata" in the Package Manager Console.
With the solution template in place, the next step is to create the model. In a production code scenario, a reference to an Entity Framework object would be used. However, for the purpose of this article, I will use an in-memory object with hard coded data. This will make the demo code more portable and easier for readers to implement.
To add the data model, I will add a class to the Models folder, called CustomerOrders.cs, contents of which is in Listing 1.
Listing 1: Contents of CustomerOrders.cs
using System.Collections.Generic;
using System.Web.OData.Builder; //Manually Added
namespace ODContainment.Models
{
public class Customer
{
public int CustomerID { get; set; }
public string FullName { get; set; }
[Contained]
public IList<Order> OrdersShipped { get; set; }
[Contained]
public Order CurrentOrder { get; set; }
}
public class Order
{
public int OrderID { get; set; }
public string ShippingAddress { get; set; }
}
}
The data model is made to reflect a relationship between a customer and their orders. Note the inclusion of namespace System.Web.OData.Builder. This is added manually to allow the use of the [Contained] attribute. It's this attribute that allows the parent record to contain child records when accessed via OData calls.
The next step is to add a controller class to the Controller folder. Right clicking the Controllers folder, I select Add | Controller | OData Controller with read/write actions, shown in Figure 3.
The model chosen for the scaffolding will be the Customer model, and the controller name will be called "CustomersController." This can be seen in Figure 4.
I will modify the newly created controller by removing some of the default methods so it better suits the purpose of this demonstration. The controller class will have three action methods for handling data queries: Get(), GetOrdersShipped(), and GetSingleOrderShipped(). All three methods are decorated with the [EnableQuery] attribute. This is required for an action method to support OData queries.
I will also add a method called GetDemoData() for loading the data object in memory with hardcoded values. This method loads two parent records, each with a one "CurrentOrder" record and two "OrdersShipped" records. A complete listing of the controller class can be seen in Listing 2.
Listing 2: Contents of CustomersController.cs
using System.Collections.Generic;
using System.Linq;
using System.Web.Http;
using ODContainment.Models;
using System.Web.OData; //Added Manually
using System.Web.OData.Routing; //Added Manually
namespace ODContainment.Controllers
{
public class CustomersController : ODataController
{
private static IList<Customer> _Customers = null;
public CustomersController()
{
if (_Customers == null)
{
_Customers = GetDemoData();
}
}
[EnableQuery]
public IHttpActionResult Get()
{
return Ok(_Customers.AsQueryable());
}
[EnableQuery]
public IHttpActionResult GetOrdersShipped(int key)
{
var OrdersShipped = _Customers.Single(a => a.CustomerID == key).OrdersShipped;
return Ok(OrdersShipped);
}
[EnableQuery]
[ODataRoute("Customers({CustomerId})/OrdersShipped({OrderId})")]
public IHttpActionResult GetSingleOrderShipped(int CustomerId, int OrderId)
{
var OrdersShipped = _Customers.Single(a => a.CustomerID == CustomerId).OrdersShipped;
var OrderShipped = OrdersShipped.Single(pi => pi.OrderID == OrderId);
return Ok(OrderShipped);
}
private static IList<Customer> GetDemoData()
{
var Customers = new List<Customer>()
{
new Customer()
{
CustomerID = 100,
FullName="Sam Nasr",
CurrentOrder = new Order()
{
OrderID = 103,
ShippingAddress = "1234 Walnut Street, Cleveland, Ohio 44101",
},
OrdersShipped = new List<Order>()
{
new Order()
{
OrderID = 101,
ShippingAddress = "2121 E.9th Street, Cleveland, Ohio 44103",
},
new Order()
{
OrderID = 102,
ShippingAddress = "3221 W.6th Street, Cleveland, Ohio 44104",
},
},
},
new Customer()
{
CustomerID = 200,
FullName="James Williams",
CurrentOrder = new Order()
{
OrderID = 203,
ShippingAddress = "8901 Chestnut Street, Cleveland, Ohio 44101",
},
OrdersShipped = new List<Order>()
{
new Order()
{
OrderID = 201,
ShippingAddress = "5477 E.49th Street, Cleveland, Ohio 44103",
},
new Order()
{
OrderID = 202,
ShippingAddress = "7181 W.6th Street, Cleveland, Ohio 44104",
},
},
}
};
return Customers;
}
}
}
The last step in coding this solution is coding the Register() method in App_Start\WebApiConfig.cs. The code for this file can be seen in Listing 3.
Listing 3: Contents of App_Start\WebApiConfig.cs
using System.Web.Http;
using ODContainment.Models;
using System.Web.OData.Builder; //Added Manually
using System.Web.OData.Extensions; //Added Manually
using System.Net.Http.Formatting; //Added Manually
namespace ODContainment
{
public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
ODataConventionModelBuilder builder = new ODataConventionModelBuilder();
var OrderType = builder.EntityType<Order>();
builder.EntitySet<Customer>("Customers");
builder.Namespace = typeof(Customer).Namespace;
config.IncludeErrorDetailPolicy = IncludeErrorDetailPolicy.Always;
config.MapODataServiceRoute(routeName: "OData", routePrefix: "odata", model: builder.GetEdmModel());
config.Formatters.Clear(); //Remove all other formatters
config.Formatters.Add(new JsonMediaTypeFormatter()); //Enable JSON in the web service
}
}
1. }
At the top of the file you will notice the addition of three namespaces needed for code in the Register() method. This code first instantiates an ODataConventionModelBuilder object used for building the data model structure. Another important step in the Register() method is defining the routing that will be used for OData calls. This is done by using the MapODataServiceRoute().
The final process in the Register() method involves specifying the data format. For this demo, I'll utilize JSON by instantiating the JsonMediaTypeFormatter(). This will cause the client to receive JSON formatted data for the OData calls. At this stage, the solution should build without issue.
To test the application, I will create a simple HTML page (index.html) that will act as a test harness. This page will contain a few links for calling into the controller and the results will be returned directly to the browser as a JSON formatted text file. The content added to index.html are shown in Listing 4.
Listing 4: Links Listed on index.html
To view meta data:
1. http://localhost:41093/odata/$metadata
2. To view current order: http://localhost:41093/odata/Customers?$expand=CurrentOrder
3. To view all Orders Shipped for CustomerID=100: http://localhost:41093/odata/Customers(100)/OrdersShipped
4. To view OrderShippedID=101 for CustomerID=100: http://localhost:41093/odata/Customers(100)/OrdersShipped(101)
The first link is used to display the meta data of the data structure and relationships in XML format. An example of this can be seen in Listing 5.
Listing 5: Meta Data of the Customer-Orders Data Model
1. <?xml version="1.0" encoding="UTF-8"?>
2. -<edmx:Edmx xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx" Version="4.0">
3. -<edmx:DataServices>
4. -<Schema xmlns="http://docs.oasis-open.org/odata/ns/edm" Namespace="ODContainment.Models">
5. -<EntityType Name="Order">
6. -<Key>
7. <PropertyRef Name="OrderID"/>
8. </Key>
9. <Property Name="OrderID" Nullable="false" Type="Edm.Int32"/>
10. <Property Name="ShippingAddress" Type="Edm.String"/>
11. </EntityType>
12. -<EntityType Name="Customer">
13. -<Key>
14. <PropertyRef Name="CustomerID"/>
15. </Key>
16. <Property Name="CustomerID" Nullable="false" Type="Edm.Int32"/>
17. <Property Name="FullName" Type="Edm.String"/>
18. <NavigationProperty Name="OrdersShipped" Type="Collection(ODContainment.Models.Order)" ContainsTarget="true"/>
19. <NavigationProperty Name="CurrentOrder" Type="ODContainment.Models.Order" ContainsTarget="true"/>
20. </EntityType>
21. -<EntityContainer Name="Container">
22. <EntitySet Name="Customers" EntityType="ODContainment.Models.Customer"/>
23. </EntityContainer>
24. </Schema>
25. </edmx:DataServices>
26. </edmx:Edmx>
The two primary data entities, Order and Customer, are defined on lines 5 and 12, respectively. Lines 18 and 19 show the navigation properties of OrdersShipped and CurrentOrder defined in the Customer entity. Notice that both navigation properties have ContainTarget="true". This is because we decorated both properties in the CustomerOrders.cs with the [Contained] attribute. Therefore, those records will be contained in the Customer object when queried. Line 22 shows the Customers entity is defined as a container, so it will be the holding container of any child records.
The second link on the Test Page is http://localhost:41093/odata/Customers?$expand=CurrentOrder. This link will query all customers in the data set and display the CurrentOrder for each one. Because of the routing template defined in the WebApiConfig.cs, the link maps to the Get() method in the CustomersController. The output, received in JSON format, can be seen in Listing 6.
Listing 6: Result Set of All Customers and Perspective Current Order
{
"@odata.context":"http://localhost:41093/odata/$metadata#Customers","value":[
{
"CustomerID":100,"FullName":"Sam Nasr","[email protected]":"http://localhost:41093/odata/$metadata#Customers(100)/CurrentOrder/$entity","CurrentOrder":{
"OrderID":103,"ShippingAddress":"1234 Walnut Street, Cleveland, Ohio 44101"
}
},{
"CustomerID":200,"FullName":"James Williams","[email protected]":"http://localhost:41093/odata/$metadata#Customers(200)/CurrentOrder/$entity","CurrentOrder":{
"OrderID":203,"ShippingAddress":"8901 Chestnut Street, Cleveland, Ohio 44101"
}
}
]
}
Although the Customer object contains child records for both OrdersShipped and CurrentOrder, only CurrentOrder is displayed as dictated by the URI parameter "$expand=CurrentOrder".
In contrast, I can modify the URI to be only http://localhost:41093/odata/Customers. The results returned will show only the parent records, without expanding any of the child records. This data set is shown in Listing 7.
Listing 7: Results of http://localhost:41093/odata/Customers
{
"@odata.context":"http://localhost:41093/odata/$metadata#Customers","value":[
{
"CustomerID":100,"FullName":"Sam Nasr"
},{
"CustomerID":200,"FullName":"James Williams"
}
]
}
This demonstrates the power of OData, where the data queries can be dynamically changed in the URI via the query parameters. For a complete list of new URI parameters available in OData v4, read "OData Version 4.0 Part 2: URL Conventions Plus Errata 02" on the OASIS Web site.
The third link will be used to demonstrate the containment feature. When the URI http://localhost:41093/odata/Customers(100)/OrdersShipped is clicked, it matches the routing template for GetOrdersShipped(). This returns the Customer record for CustomerID=100, and its corresponding OrdersShipped child records, seen in Listing 8.
Listing 8: Customer Record and Contained OrdersShipped Records for CustomerID=100
{
"@odata.context":"http://localhost:41093/odata/$metadata#Customers(100)/OrdersShipped","value":[
{
"OrderID":101,"ShippingAddress":"2121 E.9th Street, Cleveland, Ohio 44103"
},{
"OrderID":102,"ShippingAddress":"3221 W.6th Street, Cleveland, Ohio 44104"
}
]
}
The fourth link will further demonstrate the containment feature. By using the link http://localhost:41093/odata/Customers(100)/OrdersShipped(101), the call will map to GetSingleOrderShipped() action method in the CustomersController. This is due to the method being decorated with [ODataRoute("Customers({CustomerId})/OrdersShipped({OrderId})")]. The resulting output shows the Customer record for CustomerID=100 and only one OrdersShipped record for OrderID=100. The output is shown in Listing 9.
Listing 9: Customer Record for CustomerID=100 and OrdersShipped Record for OrderID=101
{
"@odata.context":"http://localhost:41093/odata/$metadata#Customers(100)/OrdersShipped/$entity","OrderID":101,"ShippingAddress":"2121 E.9th Street, Cleveland, Ohio 44103"
}
Not only does OData provide containment of the child records with the parent record, but it also allows the client to query the child records as well.
OData 4 has proven to be a productive protocol for accessing data. With the introduction of containment, parent records queried will be returned to the client with the child records contained in the same result set. A client application no longer has to parse records or make separate calls for each parent record to retrieve the child records. In addition, query parameters now support querying both parent and child records, making OData more efficient than ever.