Build formatter extensions in ASP.NET Core OData 8 and hooks in ODataConnectedService

Sam

Introduction

In this post, I will create formatter extensions in an OData web service and request/response hooks in an OData client application to generate/consume ETag control information. ETag (aka entity tag), one of OData control information that may be applied to an entity or collection in response, can be used in a subsequent request to avoid overwriting if the entity or collection’s value has been changed by other requests.

ETag value normally can be generated using concurrency properties automatically in OData, however, I’d like to use this post to share with you the ideas of how to extend serialization and deserialization in ASP.NET Core OData 8 and how to use request/response hook in OData Connected Service to customize the ETag functionality.

Let’s get started.

Scenarios & Prerequisites

I want to generate/consume the ETag control information (aka odata.etag) using a hidden property on the service side, meanwhile, to generate/consume the ETag using an extra property at the client side.

So, let’s create a blank solution named ODataETagExtensions, in which I create an ASP.NET Core Application called ODataETagWebApi, and a console application named ODataETagClient. I install the “Microsoft.AspNetCore.OData -version 8.0.1” nuget package to ODataETagWebApi.

I use ODataConnectedService in the console application ODataETagClient to consume the ETag from the service. If you haven’t installed ODataConnectedService, go to [Extensions]🡺[Manage Extensions] menu from Visual studio, search and install OData Connected Service. You might need to close the VS to let the installation finish.

Graphical user interface, application Description automatically generated

Build the model with hidden ETag property

In ODataETagWebApi, I create a simple C# class as our schema model as:

// Customer.cs
public class Customer
{
    public int Id { get; set; }

    public string Name { get; set; }

    public Guid Label { get; set; }
}

Where, “Label” is the property containing ETag info but not included in the schema. So, when building the Edm model, let’s ignore this property as:

private static IEdmModel GetEdmModel()
{
    var builder = new ODataConventionModelBuilder();

    builder.EntitySet<Customer>("Customers").EntityType.Ignore(c => c.Label);

    return builder.GetEdmModel();
}

Here’s a part of the schema:

<EntityType Name="Customer">
  <Key>
    <PropertyRef Name="Id"/>
  </Key>
  <Property Name="Id" Type="Edm.Int32" Nullable="false"/>
  <Property Name="Name" Type="Edm.String"/>
</EntityType>

Build controller

I create the following CustomersController to handle the request. For simplicity, I use memory data for customers:

public class CustomersController : ControllerBase
{
    private static IList<Customer> Customers = new List<Customer>
    {
        new Customer { Id = 1, Name = "Freezing", Label = new Guid("81B06CD0-3B66-4447-B193-94B11328A762") },
        new Customer { Id = 2, Name = "Bracing", Label = new Guid("629C11E1-1918-4978-AFE0-F90BA6A452C6") },
        new Customer { Id = 3, Name = "Chilly", Label = new Guid("CA02BF9C-8364-4320-B74F-CB8956C9A502") },
    };

    [HttpGet]
    [EnableQuery]
    public IActionResult Get()
    {
        return Ok(Customers);
    }

    [HttpGet]
    [EnableQuery]
    public IActionResult Get(int key)
    {
        Customer c = Customers.FirstOrDefault(c => c.Id == key);
        if (c == null)
        {
            return NotFound($"Cannot find customer with Id={key}");
        }

        return Ok(c);
    }
}

Config and test service

In the startup.cs, let’s configure OData as:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers()
        .AddOData(opt => opt.AddRouteComponents("odata", GetEdmModel));
}

Now, let’s build, run ODataETagWebApi, and query `http://localhost:5000/odata/customers`, we can get the following:

{
  "@odata.context":"http://localhost:5000/odata/$metadata#Customers",
  "value":[
    {
      "Id":1,
      "Name":"Freezing"
    },
    ......
    {
      "Id":5,
      "Name":"Mild"
    }
  ]
}

Customize serializer to return ETtag from service

From the above Customers query, you can find that each customer doesn’t have “Label” property, neither odata.etag property. In order to generate “odata.etag” using the “Label” property, we can customize the resource serializer to generate it.

I customize the serializer by deriving from ODataResourceSerializer. Below is my implementation:

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

    public override ODataResource CreateResource(SelectExpandNode selectExpandNode, ResourceContext resourceContext)
    {
        ODataResource resource = base.CreateResource(selectExpandNode, resourceContext);

        if (resource.ETag == null && resourceContext.ResourceInstance is Customer c)
        {
            resource.ETag = EncodeETag(c.Label);
        }

        return resource;
    }

    private static string EncodeETag(Guid guide)
    {
        byte[] bytes = Encoding.UTF8.GetBytes(guid.ToString());
        return Convert.ToBase64String(bytes);
    }
}

I create the following serializer provider to inject the ETagResourceSerialize. Here’s my implementation:

public class ETagSerializerProvider : ODataSerializerProvider
{
    public ETagSerializerProvider(IServiceProvider serviceProvider)
        : base(serviceProvider)
    { }

    public override IODataEdmTypeSerializer GetEdmTypeSerializer(IEdmTypeReference edmType)
    {
        if (edmType.IsEntity() || edmType.IsComplex())
        {
            return new ETagResourceSerializer(this);
        }

        return base.GetEdmTypeSerializer(edmType);
    }
}

Let’s update Startup.cs by injecting the serializer provider into service collection:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers()
        .AddOData(opt => opt.AddRouteComponents("odata", GetEdmModel(),
            builder => builder.AddSingleton<IODataSerializerProvider, ETagSerializerProvider>()));
}

Now, build, run ODataETagWebApi and query request: http://localhost:5000/odata/customers/1

We can get the following response:

{
  "@odata.context": "http://localhost:5000/odata/$metadata#Customers/$entity",
  "@odata.etag": "ODFiMDZjZDAtM2I2Ni00NDQ3LWIxOTMtOTRiMTEzMjhhNzYy",
  "Id": 1,
  "Name": "Freezing"
}

Consume ETag at OData client

Let’s use ODataConnectedService to config the ODataETagClient project. First, Let’s run the service ODataETagWebApi without debug. Once the service is running, right click ODataETagClient project from Solution Explorer in VS. Select [Add] 🡺 [Connected Service] menu from the Pop-up menu. In the dialog, select OData Connected Service. In the Configure endpoint dialog below, input the Uri in the Address input box (be sure to use your service URI if different):

Click next button and go to Schema Types page to make sure we have the schema loaded.

The above picture shows that OData connected service correctly retrieves the schema type from our service.

Let’s skip other configurations and click the Finish button to let the connected service generate the proxy classes.

Now, let’s change Program.cs in the ODataETagClient using the following codes:

class Program
{
    async static Task Main(string[] args)
    {
        await ListCustomers();
    }

    async static Task ListCustomers()
    {
        Console.WriteLine("List Customers: ");
        Container context = GetContext();
        IEnumerable<Customer> customers = await context.Customers.ExecuteAsync();
        foreach (var customer in customers)
        {
            Console.WriteLine(" {0}) Name={1}", customer.Id, customer.Name);
        }

        Console.WriteLine();
    }

    private static Container GetContext()
    {
        var serviceRoot = "http://localhost:5000/odata/";
        var context = new Container(new Uri(serviceRoot));
        return context;
    }
}

To test, set ODataETagClient as startup project by right click ODataETagClient project in the solution explorer. Please make sure ODataETagWebApi is running, then [Ctrl+F5] to run ODataETagClient, we can get:

You can find OData connected service generates a proxy class named Customer, but it only has Id and Name properties.

To use Label, let’s add a new C# file “Customer.cs” into ODataETagClient project, add the following:

// Customers.cs
public partial class Customer
{
    public Guid Label { get; set; }
}

Change “ListCustomers()” in the program.cs class:

async static Task ListCustomers()
{
    // omit codes

    foreach (var customer in customers)
    {
        Console.WriteLine(" {0}) Name={1,-10}Label={2}", customer.Id, customer.Name + ",", customer.Label);
    }

    // omit codes
}

Run the console application again, we can get an empty Guid Label.

Now, let’s retrieve the Label from ETag. In order to retrieve the value, we can use OData client hook. OData client provides several ways to allow developers to hook into the client request and response. In the current scenario, we can use OnEntityMaterialized from response pipeline configuration. For other hooks, please refer to here.

OnEntityMaterialized response hook uses MaterializedEntityArgs as:

public sealed class MaterializedEntityArgs
{
    public MaterializedEntityArgs(ODataResource entry, object entity);
    public ODataResource Entry { get; }
    public object Entity { get; }
}

Where,

  • Entry is the object holding the value from response.
  • Entity is the object to the client.

I have the following codes to retrieve the ETag value from each customer and save it to Label as following.

private static Container GetContext()
{
    var serviceRoot = "http://localhost:5000/odata/";
    var context = new Container(new Uri(serviceRoot));
    context.Configurations.ResponsePipeline.OnEntityMaterialized(args =>
    {
        if (args. Entity is Customer customer)
        {
            if (args.Entry.ETag != null)
            {
                customer.Label = DecodeETag(args.Entry.ETag);
            }
        }
    });

    return context;
}

private static Guid DecodeETag(string etag)
{
    byte[] base64 = Convert.FromBase64String(etag);
    string base64Str = Encoding.UTF8.GetString(base64);
    return new Guid(base64Str);
}

Now, run it again, we can get Label value:

Customize deserializer to consume ETag at service

In the ODataETagWebApi project, Let’s update the controller by adding the following methods to consume the ETag value from Label property:

public class CustomersController : ODataController
{
    // ...... omit others

    [HttpPost]
    public IActionResult Post([FromBody]Customer customer)
    {
        customer.Id = Customers.Last().Id + 1; // for simplicity
        if (customer.Label == Guid.Empty)
        {
            customer.Label = Guid.NewGuid();
        }

        Customers.Add(customer);
        return Created(customer);
    }

    [HttpPatch]
    public IActionResult Patch(int key, Delta<Customer> patch)
    {
        Customer old = Customers.FirstOrDefault(c => c.Id == key);
        if (old == null)
        {
            return NotFound($"Cannot find customer with Id={key}");
        }

        if (!patch.TryGetPropertyValue("Label", out object value))
        {
            return BadRequest($"Cannot find the ETag value");
        }

        Guid labelValue = (Guid)value;
        if (labelValue != old.Label)
        {
            // Compare Guid value for simplicity
            return BadRequest($"ETag value can not match!");
        }

        patch.Patch(old);
        return Updated(old);
    }
}

I change the base controller to ODataController to use “Created()” and “Updated()” methods.

Same as serializer, I customize the deserializer by deriving form ODataResourceDeserializer:

public class ETagResourceDeserializer : ODataResourceDeserializer
{
    public ETagResourceDeserializer(IODataDeserializerProvider serializerProvider)
        : base(serializerProvider)
    { }

    public override object ReadResource(ODataResourceWrapper resourceWrapper, IEdmStructuredTypeReference structuredType, ODataDeserializerContext readContext)
    {
        object resource = base.ReadResource(resourceWrapper, structuredType, readContext);
        if (resourceWrapper.Resource.ETag != null)
        {
            Guid label = DecodeETag(resourceWrapper.Resource.ETag);
            if (resource is Customer c)
            {
                // For POST scenario
                c.Label = label;
            }
            else if (resource is IDelta delta)
            {
                // For PATCH scenario
                // Have to use the reflection here to save the property "Lavel" into Hash-set.
                Type deltaType = typeof(Delta<>).MakeGenericType(typeof(Customer));
                var fieldInfo = deltaType.GetField("_updatableProperties", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
                var updateProperties = fieldInfo.GetValue(delta) as HashSet<string>;
                updateProperties.Add("Label");
                delta.TrySetPropertyValue("Label", label);
            }
        }

        return resource;
    }

    private static Guid DecodeETag(string etag)
    {
        // ...... same as above, Omit codes, 
    }
}

In order to use ETagResourceDeserializer, I customize the deserializer provider to retrieve it:

public class ETagDeserializerProvider : ODataDeserializerProvider
{
    public ETagDeserializerProvider(IServiceProvider serviceProvider)
        : base(serviceProvider)
    { }

    public override IODataEdmTypeDeserializer GetEdmTypeDeserializer(IEdmTypeReference edmType, bool isDelta = false)
    {
        if (edmType.IsEntity() || edmType.IsComplex())
        {
            return new ETagResourceDeserializer(this);
        }

        return base.GetEdmTypeDeserializer(edmType, isDelta);
    }
}

Add the deserializer provider into the service collection as:

    services.AddControllers()
        .AddOData(opt => opt.AddRouteComponents("odata", GetEdmModel(),
            builder => builder.AddSingleton<IODataSerializerProvider, ETagSerializerProvider>()
               .AddSingleton<IODataDeserializerProvider, ETagDeserializerProvider>()));

Now, build and run ODataETagWebApi project in debug model, put a break point in the Post() method, use Postman send request as:

We can get the following debug snapshot:

Create entity using ETag from client

Let’s use OData client to replace Postman and send Post request to create a new customer with ETag value from Label property.

First, create a new method in Program class of ODataETagClient project.

async static Task CreateCustomer(string name, Guid? label = null)
{
    Console.WriteLine($"Create a new Customer: name={name}");
    Container context = GetContext();
    Customer newCustomer = new Customer
    {
        Name = name,
        Label = label ?? Guid.Empty
    };

    context.AddToCustomers(newCustomer);
    DataServiceResponse responses = await context.SaveChangesAsync();
    foreach (var response in responses)
    {
        Console.WriteLine($" StatusCode={response.StatusCode}");
    }

    Console.WriteLine();

    // List Customers
    await ListCustomers();
}

Then, update “GetContext()” method to register a request Hook. I use “OnEntryStarting” request hook, for others, please refer to here:

private static Container GetContext()
{
    // ...... Omit codes

    context.Configurations.RequestPipeline.OnEntryStarting(args =>
    {
        Customer customer = args.Entity as Customer;
        if (customer != null && customer.Label != Guid.Empty)
        {
            args.Entry.ETag = EncodeETag(customer.Label);
        }

        args.Entry.Properties = args.Entry.Properties.ToList().Where(c => c.Name != "Label");
    });

    return context;
}

private static string EncodeETag(Guid guid)
{
    // ...... omit codes
}

Now, let’s change Main method as follows to test:

static void Main(string[] args)
{
    await ListCustomers();

    await CreateCustomer("Sam");

    await CreateCustomer("Lucas", new Guid("00000000-029B-484E-A257-111122223333"));
}

Let’s config the startup project by right click on ODataETagExtensions as follows:

Open Fiddler, then build and run, we can see the following requests with the correct odata.etag value:

Here’s the console application output:

Update entity using ETag from client

Let’s create a new method in Program class of ODataETagClient project.

async static Task UpdateCustomer(int key, string name, Guid? label = null)
{
    Console.WriteLine($"Update Customers({key}) with name={name}: ");

    Container context = GetContext();

    Customer customer = context.Customers.ByKey(key).GetValue();
    customer.Name = name;

    if (label != null)
    {
        customer.Label = label.Value;
    }
    context.UpdateObject(customer);

    try
    {
        DataServiceResponse responses = await context.SaveChangesAsync();
        foreach (var response in responses)
        {
            Console.WriteLine($" StatusCode={response.StatusCode}");
        }
    }
    catch (DataServiceRequestException ex)
    {
        foreach (var response in ex.Response)
        {
            Console.WriteLine($" StatusCode={response.StatusCode},\n Message={ex.InnerException.Message}");
        }
    }

    Console.WriteLine();

    // List Customers
    await ListCustomers();
}

Now, let’s change Main method as:

static void Main(string[] args)
{
    Guid label = new Guid("00000000-029B-484E-A257-111122223333");

    await ListCustomers();

    await CreateCustomer("Sam");

    await CreateCustomer("Lucas", label);

    await UpdateCustomer(1, "Peter");

    await UpdateCustomer(2, "John", label);
}

Let’s run the ODataETagWebApi first again, then run the ODataETagClient console application. We can get the following output:

Where, you can see:

  1. Successfully update the name of Customer #1 from “Freezing” to “Peter”
  2. Can’t update the name of Customer #2 from “Bracing” to “John” because “ETag value cannot match!”.

Summary

This post went throw the steps on how to extend serializer and deserializer in ASP.NET Core OData 8 and how to use request and response hook in OData connected service. Hope it can help you with a similar requirement. 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.

2 comments

Leave a comment