Dynamic properties container property in OData Client

John Gathogo

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:

  1. The value is either:
    • Primitive, or
    • Enum, or
    • Complex (including nested complex), or
    • Any collection of the above
  2. 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
  1. The DataServiceContext class has a property named ResolveType of type Func<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 how ResolveType property can be initialized:

    dataServiceContext.ResolveType = (typeName) =>
    {
        if (typeName.Equals("ServiceNS.Models.Address"))
            return typeof(ClientNS.Models.Address);
        // ...
    }
  2. 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 annotation NS.Address, we will scan the assembly that Customer type belongs to for a type named NS.Address. Code generators like OData Connected Service emit client types with names matching those of the server types.
  3. 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 type Customer belonging to Client.NS namespace. If we encounter a dynamic property with type name Address, we will scan the assembly that Customer type belongs to for a type named Client.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:

Software versions used in this walkthrough

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.

Image Project Settings

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.

Image Client Project Settings

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

Image Startup Projects

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.

Image Dynamic Properties on Client

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

Image Nested Dynamic Property

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.

Image Dynamic Properties on 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

2 comments

Discussion is closed. Login to edit/delete existing comments.

  • Dirk 0

    Very good explanation on subject hard to find documentation for.

    Thank you!
    Dirk

  • Hamed F. 0

    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,

Feedback usabilla icon