September 7th, 2022

Extension: Omit null value properties in ASP.NET Core OData

Sam Xu
Senior Software Engineer

Introduction

By default, ASP.NET Core OData serializes a single value property as “null”, and a collection value property as an empty array if its value is null as such:

{
   "SingleValueProperty": null
   "CollectionValueProperty": []
}

It’s good for most scenarios. However, omitting those ‘annoying‘ null-value properties from the OData response gets more and more attention. Along with the latest ASP.NET Core OData, it’s very easy to omit such null-value properties by extending the OData resource serializer. The approach is so common that it’s better to create a post and value more customers/developers. Let’s get started.

Prerequisites

As usual, my post starts with building an ASP.NET Core Web API application with Microsoft.AspNetCore.OData (version-8.0.11) installed. You can follow up on my previous post to build the project.

In the project, I create the following C# classes as the data model:

Where:

  1. School and Student are two entity types
  2. Address is a complex type
  3. Color is an enum type

The Edm model builder is as simple as the codes below:

public static IEdmModel GetEdmModel()
{
    var builder = new ODataConventionModelBuilder();
    builder.EntitySet<School>("Schools");
    builder.EntitySet<Student>("Students");
    return builder.GetEdmModel();
}

I build the controller as below. As you can see, we can use attribute routing to specify multiple routes on one action.

[Route("odata")]
public class SchoolStudentController : ODataController
{
    private readonly ISchoolStudentRepository _repo;

    public SchoolStudentController(ISchoolStudentRepository repo)
    {
        _repo = repo;
    }

    [HttpGet("Schools")]
    [HttpGet("Schools/{key}")]
    [EnableQuery]
    public IActionResult GetSchool(int? key)
    {
        if (key != null)
        {
            return Ok(_repo.Schools.FirstOrDefault(s => s.ID == key.Value));
        }
        else
        {
            return Ok(_repo.Schools);
        }
    }

    [HttpGet("Students")]
    [HttpGet("Students/{key}")]
    [EnableQuery]
    public IActionResult GetStudent(int? key)
    {
        // omit for similar codes
    }

Where:

  1. GetSchool has the following two endpoints:
    • ~/odata/schools
    • ~/odata/schools/{key}
  2. GetStudent has the following two endpoints:
    • ~/odata/students
    • ~/odata/students/{key}
  3. ISchoolStudentRepository is a sample data repository. You can find a simple in-memory data repository implementation here.

Customize OData resource serializer

The easy way to change default resource serialization is to customize OData resource serializer. In this project, I derive from ODataResourceSerializer again to change the default null value properties serialization. There are three virtual methods that you can override to change three types of property serialization:

  1. CreateStructuralProperty is for single value and collection value primitive property (Enum included)
  2. CreateComplexNestedResourceInfo is for single value and collection value complex property
  3. CreateNavigationLink is for single value and collection value navigation property

Here’s my OmitNullResourceSerializer sample codes:

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

    public override ODataProperty CreateStructuralProperty(IEdmStructuralProperty structuralProperty, ResourceContext resourceContext)
    {
        // for primitive property, enum property, and collection of them
    }

    public override ODataNestedResourceInfo CreateComplexNestedResourceInfo(IEdmStructuralProperty complexProperty, PathSelectItem pathSelectItem, ResourceContext resourceContext)
    {
        // for complex property and collection of it
    }

    public override ODataNestedResourceInfo CreateNavigationLink(IEdmNavigationProperty navigationProperty, ResourceContext resourceContext)
    {
        // for single value and collection value navigation property
    }
}

Omit null-value properties

Let’s implement the above methods to omit null-value properties. The code could be as simple as below:

  
public override ODataProperty CreateStructuralProperty(IEdmStructuralProperty structuralProperty, ResourceContext resourceContext)
{
    object propertyValue = resourceContext.GetPropertyValue(structuralProperty.Name);
    if (propertyValue == null)
    {
        return null;
    }

    return base.CreateStructuralProperty(structuralProperty, resourceContext);
}

public override ODataNestedResourceInfo CreateComplexNestedResourceInfo(IEdmStructuralProperty complexProperty, PathSelectItem pathSelectItem, ResourceContext resourceContext)
{
    // similar codes as above, omit it.
}

public override ODataNestedResourceInfo CreateNavigationLink(IEdmNavigationProperty navigationProperty, ResourceContext resourceContext)
{
    // similar codes as above, omit it.
}

Run and test

Since we have everything ready, we can config the ASP.NET core web application to run and test. Let’s register OData services into the service container in the Program.cs as follows: (Be noted, I also register the data repository as a transient service. It’s better to use transient for the data repository and make sure we get the update-to-date repository every time.):

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers()

.AddOData(opt => opt.AddRouteComponents("odata", EdmModelBuilder.GetEdmModel(),

services => services.AddSingleton<ODataResourceSerializer, OmitNullResourceSerializer>()).EnableQueryFeatures());

builder.Services.AddTransient<ISchoolStudentRepository, SchoolStudentRepositoryInMemory>();

Now, we can send a HTTP request as “GET https://localhost:7282/odata/$metadata“, and get the OData metadata schema as:

<?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="OmitNullPropertySample.Models" xmlns="http://docs.oasis-open.org/odata/ns/edm">
      <EntityType Name="School">
        <Key>
          <PropertyRef Name="ID" />
        </Key>
        <Property Name="ID" Type="Edm.Int32" Nullable="false" />
        <Property Name="Name" Type="Edm.String" />
        <Property Name="Emails" Type="Collection(Edm.String)" />
        <Property Name="HeadQuarter" Type="OmitNullPropertySample.Models.Address" />
        <Property Name="Addresses" Type="Collection(OmitNullPropertySample.Models.Address)" />
        <NavigationProperty Name="Students" Type="Collection(OmitNullPropertySample.Models.Student)" />
      </EntityType>
      <EntityType Name="Student">
        <Key>
          <PropertyRef Name="ID" />
        </Key>
        <Property Name="ID" Type="Edm.Int32" Nullable="false" />
        <Property Name="Name" Type="Edm.String" />
        <Property Name="Age" Type="Edm.Int32" Nullable="false" />
        <Property Name="FavoriteColor" Type="OmitNullPropertySample.Models.Color" />
        <Property Name="HomeLocation" Type="OmitNullPropertySample.Models.Address" />
      </EntityType>
      <ComplexType Name="Address">
        <Property Name="City" Type="Edm.String" />
        <Property Name="Street" Type="Edm.String" />
        <Property Name="ZipCode" Type="Edm.Int32" Nullable="false" />
      </ComplexType>
      <EnumType Name="Color">
        <Member Name="Red" Value="0" />
        <Member Name="Green" Value="1" />
        <Member Name="Blue" Value="2" />
        <Member Name="Black" Value="3" />
        <Member Name="Yellow" Value="4" />
      </EnumType>
    </Schema>
    <Schema Namespace="Default" xmlns="http://docs.oasis-open.org/odata/ns/edm">
      <EntityContainer Name="Container">
        <EntitySet Name="Schools" EntityType="OmitNullPropertySample.Models.School">
          <NavigationPropertyBinding Path="Students" Target="Students" />
        </EntitySet>
        <EntitySet Name="Students" EntityType="OmitNullPropertySample.Models.Student" />
      </EntityContainer>
    </Schema>
  </edmx:DataServices>
</edmx:Edmx>

Send a request “GET https://localhost:7282/odata/schools” to query school data as:

{
  "@odata.context": "https://localhost:7282/odata/$metadata#Schools",
  "value": [
    {
      "ID": 1,
      "Name": "Moon Middle School",
      "Emails": [
        "efg@efg.com"
      ],
      "Addresses": [
        {
          "City": "Moon City",
          "Street": "145TH AVE",
          "ZipCode": 0
        },
        {
          "City": "Sun City",
          "Street": "24TH ST",
          "ZipCode": 0
        }
      ]
    },
    {
      "ID": 2,
      "Name": "Jupiter Middle School",
      "HeadQuarter": {
        "City": "Jupiter City",
        "Street": "1110 AVE",
        "ZipCode": 0
      }
    },
    {
      "ID": 3,
      "Name": "Mars High School",
      "Emails": [
        "abc@abc.com"
      ],
      "HeadQuarter": {
        "City": "Mars City",
        "Street": "Space Rd",
        "ZipCode": 0
      }
    }
  ]
}

Where you can see:

  1. Schools(#1) doesn’t have ‘HeadQuarter‘ complex type property.
  2. Schools(#2) doesn’t have ‘Emails‘ collection primitive property and ‘Addresses‘ collection complex property.
  3. Schools(#3) doesn’t have ‘Addresses‘ collection complex property.

Client-side omit null-value properties

Most of the time, we’d like to let the client tell the server whether or not to omit null-value properties. Basically, you can achieve it using any technique. In this post, I use OData prefer header called omit-values. You can find more details about prefer header in OData here.

Ok, first, we should check the request prefer header on the server side to understand what the client needs. Let’s create a static class (any class) and add an extension method as follows:

public static bool ShouldOmitNullValues(this HttpRequest request)
{
    string preferHeader = null;
    StringValues values;
    if (request.Headers.TryGetValue("Prefer", out values))
    {
        // If there are many "Prefer" headers, pick up the first one.
        preferHeader = values.FirstOrDefault();
    }

    // use case insensitive string comparison for simplicity
    if (preferHeader != null && preferHeader.Contains("omit-values=nulls", StringComparison.OrdinalIgnoreCase))
    {
        return true;
    }

    return false;
}

Then, we call the above extension method in our OmitNullResourceSerializer to make a decision on whether to omit null-value properties. Here are the changes for complex type property (similar codes for the other two methods)

public override ODataNestedResourceInfo CreateComplexNestedResourceInfo(IEdmStructuralProperty complexProperty, PathSelectItem pathSelectItem, ResourceContext resourceContext)
{
    bool isOmitNulls = resourceContext.Request.ShouldOmitNullValues ();
    if (isOmitNulls)
    {
        object propertyValue = resourceContext.GetPropertyValue(complexProperty.Name);
        if (propertyValue == null || propertyValue is NullEdmComplexObject)
        {
            return null;
        }
    }

    return base.CreateComplexNestedResourceInfo(complexProperty, pathSelectItem, resourceContext);
}

Let’s run the project again and send the following requests separately (left one without request prefer header, right one with request prefer header):

GET https://localhost:7282/odata/schools
GET https://localhost:7282/odata/schools
Prefer: omit-values=nulls
Here’s the difference between the two responses:

Where you can see:

  1. Left contains ‘full‘ properties for each school entity
  2. Right omits HeadQuarter, Emails, Addresses for certain school if its property value is null

Let’s run the sample and send the following requests to verify the navigation property serialization:

GET ~/odata/schools/3?$expand=students
GET ~/odata/schools/3?$expand=students
Prefer: omit-values=nulls
Here’s the difference between the two responses:

As shown, it omits for null-value navigation property if we specify the prefer header for the request.

Omit null vs $select

The $select system query option requests that the service return only the selected properties. In other words, $select omits the properties which are not included in the select clause. Sometimes, it could be confusing at the client side if we combine $select and omit-values=nulls together. For example:

Get https://localhost:7282/odata/schools?$select=Name,Emails
Prefer: omit-values=nulls

We can get the following result:

{
  "@odata.context": "https://localhost:7282/odata/$metadata#Schools(Name,Emails)",
  "value": [
    {
      "Name": "Moon Middle School",
      "Emails": [
        "efg@efg.com"
      ]
    },
    {
      "Name": "Jupiter Middle School"
    },
    {
      "Name": "Mars High School",
      "Emails": [
        "abc@abc.com"
      ]
    }
  ]
}

Where, the second school entity doesn’t have the ‘Emails‘ property. It might be confusing that ‘Emails‘ is excluded from $select or is omitted by null-value.

The good news is that it’s OData response payload, it contains the metadata (aka, control information) to help the client understand the payload. From the above, you can see a context URI metadata (@odata.context in the response payload) included in the payload.

The context URI “https://localhost:7282/odata/$metadata#Schools(Name,Emails)” shows:

  • The data is from Schools entity set
  • Each school include and only include ‘Name‘ and ‘Emails‘ property

In order to let the client understand that ‘Emails‘ is omitted by a null value, we can specify the Preference-Applied response header with omit-values=nulls based on OData spec. Then, the client can combine the context Uri information and the response header together to parse the response payload correctly.

In this project, I build a simple method to specify the Preference-Applied response header at here for your reference. Now, if you run the project and send the following request:

Get https://localhost:7282/odata/schools?$select=Name,Emails
Prefer: omit-values=nulls

You can see a header added in the response as:

That’s it.

Summary

This post introduced the extension to omit null value properties in the OData response payload. Using a similar approach, I think you can achieve more. Try and let us know your stories.

Again, 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.

Author

Sam Xu
Senior Software Engineer

Sam is a Senior software engineer at Microsoft with over than 10 years of software developement experience. He's worked on a wide variety of platforms such as (C++, C#, etc.) and currently works on the OData team to design and implement features in the .NET stack of Microsoft's OData libraries. OData (Open Data Protocol) is an ISO/IEC approved, OASIS standard that defines a set of best practices for building and consuming RESTful APIs. You can find more information about OData at ...

More about author

0 comments

Discussion are closed.