Enable Un-typed within ASP.NET Core OData

Sam Xu

Introduction

The latest ASP.NET Core OData supports the following two built-in OData abstract types:

  • Edm.Untyped
  • Collection(Edm.Untyped)

Developers can use them to advertise a property in OData metadata schema (aka, Edm model) so that such property is declared with a particular name present, but there is no type associated to describe the structure of the property’s values. Here’s an example:

  <Property Name="Data" Type="Edm.Untyped"/>
  <Property Name="Infos" Type="Collection(Edm.Untyped)"/>

Where, Data is called single value untyped property, meanwhile Infos is called collection value untyped property. Since they are untyped, in other words, there’s no type limitation for the property value, developers can use any kind of values (such as primitive values, structural values, or collection values) to instantiate such properties. The value assigned to an untyped property is called untyped value. The untyped value can also be used to create dynamic property.

In this post, I’d like to illustrate the difference between typed and untyped, declared and undeclared properties from the perspective of OData schema using a data model. Meanwhile, I’d like to share with you the scenarios of untyped value serialization and deserialization to help you understand how to use untyped properties in your own OData service. Let’s get started.

Prerequisites

I created an ASP.NET Core Web API application named UntypedApp to enable untyped properties in an OData service with Microsoft.AspNetCore.OData (version-8.2.0) installed. In the application, I have the following C# POCO (aka, Plain Old CLR Object) data classes created to build the OData Edm model.

// Entity type person
public class Person
{
    public int Id { get; set; }
    public string Name { get; set; }
    public Gender Gender { get; set; }
    public object Data { get; set; }
    public IList<object> Infos { get; set; } = new List<object>();
    public IDictionary<string, object> DynamicContainer { get; set; }
}

// Complex type Address
public class Address
{
    public string City { get; set; }
    public string Street { get; set; }
}

// Enum type Gender
public enum Gender
{
    Other,
    Male,
    Female
}

Meanwhile, I have the following C# POCO data classes which are NOT defined in the OData Edm model.

// Complex type Course
public class Course
{
    public string Title { get; set; }
    private string AliasName { get => Title + "_alias"; }
    public IList<int> Credits { get; set; }
}

// Enum type Color and UserType
public enum Color
{
    Black,
    While,
    Yellow,
    Blue,
    Green
}

public enum UserType
{
    Normal,
    Admin
}

Untyped property

From the perspective of OData spec, a property could be:

  1. A declared property: all properties declared as part of a structured type’s definition. For example, the property Name of Person class.
  2. An un-declared property: all dynamic properties included in the instances of structured types. For example, all items in the DynamicContainer of Person class.

Basically, a declared property is a named reference to an Edm type, such Edm type should be a Known type defined in the Edm model. Here, Known means this type should be resolved from the Edm model using the full-type name. An untyped property is a declared property whose reference type is Edm.Untyped or Collection(Edm.Untyped).

In ASP.NET Core OData, it’s easy to build untyped property using the OData model builder following the conventions:

  • If a property at C# side is defined using System.Object, the OData model builder will build it as a single value untyped property, for example Data.
  • If a property at C# side is defined using collection of System.Object, for example IList<object>, the OData model builder will build it as a collection value untyped property, for example Infos.

Based on the above C# data model classes, we can build the Edm model schema containing untyped properties using the model builder as follows:

public static IEdmModel GetEdmModel()
{
    var builder = new ODataConventionModelBuilder();
    builder.ComplexType<Address>();
    builder.EntitySet<Person>("People");
    EdmModel model = (EdmModel)builder.GetEdmModel();

    // Add a complex type without C# class mapped
    EdmComplexType complexType = new EdmComplexType("UntypedApp.Models", "Message");
    complexType.AddStructuralProperty("Description", EdmCoreModel.Instance.GetString(true));
    complexType.AddStructuralProperty("Status", EdmCoreModel.Instance.GetString(false));
    model.AddElement(complexType);

    return model;
}

It builds the Edm model as follows:

<?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="UntypedApp.Models" xmlns="http://docs.oasis-open.org/odata/ns/edm">
      <ComplexType Name="Address">
        <Property Name="City" Type="Edm.String" />
        <Property Name="Street" Type="Edm.String" />
      </ComplexType>
      <EntityType Name="Person" OpenType="true">
        <Key>
          <PropertyRef Name="Id" />
        </Key>
        <Property Name="Id" Type="Edm.Int32" Nullable="false" />
        <Property Name="Name" Type="Edm.String" />
        <Property Name="Gender" Type="UntypedApp.Models.Gender" Nullable="false" />
        <Property Name="Data" Type="Edm.Untyped" />
        <Property Name="Infos" Type="Collection(Edm.Untyped)" />
      </EntityType>
      <EnumType Name="Gender">
        <Member Name="Other" Value="0" />
        <Member Name="Male" Value="1" />
        <Member Name="Female" Value="2" />
      </EnumType>
      <ComplexType Name="Message">
        <Property Name="Description" Type="Edm.String" />
        <Property Name="Status" Type="Edm.String" Nullable="false" />
      </ComplexType>
    </Schema>
    <Schema Namespace="Default" xmlns="http://docs.oasis-open.org/odata/ns/edm">
      <EntityContainer Name="Container">
        <EntitySet Name="People" EntityType="UntypedApp.Models.Person" />
      </EntityContainer>
    </Schema>
  </edmx:DataServices>
</edmx:Edmx>

Where,

  • We have types both defined at C# data model side and at Edm model side as:
  • We have types only defined at Edm model side, not at C# data side:
  • We have C# types, but no Edm types defined in the model as:

You may notice that we have the following untyped properties:

Where,

  • Data is a System.Object typed property at C# side, meanwhile it is an Edm.Untyped property at OData Edm schema side. The value of such property MAY be a primitive value, a structural value, or a collection. If a collection, it may contain any combination of primitive values, structural values, and collections.
  • Infos is a IList<object> typed property at C# side, meanwhile it is a Collection(Edm.Untyped) property at OData Edm schema side. The value of such property MUST be a collection, and it MAY contain any combination of primitive values, structural values, and collections.

Untyped value

The value of an untyped property no matter whether it’s an Edm.Untyped or Collection(Edm.Untyped) property is untyped value. As mentioned above, an untyped value MAY be a primitive value, a structural value, or a collection. If a collection, it may contain any combination of primitive values, structural values, and collections.

  • Primitive values

    Developers can use all built-in primitive types to create untyped value. Here’s a partial table of mapping between C# primitive types and Edm primitive types.

    C# Types Edm Types
    byte[] Edm.Binary
    bool Edm.Boolean
    byte Edm.Byte
    string Edm.String
    DateTimeOffset Edm.DateTimeOffset
    decimal Edm.Decimal
    double Edm.Double
    Guid Edm.Guid
    int Edm.Int16

    In serialization process, developers can use any C# known primitive type. For example,

        Person aPerson = new Person();
        aPerson.Data = true; // a System.Boolean
    

    In deserialization process, primitive values are read as associated C# primitive instances, for example:

    {
      "Data": 42 🡺 a Edm.Int32 value, read as System.Int32
    }
    
  • Enum values

    Basically, Enum values are considered as part of primitive value. In serialization process, developers can use any C# Enum type, for example:

        Person aPerson = new Person();
        aPerson.Data = Gender.Male; // or
        aPerson.Data = UserType.Admin;
    

    In deserialization process, Enum values are read as Edm.String value unless they are annotated with the @odata.type control information, in which case they MUST conform to the Enum type described by the control information. If Edm Enum type has C# Enum type associated, we can get an instance of C# Enum type (for example, C# Color Enum type). If Edm Enum type has no C# Enum type associated, we can get an instance of EdmEnumObject.

    No @odata.type
    {
      "Data": "Red"
    }
    A string primitive value
    Has @odata.type
    {
      "Data@odata.type": "#Untyped.Models.Color",
      "Data": "Red"
    }
    A known Enum value
  • Structural values

    In serialization process, developers can use any C# class type, for example:

        Person aPerson = new Person();
        aPerson.Data = new Address { … }; // or
        aPerson.Data = new Course { …… }
    

    In deserialization process, structural values are read as an EdmUntypedObject instance value unless they are annotated with the @odata.type control information, in which case they MUST conform to the structural type described by the control information. If Edm structural type has C# class type associated, we can get an instance of C# class type (for example, C# Address class type). If Edm structural type has no C# class type associated, we can get an instance of EdmComplexObject or EdmEntityObject.

    No @odata.type
    {
      "Data": {
        "City":"City1"
      }
    }
    
    An untyped structural value
    Has @odata.type
    {
      "Data": {
        "@odata.type": "#Untyped.Models.Address",
        "City": "City1"
      }
    }
    A known C# class instance
  • Collection values

    In serialization process, developers can use any C# collection types, for example:

        Person aPerson = new Person();
        aPerson.Data = new List<Address> { … }; // or
        aPerson.Data = new object[] { …… }
    

    In deserialization process, collection values are read as an EdmUntypedCollection instance value unless they are annotated with the @odata.type control information on collection property, in which case they MUST conform to the collection type described by the control information. If the collection type has C# class type associated, we can get an instance of C# collection type (for example, C# IList<Address> type). If the collection type has no C# class type associated, we can get an instance of EdmComplexCollectionObject or EdmEntityCollectionObject. If the whole collection is not @odata.type annotated, we should conform each item based on item’s value and @odata.type one by one.

    No @odata.type
    {
      "Data": [
        ……
      ]
    }
    An untyped collection value
    Has @odata.type
    {
      "Data@odata.type": "#Collection(Edm.Int32)",
      "Data": [
        ……
      ]
    }
    A known C# collection instance

The value of a dynamic property MAY be an untyped value, therefore the above also apply to dynamic properties.

Untyped value Serialization

Ok, Let’s look at the detailed untyped value serialization scenarios.

Primitive as untyped value

The basic usage is to assign a known primitive value to an untyped property, such value can also be used for dynamic property. In the application, I have the following person data entity:

IList<Person> _persons = new List<Person>
{
    new Person
    {
        Id = 1,
        Name = "Kai",
        Gender = Gender.Female,
        Data = 42,
        Infos = new object[] { 1, true, 3.1415f, new byte[] { 4, 5 } }
    }
    ......
}

Run the application and send a GET request using http://localhost:5299/odata/people/1

We can get the following OData response:

{
  "@odata.context": "http://localhost:5299/odata/$metadata#People/$entity",
  "Id": 1,
  "Name": "Kai",
  "Gender": "Female",
  "Data@odata.type": "#Int32",
  "Data": 42,
  "Infos": [
    1,
    true,
    3.1415,
    "BAU="
  ]
}

Be noted:

  • Data has @odata.type control information to specify the real value type as Edm.Int32.
  • Infos doesn’t have @odata.type control information because its value is a mix of multiple type values.

Enum as untyped value

As mentioned, we can use the C# Enum type value as for untyped property value, no matter whether the C# enum type is declared in Edm model or not. In the application, I have the following person in the data source:

IList<Person> _persons = new List<Person>
{
    ......
    new Person
    {
        Id = 2,
        Name = "Ezra",
        Gender = Gender.Male,
        Data = UserType.Admin.ToString(),
        Infos = new object[] { UserType.Admin, 2, Gender.Male },
        DynamicContainer = new Dictionary<string, object>
        {
            { "D_Data", UserType.Admin.ToString() }
        }
    }
}

Be noted, please call ToString() if you use un-declared C# Enum type for single untyped value.

Run it and send GET request using http://localhost:5299/odata/people/2

We can get the following response:

{
  "@odata.context": "http://localhost:5299/odata/$metadata#People/$entity",
  "Id": 2,
  "Name": "Ezra",
  "Gender": "Male",
  "D_Data": "Admin",
  "Data@odata.type": "#String",
  "Data": "Admin",
  "Infos": [
    "Admin",
    2,
    "Male"
  ]
}

You may notice that Enum untyped value is serialized as string literal. Specially, Data has @odata.type control information as Edm.String since we specify the string value for it. Besides, this entity contains a dyanmic property D_Data also.

Resource as untyped value

We can use any of the following types to serialize a resource for untyped value:

  • Any C# classes no matter whether it is declared in Edm model
  • IDictionary<string,object> or EdmUntypedObject

C# class

In the application, I have the following two persons in the data source:

IList<Person> _persons = new List<Person>
{
    ......
    new Person
    {
        Id = 3,
        Name = "Chang",
        Gender = Gender.Male,
        Data = new Course { Title = "Maths", Credits = new List<int> { 77, 97 }},
        Infos = new List<object>
        {
            new Address { City = "Issaquay", Street = "Main ST" }
        }
    },

    new Person
    {
        Id = 4,
        Name = "Robert",
        Gender = Gender.Female,
        Data = new Address { City = "Redmond", Street = "152TH AVE" },
        Infos = new List<object>
        {
            new Course { Title = "Geometry", Credits = new List<int> { 95, 96 }}
        }
    },
}

Where:

  • Data (3) is an instance of Course, meanwhile Data (4) is an instance of Address.
  • Infos (3) contains instances of Address item, meanwhile Infos (4) contains instance of Course.
  • C# Address class is defined as Edm complex type in the model.
  • C# Course class is NOT defined in the model.

Run it and send GET request using http://localhost:5299/odata/people/3

And we can get the following response:

{
  "@odata.context": "http://localhost:5299/odata/$metadata#People/$entity",
  "Id": 3,
  "Name": "Chang",
  "Gender": "Male",
  "Data": {
    "Title": "Maths",
    "Credits@odata.type": "#Collection(Int32)",
    "Credits": [
      77,
      97
    ]
  },
  "Infos": [
    {
      "@odata.type": "#UntypedApp.Models.Address",
      "City": "Issaquay",
      "Street": "Main ST"
    }
  ]
}

You may notice:

  • If the value type is defined in the model, there’s a @odata.type control metadata to indicate the real Edm type, otherwise there’s no such control metadata.
  • For un-declared C# class (No Edm type defined in the model), only the public properties are retrieved. So, only public properties of Course are serialized. Developers can customize this behavior by implementing an untyped value mapper (see below).

Try the http://localhost:5299/odata/people/4 to see what’s the difference.

{
  "@odata.context": "http://localhost:5299/odata/$metadata#People/$entity",
  "Id": 4,
  "Name": "Robert",
  "Gender": "Female",
  "Data": {
    "City": "Redmond",
    "Street": "152TH AVE"
  },
  "Infos": [
    {
      "Title": "Geometry",
      "Credits@odata.type": "#Collection(Int32)",
      "Credits": [
        95,
        96
      ]
    }
  ]
}

Be noted, Data should have @odata.type associated, but It doesn’t. It’s an known issue and will be fixed in the next version.

Dictionary or EdmUntypedObject

It also supports C# dictionary, which is a key value pair collection. In the application, I have the following person entity in the data source:

IList<Person> _persons = new List<Person>
{
    ......
    new Person
    {
        Id = 5,
        Name = "Xu",
        Gender = Gender.Male,
        Data = new Dictionary<string, object>
        {
            { "Age", 8 },
            { "Email", "xu@abc.com" }
        },
        Infos = new List<object>
        {
            null,
            new Dictionary<string, object>
            {
                { "Title", "Software engineer" }
            },
            42
        }
    }
}

Run it and send GET request using http://localhost:5299/odata/People/5, we can get:

{
  "@odata.context": "http://localhost:5299/odata/$metadata#People/$entity",
  "Id": 5,
  "Name": "Xu",
  "Gender": "Male",
  "Data": {
    "Age": 8,
    "Email": "xu@abc.com"
  },
  "Infos": [
    null,
    {
      "Title": "Software engineer"
    },
    42
  ]
}

Developers can use EdmUntypedObject class, which is a derived class from Dictionary<string,object>. This class is designed to hold the resource content during deserialization if there’s no @odata.type control information associated.

Try the http://localhost:5299/odata/people/6 to see the OData payload using EdmUntypedObject.

Collection as untyped value

The collection value for untyped property can be any combination of primitive values, structural values, and collections. We can use most of generic collection type, such as (IList<T>, Arrray[], ICollection<T>,…) or EdmUntypedCollection which is derived from List<object>.

In the application, I have the following person entity in the data source:

IList<Person> _persons = new List<Person>
{
    ......
    new Person
    {
        Id = 7,
        Name = "Wen",
        Gender = Gender.Male,
        Data = new object[]
        {
            null,
            42,
            new Dictionary { { "City", "Shanghai"} },
            new Course { Title = "Science", Credits = new List<int> { 78, 88 }}
        },
        Infos = new EdmUntypedCollection
        {
            new List<object>
            {
                new EdmUntypedCollection
                {
                    null
                }
            },
        },
    },
}

Run it and send GET request using http://localhost:5299/odata/people/7

We can get the OData payload as follows:

{
  "@odata.context": "http://localhost:5299/odata/$metadata#People/$entity",
  "Id": 7,
  "Name": "Wen",
  "Gender": "Male",
  "Data": [
    null,
    42,
    {
      "City": "Shanghai"
    },
    {
      "Title": "Science",
      "Credits@odata.type": "#Collection(Int32)",
      "Credits": [
        78,
        88
      ]
    }
  ],
  "Infos": [
    [
      [
        null
      ]
    ]
  ]
}

Be noted, single value untyped property can have single value untyped value and collection untyped value, however, collection value untyped property can only have collection untyped value.

Customize the Value mapper

As mentioned, the default serialization process for a C# type instance only retrieves the public properties if such C# type is not defined in the Edm model. Most of the time, it works fine for POCO class. However, the serialization result could be not expected if the resource is not a POCO class. Here’s an example:

IList<Person> _persons = new List<Person>
{
    ......
    new Person
    {
        Id = 8,
        Name = "JSON",
        Gender = Gender.Male,
        Data = JsonDocument.Parse(@"{""name"": ""Joe"",""age"": 22,""canDrive"": true}"),
        Infos = new List<object>(),
    }
}

Where, Data has a System.Text.Json.JsonDocument instance.

If we send the GET request using http://localhost:5299/odata/people/8, we can get the following result. Obviously it’s unexpected.

{
  "@odata.context": "http://localhost:5299/odata/$metadata#People/$entity",
  "Id": 8,
  "Name": "JSON",
  "Gender": "Male",
  "Data": {
    "RootElement": {
      "ValueKind": {}
    }
  },
  "Infos": []
}

In this case, ASP.NET Core OData enables developers to customize the untyped resource value serialization by implementing IUntypedResourceMapper interface. Here’s the interface definition:

public interface IUntypedResourceMapper
{
    IDictionary<string, object> Map(object resource, ODataSerializerContext context);
}

Developers can start from this interface directly, or start it deriving from the default mapper implementation as:

public class MyUntypedResourceMapper : DefaultUntypedResourceMapper
{
    public override IDictionary<string, object> Map(object resource, ODataSerializerContext context)
    {
        // ...... Omit the codes, please refer to the repos
    }
}

Developers should register the implementation as a service into the service container at beginning to make it work:

builder.Services.AddControllers()
    .AddOData(opt =>
        opt.EnableQueryFeatures()
           .AddRouteComponents("odata", EdmModelBuilder.GetEdmModel(), services =>
             services.AddSingleton<IUntypedResourceMapper, MyUntypedResourceMapper>())
);

Ok, run it and resend GET request using http://localhost:5299/odata/people/8, we can get the following result:

{
  "@odata.context": "http://localhost:5299/odata/$metadata#People/$entity",
  "Id": 8,
  "Name": "JSON",
  "Gender": "Male",
  "Data": {
    "name": "Joe",
    "age": 22,
    "canDrive": true
  },
 "Infos": []
}

Besides implementing the IUntypedResourceMapper interface, developers can also override the CreateUntypedPropertyValue virtual method by deriving from ODataResourceSerializer to customize the untyped value serialization. Here’s a sample:

public class MyResourceSerializer : ODataResourceSerializer
{
    public MyResourceSerializer(IODataSerializerProvider serializerProvider) : base(serializerProvider)
    { }

    public override object CreateUntypedPropertyValue(IEdmStructuralProperty structuralProperty, ResourceContext resourceContext, out IEdmTypeReference actualType)
    {
        // ...... add your logics here to return some values for untyped properties.
        return base.CreateUntypedPropertyValue(structuralProperty, resourceContext, out actualType);
    }
}

Be noted:

  • IUntypedResourceMapper only handles structural untyped value mapper.
  • CreateUntypedPropertyValue can handle more complex scenarios.

Untyped value deserialization

We can create a person using POST request with request body containing untyped value for untyped properties. It supports primitive values, structural values, or collections. If a collection, it supports any combination of primitive values, structural values, and nested collections.

Read primitive untyped value

We can use primitive value for the untyped property as below:

POST http://localhost:5299/odata/people

Content-Type: application/json (omits for others below)

Request Body:

{
  "name": "sam",
  "gender": "Male",
  "data":42,
  "infos": [42, "str"]
}

At controller action debug view, we can get a person instance with the following content:

We can use @odata.type to annotate the untyped value for the untyped property as below:

{
  "name": "sam",
  "gender": "Male",
  "data@odata.type":"Edm.Guid",
  "data": "2764AFFC-9AC4-42DB-B3D0-68BC1714261F",
  "infos@odata.type":"Collection(Edm.Guid)",
  "infos": [ "2764AFFC-9AC4-42DB-B3D0-68BC1714261F"]
}

We can get the following person instance in controller action as:

The real type of Data is System.Guid (see the immediate window testing). Of course, If we remove @odata.type in the request body for Data property, the real type of Data property at controller should be System.String.

Read Enum untyped value

We can’t distinguish Enum value from string value, since Enum value is literally a string in the JSON payload. For example, we can send a POST request with the following request body:

{
  "name": "sam",
  "gender": "Male",
  "data": "Male"
}

At the controller action, we can get the property value as System.String again.

We can annotate @odata.type for enum value if we do want to read a string as an enum member as below:

{
  "name": "sam",
  "gender": "Male",
  "data@odata.type":"#UntypedApp.Models.Gender",
  "data": "Male"
}

We should get a C# Enum instance for Data property at the controller action. Unfortunately, there’s an issue to support it and OData team will fix it in the next version.

Read resource untyped value

We can use resource untyped value for the untyped property as below:

{
  "name": "sam",
  "gender": "Male",
  "data": {"City": "abc", "Street": "xzy"},
  "infos": []
}

At controller action, we can get a person instance with the following content:

Where, we get a EdmUntypedObject instance for Data, it’s a dictionary whose key is System.String, whose value is System.Object.

We can use @odata.type to annotate the resource if we do want to read the value as a C# class instance, for example {“@odata.type”:”#UntypedApp.Models.Message”, … }. Unfortunately, same as Enum type, there’s an issue to support it and OData team will fix it in the next version.

Read collection untyped value

As mentioned, we can use collection value for Edm.Untyped property, meanwhile, we can ONLY use collection value for Collection(Edm.Untyped) property. If you use non-collection value for collection untyped property, it won’t pass model validation and you will get a reading exception.

Here’s an example to read collection untyped value.

{
  "name": "sam",
  "gender": "Male",
  "data":[
     [42],
     {"k1": "abc", "k2": 42, "k3": {"a1": 2, "b2": null}, "k4": [null, 42]}
  ],
  "infos": [
    null,
    42,
    [nulltrue]
  ]
}

Here’s the debug view of person:

Where,

  • Data is a collection containing 2 items, the first one is a primitive value, the second one is a resource value.
  • Infos is a collection containing 3 items, the first one is null, the second one is a primitive, the third one is a nested collection containing 2 items.

Query untyped property

We can query the untyped property directly. It’s same as querying other normal properties if we have property query endpoint enabled. In the application, I enabled the Data and Infos property querying endpoint. So, we can run it and send GET request using http://localhost:5299/odata/people/5/data

We can get:

{
  "@odata.context": "http://localhost:5299/odata/$metadata#People(5)/Data/Edm.Untyped",
  "Age": 8,
  "Email": "xu@abc.com"
}

Or we can send a GET request using http://localhost:5299/odata/people/7/infos

We can get the following:

[
  [
    [
      null
    ]
  ]
]

You may notice this payload is not an OData payload since the JSON object doesn’t contain @odata.context. It’s a known issue OData team will fix in the next version.

That’s it.

Summary

This post introduced Edm.Untyped and Collection(Edm.Untyped) abstract types supported in ASP.NET Core OData 8.x. We also went through the untyped value serialization and deserialization scenarios for untyped properties. By defining an untyped property, it enables OData developers to achieve more usages since there’s no value type kind limitation that we should follow up to read or write the property.

At the end, please do not hesitate to leave your comments below or let me know your thoughts through saxu@microsoft.com. Thanks.

I uploaded the whole project to this repository. If you find any bug or issue, please file them here.

0 comments

Comments are closed. Login to edit/delete your existing comments

Feedback usabilla icon