When working with open types in an OData service, dynamic properties are held in a container property of type IDictionary<string, object>
. However, support for a similar container property has traditionally been absent in OData client. For this reason, dynamic properties returned from an OData service could only be mapped to declared properties on the client backing type. Where the backing types are autogenerated using a code generator like OData Connected Service, the developer would augment the autogenerated class with a partial class, to avoid messing around with the autogenerated code.
For instance, for a property PurchaseDescription
returned as a dynamic property by the service, the corresponding PurchaseDescription
 property on the client side would be declared on a partial class as follows:
public partial class Product { public string PurchaseDescription { get { return this._PurchaseDescription; } set { this.OnPurchaseDescriptionChanging(value); this._PurchaseDescription = value; this.OnPurchaseDescriptionChanged(); this.OnPropertyChanged("PurchaseDescription"); } } private string _PurchaseDescription; partial void OnPurchaseDescriptionChanging(string value); partial void OnPurchaseDescriptionChanged(); }
Note: The partial class needs to be in the same namespace as the other partial autogenerated by the code generator.
Support for dynamic properties container property in OData client
Microsoft.OData.Client 7.7.0 introduced an alternative way of working with dynamic properties on the client side. You can now add a container property of type IDictionary<string, object>
(or any type that implements IDictionary
, e.g. Dictionary<string, object>
, SortedDictionary<string, object>
, etc.) to hold the dynamic properties.
Dynamic properties in the response payload are materialized and added to the dictionary for every property that is not mapped to a declared property on the backing type. Conversely, in insert/update scenarios, key/value pairs in the dictionary are serialized and added to the request payload.
In support of this, OData Connected Service release 0.10.0 introduced support for emitting the dynamic properties container property for open types on the client. The emitted property looks like this in C#:
[global::Microsoft.OData.Client.ContainerProperty] public IDictionary<string, object> DynamicProperties { get { return _DynamicProperties; } set { this.OnDynamicPropertiesChanging(value); this._DynamicProperties = value; this.OnDynamicPropertiesChanged(); this.OnPropertyChanged("DynamicProperties"); } } partial void OnDynamicPropertiesChanging(IDictionary<string, object> value); partial void OnDynamicPropertiesChanged(); private IDictionary<string, object> _DynamicProperties = new Dictionary<string, object>();
Note: Namespace (global::System.Collections.Generic
) stripped to make the code snippet fit better.
A developer who may not be using OData Connected Service extension or any other code generator that emits the container property would need to add the property manually and decorate it with the Microsoft.OData.ContainerProperty
attribute.
Materialization and serialization of dynamic properties
The rules surrounding the materialization and serialization of dynamic properties are as follows:
- The value is either:
- Primitive, or
- Enum, or
- Complex (including nested complex), or
- Any collection of the above
- If an object is being serialized and an item in the container property has a key that conflicts with a declared property, the container property item is ignored – since you can’t have two properties named the same in the Json-serialized request payload.
Type resolution for dynamic properties
Unlike declared properties where the type is explicitly specified, dynamic properties rely on the type annotations from the service response for type resolution. The snippet below shows two ways type annotations may appear on the response:
"NextOfKin": { "@odata.type": "#ServiceNS.Models.NextOfKin", "Name": "Nextof Kin001", "HomeAddress": { "AddressLine1": "NAL04", "Street": "NS04", "City": "NC04", "Country": "NC04", "PostalCode": "NPC04" } }, "Addresses@odata.type": "#Collection(ServiceNS.Models.Address)", "Addresses": [ { "AddressLine1": "DAL02", "Street": "DS02", "City": "DC02", "Country": "DC02", "PostalCode": "DPC02" }, { "AddressLine1": "DAL03", "Street": "DS03", "City": "DC03", "Country": "DC03", "PostalCode": "DPC03" } ]
Take specific note of @odata.type
property nested within NextOfKin
property value and Addresses@odata.type
appearing before Addresses
property.
Object materialization
The materialization process starts with resolving the backing type on the client, following which an instance of that type is instantiated. The process of materializing individual declared and dynamic properties then follows.
Type resolution for dynamic properties that are primitive in nature happens automatically. For other categories of dynamic properties, we employ the below strategies – in sequence – to resolve the applicable type.
Type resolution during object materialization
- The
DataServiceContext
class has a property namedResolveType
of typeFunc<string, Type>
. This property can be set to a delegate that identifies a function override that is used to override the default type resolution option that is used by the client library during materialization of entities. The client may use this hook to do custom mapping between the type name in the response payload and a type on the client. Where this property is initialized, the delegate is invoked. Below is an illustration of howResolveType
property can be initialized:dataServiceContext.ResolveType = (typeName) => { if (typeName.Equals("ServiceNS.Models.Address")) return typeof(ClientNS.Models.Address); // ... }
- If the first strategy fails to yield the desired outcome, the second strategy will involve scanning across the assembly for a type with a name matching that of the server type. How does this work? Suppose we are materializing properties of an object of type
Customer
. If we encounter a dynamic property with a type annotationNS.Address
, we will scan the assembly thatCustomer
type belongs to for a type namedNS.Address
. Code generators like OData Connected Service emit client types with names matching those of the server types. - If the second strategy still fails to yield the desired outcome, the third strategy will involve scanning the assembly for a type with the same unqualified name (e.g.
Address
) as the server type. Suppose we are materializing properties of an object of typeCustomer
belonging toClient.NS
namespace. If we encounter a dynamic property with type nameAddress
, we will scan the assembly thatCustomer
type belongs to for a type namedClient.NS.Address
.
If despite all that effort we are unable to resolve the applicable type, we ignore that dynamic property and move on.
Object serialization
Serialization on the other hand requires that each dynamic property whose type cannot be automatically detected when the request payload reaches the service be preceded with a type annotation, e.g.:
"FavoriteGenre@odata.type": "#ServiceNS.Models.Genre", "FavoriteGenre": "SciFi",
To aid in this, the DataServiceContext
class has a property named ResolveName
of type Func<Type, string>
. It enables the client to do custom mapping between the type on the client and the name of the corresponding type on the server – the reverse of what ResolveType
does during materialization.
dataServiceContext.ResolveName = (type) => { if (type.Equals(typeof(ClientNS.Models.Address))) return "ServiceNS.Models.Address"; // ... }
You can forgo the requirement for the ResolveName
hook by using matching qualified names for your server and client types.
Practical Walkthrough
This walkthrough assumes that you already know how to create an OData service in ASP.NET Web API. It also assumes you are familiar with dynamic properties in OData. We will not explore the general concepts behind OData in depth in this walkthrough. You can learn more about OData and application of dynamic properties from the following pages:
- OData – the Best Way to REST
- OData Documentation – OData Microsoft Docs
- Open Types in OData v4 with ASP.NET Web API
Software versions used in this walkthrough
- Visual Studio 2019
- ASP.NET Core 3.1
- Microsoft.AspNetCore.OData 7.4.1
- Microsoft.OData.Client 7.7.0
Creating an OData Service
Start a new instance of Visual Studio and create a new project based on ASP.NET Core Web Application template. Specify .NET Core managed framework and ASP.NET Core 3.1 web-development framework. The empty project template should suffice.
When the project is ready, right-click on the project node from the Solution Explorer and change the default namespace for the project to ServiceNS
from the Application tab.
Use Nuget Package Manager to install the AspNetCore Web API OData library. From the Package Manager Console window:
Install-Package Microsoft.AspNetCore.OData
Define the Service CLR Types
Here we define the Edm models as CLR types.
namespace ServiceNS.Models { // Genre enum type public enum Genre { Thriller = 1, SciFi = 2, Epic = 3 } // Address open complex type public class Address { public string AddressLine { get; set; } public string City { get; set; } public IDictionary<string, object> DynamicProperties { get; set; } } // NextOfKin complex type (with nested Address complex type) public class NextOfKin { public string Name { get; set; } public Address HomeAddress { get; set; } } // Director open entity type public class Director { public int Id { get; set; } public string Name { get; set; } public IDictionary<string, object> DynamicProperties { get; set; } } }
Build the Edm Model and Bootstrap the Service
In Startup.cs, we use ODataConventionModelBuilder
to build the Edm model, configure the service, and map the OData route using MapODataServiceRoute
extension method.
Note: You can also use MapODataRoute
extension method to configure the service with endpoint routing. You can read about how to configure an OData service with endpoint routing here.
namespace ServiceNS { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddMvc( options => options.EnableEndpointRouting = false).SetCompatibilityVersion(CompatibilityVersion.Version_3_0); services.AddOData(); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseMvc(routeBuilder => { routeBuild-er.Select().Filter().Expand().Count().OrderBy().SkipToken().MaxTop(100); routeBuilder.MapODataServiceRoute("odata", "odata", GetEdmModel()); routeBuilder.EnableDependencyInjection(); }); } private IEdmModel GetEdmModel() { var builder = new ODataConventionModelBuilder(); builder.EnumType<Genre>(); builder.ComplexType<Address>(); builder.ComplexType<NextOfKin>(); builder.EntitySet<Director>("Directors"); return builder.GetEdmModel(); } } }
Add an OData Controller
The next step is to add a controller. For brevity, we will only include the GET and POST controller actions. We will use an in-memory list as a data store. In addition, we will add diverse types of dynamic properties to the container property. This way we can demonstrate how different types of data are handled.
namespace ServiceNS.Controllers { public class DirectorsController : ODataController { private static readonly List<Director> _directors = new List<Director> { new Director { Id = 1, Name = "Director 1", DynamicProperties = new Dictionary<string, object> { { "Title", "Dr." }, // Primitive - string { "YearOfBirth", 1970 }, // Primitive - integer { "Salary", 700000m }, // Primitive - decimal { "BigInt", 6078747774547L }, // Primitive - long integer { "Pi", 3.14159265359d }, // Primitive - double // Primitive collection { "NickNames", new List<string> { "N1", "N2" } }, { "FavoriteGenre", Genre.SciFi }, // Enum // Enum collection { "Genres", new List<Genre>{ Genre.Epic, Genre.Thriller } }, { "WorkAddress", new Address // Complex { AddressLine = "AL1", City = "C1", DynamicProperties = new Dictionary<string, object> { { "PostalCode", "DPC01" } } } }, { "Addresses", new List<Address> // Complex collection { new Address { AddressLine = "AL2", City = "C2" }, new Address { AddressLine = "AL3", City = "C3" } } }, { "NextOfKin", new NextOfKin // Complex with nested complex { Name = "Nok 1", HomeAddress = new Address { AddressLine = "AL4", City = "C4" } } } } } }; [EnableQuery] public IQueryable<Director> Get() { return _directors.AsQueryable(); } [EnableQuery] public SingleResult<Director> Get([FromODataUri]int key) { var director = _directors.SingleOrDefault(d => d.Id.Equals(key)); return SingleResult.Create(new[] { director }.AsQueryable()); } public async Task<IActionResult> Post([FromBody] Director director) { var directorItem = _directors.SingleOrDefault(d => d.Id.Equals(director.Id)); await Task.Run(() => { /* Persist entity async */ }); return Created(new Uri( $"{Request.Scheme}://{Request.Host}{Request.Path}/{director.Id}", UriKind.Absolute), director); } } }
Querying the Service Metadata
We can query the service metadata by sending a GET request to the service metadata endpoint – comprising of the service root appended with /$metadata
.
A metadata document describes the Entity Data Model for a service. The service metadata helps clients discover the structure of an OData service together with its resources, the links between the resources and the operations it exposes.
<? 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 = "ServiceNS.Models" xmlns="http://docs.oasis-open.org/odata/ns/edm"> <ComplexType Name = "Address" OpenType="true"> <Property Name = "AddressLine" Type="Edm.String" /> <Property Name = "City" Type="Edm.String" /> </ComplexType> <ComplexType Name = "NextOfKin" > <Property Name="Name" Type="Edm.String" /> <Property Name = "HomeAddress" Type="ServiceNS.Models.Address" /> </ComplexType> <EntityType Name = "Director" OpenType="true"> <Key> <PropertyRef Name = "Id" /> </Key> <Property Name="Id" Type="Edm.Int32" Nullable="false" /> <Property Name = "Name" Type="Edm.String" /> </EntityType> <EnumType Name = "Genre" > <Member Name="Thriller" Value="1" /> <Member Name = "SciFi" Value="2" /> <Member Name = "Epic" Value="3" /> </EnumType> </Schema> <Schema Namespace = "Default" xmlns="http://docs.oasis-open.org/odata/ns/edm"> <EntityContainer Name = "Container" > <EntitySet Name="Directors" EntityType="ServiceNS.Models.Director" /> </EntityContainer> </Schema> </edmx:DataServices> </edmx:Edmx>
From the above metadata, you will notice that both Address
and Director
complex and entity types respectively have an OpenType
attribute with a value of true
. This is what identifies the type as open. However, the metadata does not include the dynamic properties container property.
Querying an Entity
We can query a Director
entity with Id
of 1 by sending a GET request to ~/Directors(1)
. Based on the single instance of Director
we stored in our in-memory datastore, we should get the following response:
{ "@odata.context": "http://localhost:8302/odata/$metadata#Directors/$entity", "Id": 1, "Name": "Director 1", "Title": "Dr.", "YearOfBirth": 1970, "Salary": 700000, "BigInt": 6078747774547, "Pi": 3.14159265359, "NickNames@odata.type": "#Collection(String)", "NickNames": [ "N1", "N2" ], "FavoriteGenre@odata.type": "#ServiceNS.Models.Genre", "FavoriteGenre": "SciFi", "Genres@odata.type": "#Collection(ServiceNS.Models.Genre)", "Genres": [ "Epic", "Thriller" ], "NextOfKin": { "@odata.type": "#ServiceNS.Models.NextOfKin", "Name": "Nok 1", "HomeAddress": { "AddressLine": "AL4", "City": "C4" } }, "Addresses@odata.type": "#Collection(ServiceNS.Models.Address)", "Addresses": [ { "AddressLine": "AL2", "City": "C2" }, { "AddressLine": "AL3", "City": "C3" } ], "WorkAddress": { "@odata.type": "#ServiceNS.Models.Address", "AddressLine": "AL1", "City": "C1", "PostalCode": "DPC01" } }
Creating an OData Client
Add a new Console App (.NET Core) project to the solution. When the project is ready, right-click on the project node from the Solution Explorer and change the default namespace for the project to ClientNS
from the Application tab.
We need to configure the client to consume the service we created earlier. One way we could achieve this is by creating a Connected Service with the help of a code generator like OData Connected Service available at the Visual Studio Marketplace. The extension is a handy tool that makes it easy to quickly generate the client code needed to communicate with an OData service. However, to avoid delving into the world of code generators and the boilerplate code they generate, we will keep things simple and set everything up manually. In the process we will also get to explore some of the things that happen under the hood.
Use Nuget Package Manager to install Microsoft.OData.Client library. From the Package Manager Console window:
Install-Package Microsoft.OData.Client
Note: You should have the client project selected on the Default project dropdown before running the above command.
Define the Client POCO classes
POCO is the acronym for Plain old CLR object. We need to define the client types to use when materializing the response from the service. These types will also be used to instantiate objects that will be serialized when sending data to the service in the insert scenario.
namespace ClientNS.Models { public enum Genre { Thriller = 1, SciFi = 2, Epic = 3 } public class Address { public string AddressLine { get; set; } public string City { get; set; } [Microsoft.OData.Client.ContainerProperty] public IDictionary<string, object> DynamicProperties { get; set; } } public class NextOfKin { public string Name { get; set; } public Address HomeAddress { get; set; } } [Microsoft.OData.Client.Key("Id")] public class Director { public int Id { get; set; } public string Name { get; set; } [Microsoft.OData.Client.ContainerProperty] public IDictionary<string, object> DynamicProperties { get; set; } } }
Notice that each of the above POCOs mirror their equivalent types in the OData service created earlier. However, they are in a different namespace – ClientNS.Models
. The Director
class is decorated with Microsoft.OData.Client.Key
attribute specifying which property represents the key for the entity. On their part, the dictionary properties are decorated with Microsoft.OData.ContainerProperty
attribute. This attribute is used to tag the property as the dynamic properties container.
Subclassing the DataServiceContext class
Next, we’ll subclass the DataServiceContext
class and add the code required for the client to talk to the service. Initialization of this class will trigger a call to the service to retrieve the metadata. This metadata is used to initialize the ServiceModel
property of the DataServiceContext
class
namespace ClientNS.Data { public partial class ExtendedDataServiceContext : DataServiceContext { public ExtendedDataServiceContext(Uri serviceUri) : base(serviceUri) { Format.LoadServiceModel = LoadServiceModel; Format.UseJson(); } private IEdmModel LoadServiceModel() { // Get the service metadata's Uri var metadataUri = GetMetadataUri(); // Create a HTTP request to the metadata's Uri // in order to get a representation for the data model var request = WebRequest.CreateHttp(metadataUri); using (var response = request.GetResponse()) { // Translate the response into an in-memory stream using (var stream = response.GetResponseStream()) { // Convert the stream into an XML representation using (var reader = System.Xml.XmlReader.Create(stream)) { // Parse the XML representation of the data model // into an EDM that can be utilized by OData client libraries return Microsoft.OData.Edm.Csdl.CsdlReader.Parse(reader); } } } } } }
Bootstrapping the Client
In this section, we wire everything together so the client can send a request to service and process the response. We start by configuring our project to support multiple startup projects.
To do that, right-click on the solution node from the Solution Explorer and select Set Startup Projects… Next, click the Multiple startup projects radio button. Change the Action value for both the service and client projects to Start. In addition, ensure that the service project appears above the client project on the list as shown in the image below
From the Main
method in Program.cs we instantiate an ExtendedDataServiceContext
object. The constructor requires us to pass a Uri
argument. We instantiate the Uri
with the service endpoint.
The client needs to be able to perform mapping between the type names in the response payload and the types on the client. To make that possible, we set the ResolveType
property of the ExtendedDataServiceContext
class to a delegate that identifies a function that resolves a qualified type name to a type on the client.
We also set the ResolveName
property to a delegate that identifies a function that resolves a type on the client to a qualified type name. This property will be applied later when posting an entity to the service.
namespace ClientNS { class Program { static void Main(string[] args) { var serviceUri = new Uri("YOUR SERVICE ENDPOINT HERE"); var dataServiceContext = new ExtendedDataServiceContext(serviceUri); dataServiceContext.ResolveType = name => { switch(name) { case "ServiceNS.Models.Genre": return typeof(Genre); case "ServiceNS.Models.Address": return typeof(Address); case "ServiceNS.Models.NextOfKin": return typeof(NextOfKin); case "ServiceNS.Models.Director": return typeof(Director); default: return null; } }; dataServiceContext.ResolveName = type => { // Lazy approach return string.Format("{0}.{1}", "ServiceNS.Models", type.Name); }; } }
Querying an Entity from the Service
Here we simply write the logic for retrieving an entity from the service. We make an async request to the service for a single entity.
// Query for entity by key var query = dataServiceContext.CreateQuery<Director>("Directors(1)"); var queryResult = query.ExecuteAsync().Result; var director = queryResult.FirstOrDefault();
Next, we set a breakpoint right after the last line and run the project. The screenshot below provides a glimpse of the container property populated with the dynamic properties returned from the service. You can use the QuickWatch window to verify their respective types.
One thing worth mentioning is the handling of complex dynamic property that contains a dynamic property. If you look keenly at the DirectorsController
class in the service project, you’ll find that a dynamic property PostalCode
is returned as a property of WorkAddress
.
{ "WorkAddress", new Address // Complex { AddressLine = "AL1", City = "C1", DynamicProperties = new Dictionary<string, object> { { "PostalCode", "DPC01" } } } },
In the image below, observe that the nested dynamic property is properly materialized on the client
POST an Entity to the Service
The logic for posting an entity is just as straightforward. We initialize a Director
object and populate the container property with any dynamic properties we intend to send to the service.
var director2 = new Director { Id = 2, Name = "Director 2 " }; director2.DynamicProperties = new Dictionary<string, object> { { "Title", "Prof." }, { "YearOfBirth", 1971 }, { "Salary", 800000m }, { "BigInt", 7454777478706L }, { "EulerConst", 0.5772156649d }, { "NickNames", new List<string> { "N3" } }, { "FavoriteGenre", Genre.Thriller }, { "Genres", new List<Genre> { Genre.SciFi } }, { "WorkAddress", new Address { AddressLine = "AL5", City = "C5" } }, { "Addresses", new List<Address> { new Address { AddressLine = "AL6", City = "C6" }, new Address { AddressLine = "AL7", City = "C7" } } }, { "NextOfKin", new NextOfKin { Name = "Nok 2", HomeAddress = new Address { AddressLine = "AL7", City = "C7", DynamicProperties = new Dictionary<string, object> { { "PostalCode", "PC7" } } } } } }; dataServiceContext.AddObject("Directors", director2); dataServiceContext.SaveChangesAsync().Wait();
Take note of the PostalCode
nested in the HomeAddress
complex property of NextOfKin
.
Before running the project, navigate to the DirectorsController
class and set a breakpoint on the Post
controller action. Next, run the project. Execution should stop at the breakpoint and you’re able to inspect the payload sent to the service.
Expand the NextOfKin
dynamic property to confirm that the nested dynamic property of HomeAddress
.
Summary
It is my hope that this feature will enrich your experience while working with dynamic properties on OData client.
If you have any questions, comments, concerns, or if you run into any issues using this feature, feel free to reach out on this blog post or report the issue on GitHub repo for OData client. We are more than happy to listen to your feedback and respond to your concerns.
Update
Find the source code for this blog post here
Holy &$!, this was an amazing article.
I have been pulling my hair for a while to figure out how to do this. Thank you so much for this great piece.
Cheers,
Very good explanation on subject hard to find documentation for.
Thank you!
Dirk