August 30th, 2024

Announcing ASP.NET Core OData 9 Official Release

John Gathogo
Senior Software Engineer

We’re happy to announce that ASP.NET Core OData 9 has been officially released and is available on NuGet:

The major highlight of this release is the update of the OData .NET dependencies to the 8.x major version. By updating the dependencies, we’re able to take advantage of the improvements and new capabilities introduced in Microsoft.OData.Core 8.x and Microsoft.OData.Edm 8.x releases specifically.

The ASP.NET Core OData 9 release will only support .NET 8 or later.

The OData .NET 8 official release announcement addresses the major changes introduced in that release. It’s advisable to go through the post to familiarize yourself with the changes.

In this post, we explore how some of those changes affect the ASP.NET Core OData library, and how to toggle the legacy behavior where possible.

Character encoding in request and response payloads

In OData .NET 8, we introduced a new JSON writer that uses the .NET Utf8JsonWriter behind the scenes to write request and response payloads. The new JSON writer is significantly faster and is the default writer in ASP.NET Core OData 9.

You might observe differences in character encoding on output payloads. We cover those differences in the sections below.

Subset of characters encoded

In its default configuration, the new JSON writer doesn’t encode a large subset of characters like the legacy (OData .NET 7) one did.

The legacy JSON writer encodes all characters with integer values less than 32 and greater than 127 by default – basically all non-ascii characters. This evaluates to around 65440 characters! It is possible to override the default configuration for the legacy JSON writer by passing ODataStringEscapeOption.EscapeOnlyControls option to the default JSON writer factory constructor. With this alternative configuration, a much-reduced subset of characters is encoded.

The new JSON writer uses an underlying Utf8JsonWriter that is configured with the JavaScriptEncoder.UnsafeRelaxedJsonEscaping encoder option by default. The resulting set of characters encoded is much smaller.

Below is an illustration – in form of code sample for an OData service, demonstrating how an output payload looks like when the new JSON writer is used:

// Model
namespace Ver900Sample.Models
{
    public class Order
    {
        public int Id { get; set; }
        public decimal Amount { get; set; }
        public string Note { get; set; }
    }
}

// Controller
using Microsoft.AspNetCore.OData.Query;
using Microsoft.AspNetCore.OData.Results;
using Microsoft.AspNetCore.OData.Routing.Controllers;
using Ver900Sample.Models;

namespace Ver900Sample.Controllers
{
    public class OrdersController : ODataController
    {
        private static readonly List<Order> orders = new List<Order>
        {
            new Order { Id = 1, Amount = 130m, Note = "a - z, α - Ω" },
            new Order { Id = 2, Amount = 170.50m, Note = "😀 🐂 🐕" }
        };

        [EnableQuery]
        public SingleResult<Order> Get(int key)
        {
            return SingleResult.Create(orders.AsQueryable().Where(d => d.Id == key));
        }
    }
}

// Service configuration
using Ver900Sample.Models;
using Microsoft.AspNetCore.OData;
using Microsoft.OData.ModelBuilder;

var builder = WebApplication.CreateBuilder(args);

var modelBuilder = new ODataConventionModelBuilder();
modelBuilder.EntitySet<Order>("Orders");

builder.Services.AddControllers().AddOData(
    options => options.EnableQueryFeatures().AddRouteComponents(
        model: modelBuilder.GetEdmModel()));

var app = builder.Build();

app.UseRouting();
app.MapControllers();

app.Run();

You can query this service for order 1 using the curl tool – the browser may not be a good choice for this illustration since it will intelligently decode the encoded characters.

curl http://localhost:5090/Orders(1)

Note: 5090 is a random port assigned by machine and would differ from one computer to the next.

Below is the result you would observe:

{
  "@odata.context": "http://localhost:5090/$metadata#Orders/$entity",
  "Id": 1,
  "Amount": 130,
  "Note": "a - z, α - Ω"
}

In the above case, the new JSON writer doesn’t encode the α and Ω non-ascii characters.

If it’s important in your scenario to retain the behaviour of the legacy JSON writer, you can do so by swapping out the new JSON writer with the legacy JSON writer using dependency injection. You can do this by modifying the AddOData method call from the above code snippet as follows:

builder.Services.AddControllers().AddOData(
    options => options.EnableQueryFeatures().AddRouteComponents(
        routePrefix: string.Empty,
        model: modelBuilder.GetEdmModel(),
        configureServices: (services) =>
        {
            services.AddScoped<Microsoft.OData.Json.IJsonWriterFactory>(
                sp => new Microsoft.OData.Json.ODataJsonWriterFactory());
        }));

Note: The ODataJsonWriterFactory class is what was previously named DefaultJsonWriterFactory in OData .NET 7. We felt that having “Default” in the name would perpetuate ambiguity since it’s not the default JSON writer factory in OData .NET 8.

Here’s how the response payload would look like if you queried for order 1 after the above change:

{
  "@odata.context": "http://localhost:5090/$metadata#Orders/$entity",
  "Id": 1,
  "Amount": 130,
  "Note": "a - z, \u03b1 - \u03a9"
}

In the above case, the non-ascii characters are encoded.

The Utf8JsonWriter also supports a stricter JavaScript encoder. You can configure your service to use that encoder as follows:

builder.Services.AddControllers().AddOData(
    options => options.EnableQueryFeatures().AddRouteComponents(
        routePrefix: string.Empty,
        model: modelBuilder.GetEdmModel(),
        configureServices: (services) =>
        {
            services.AddScoped<Microsoft.OData.Json.IJsonWriterFactory>(
                sp => new Microsoft.OData.Json.ODataUtf8JsonWriterFactory(
                    System.Text.Encodings.Web.JavaScriptEncoder.Default));
        }));

Here’s how the response payload would look like if you queried for order 1 after the above change:

{
  "@odata.context": "http://localhost:5090/$metadata#Orders/$entity",
  "Id": 1,
  "Amount": 130,
  "Note": "a - z, \u03B1 - \u03A9"
}

The above response payload looks similar to the output from the legacy JSON writer.

However, it’s important to note that the Utf8JsonWriter configured with the stricter JavaScript encoder is not a mirror of the legacy JSON writer in respect to the characters that get encoded.

To close the loop, here’s how you can configure your service to use the legacy JSON writer with the less strict encoding option:

builder.Services.AddControllers().AddOData(
    options => options.EnableQueryFeatures().AddRouteComponents(
        routePrefix: string.Empty,
        model: modelBuilder.GetEdmModel(),
        configureServices: (services) =>
        {
            services.AddScoped<Microsoft.OData.Json.IJsonWriterFactory>(
                sp => new Microsoft.OData.Json.ODataJsonWriterFactory(
                    Microsoft.OData.Json.ODataStringEscapeOption.EscapeOnlyControls));
        }));

The choice of JavaScriptEncoder.UnsafeRelaxedJsonEscaping configuration option was informed by the fact that it’s the default option configured in ASP.NET Core 8.0.

Uppercase letters for unicode code points

The new JSON writer uses uppercase letters in the encoded output while the legacy JSON writer uses lowercase letters. Both outputs are legal, and clients should not have a problem deserializing either format.

Using the sample service from previous section with the new JSON writer configured, you can query for order 2 as follows:

curl http://localhost:5090/Orders(2)

Below is the result you would observe:

{
  "@odata.context": "http://localhost:5090/$metadata#Orders/$entity",
  "Id": 2,
  "Amount": 170.50,
  "Note": "\uD83D\uDE00 \uD83D\uDC02 \uD83D\uDC15"
}

Note that the encoded values for the unicode characters are in uppercase.

If you query the same order 2 with the legacy JSON writer configured, here is how the response payload looks like:

{
  "@odata.context": "http://localhost:5090/$metadata#Orders/$entity",
  "Id": 2,
  "Amount": 170.50,
  "Note": "\ud83d\ude00 \ud83d\udc02 \ud83d\udc15"
}

If the described changes to character encoding are a deal breaker in your scenario, swap out the new JSON writer with the legacy JSON writer as illustrated.

Changes to injection of dependencies

We made significant changes to accommodate dependency injection restructure in OData .NET 8. We got rid of non-standard dependency injection artifacts (IContainerBuilder for instance) in favour of the .NET framework dependency injection abstractions.

In particular, the changes to the AddODataDefaultServices method in the OData core library makes it possible to configure ODataReaderSettings, ODataMessageWriterSettings, and ODataUriParserSettings as follows:

builder.Services.AddControllers().AddOData(
    options => options.EnableQueryFeatures().AddRouteComponents(
        routePrefix: string.Empty,
        model: modelBuilder.GetEdmModel(),
        configureServices: (services) =>
        {
            services.AddDefaultODataServices(
                odataVersion: Microsoft.OData.ODataVersion.V4,
                configureReaderAction: (messageReaderSettings) =>
                {
                    // Relevant changes to the ODataMessageReaderSettings instance here
                },
                configureWriterAction: (messageWriterSettings) =>
                {
                    // Relevant changes to the ODataMessageWriterSettings instance here
                },
                configureUriParserAction: (uriParserSettings) =>
                {
                    // // Relevant changes to the ODataUriParserSettings instance here
                });
        }));

This offers the developer greater flexibility when manipulating those specific settings.

The IServiceCollection parameter also makes it straightforward to inject any desired dependencies:

builder.Services.AddControllers().AddOData(
    options => options.EnableQueryFeatures().AddRouteComponents(
        routePrefix: string.Empty,
        model: modelBuilder.GetEdmModel(),
        configureServices: (services) =>
        {
            services.AddSingleton<Microsoft.OData.ODataPayloadValueConverter>(
                sp => new CustomODataPayloadValueConverter());
        }));

// ...

public class CustomODataPayloadValueConverter : Microsoft.OData.ODataPayloadValueConverter
{
    public override object ConvertToPayloadValue(object value, IEdmTypeReference edmTypeReference)
    {
        if (edmTypeReference.PrimitiveKind() == EdmPrimitiveTypeKind.DateTimeOffset)
        {
            var dateTimeOffset = (DateTimeOffset)value;
            return dateTimeOffset.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffffzzz");
        }

        return base.ConvertToPayloadValue(value, edmTypeReference);
    }
}

Backward compatibility flags

A major goal for OData .NET 8 was to ensure that customers who migrated to the new version could output the same response payloads as they did in OData .NET 7. Where changes were made to better align with OData standard, a compatibility flag was added to make it possible to toggle the legacy behaviour.

One example of a change that we made to align with the OData standard was in writing the Scale property for decimal properties in service metadata payloads, as well as SRID property for spatial properties.

If you query the service metadata endpoint (http://localhost:5090/$metadata) for the sample service from the previous section, you’ll get the payload below:

<?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="Ver900Sample.Models" xmlns="http://docs.oasis-open.org/odata/ns/edm">
            <EntityType Name="Order">
                <Key>
                    <PropertyRef Name="Id" />
                </Key>
                <Property Name="Id" Type="Edm.Int32" Nullable="false" />
                <Property Name="Amount" Type="Edm.Decimal" Nullable="false" Scale="variable" />
                <Property Name="Note" Type="Edm.String" Nullable="false" />
            </EntityType>
        </Schema>
        <Schema Namespace="Default" xmlns="http://docs.oasis-open.org/odata/ns/edm">
            <EntityContainer Name="Container">
                <EntitySet Name="Orders" EntityType="Ver900Sample.Models.Order" />
            </EntityContainer>
        </Schema>
    </edmx:DataServices>
</edmx:Edmx>

In OData .NET 7, the Scale property would be written as Scale="Variable" – with an uppercase “V”. This deviation from the OData standard was fixed in OData .NET 8. The Scale property is now written as Scale="variable", like in the payload above.

If it’s important to maintain the OData .NET 7 behaviour in your scenario, you can achieve that by toggling a feature flag as follows:

builder.Services.AddControllers().AddOData(
    options => options.EnableQueryFeatures().AddRouteComponents(
        routePrefix: string.Empty,
        model: modelBuilder.GetEdmModel(),
        configureServices: (services) =>
        {
            services.AddDefaultODataServices(
                odataVersion: Microsoft.OData.ODataVersion.V4,
                configureReaderAction: null,
                configureWriterAction: (messageWriterSettings) =>
                {
                    messageWriterSettings.LibraryCompatibility |= Microsoft.OData.ODataLibraryCompatibility.UseLegacyVariableCasing;
                },
                configureUriParserAction: null);
        }));

With the ODataLibraryCompability.UseLegacyVariableCasing flag toggled, the Scale property is written as Scale="Variable" for compatibility with OData .NET 7.

You can combine as many compatibility flags as necessary to achieve the desired behaviour.

To toggle all the compatibility flags to achieve OData .NET 7 compatibility, you can use the convinient ODataLibraryCompatibility.Version7 flag as follows:

builder.Services.AddControllers().AddOData(
    options => options.EnableQueryFeatures().AddRouteComponents(
        routePrefix: string.Empty,
        model: modelBuilder.GetEdmModel(),
        configureServices: (services) =>
        {
            services.AddDefaultODataServices(
                odataVersion: Microsoft.OData.ODataVersion.V4,
                configureReaderAction: null,
                configureWriterAction: (messageWriterSettings) =>
                {
                    messageWriterSettings.LibraryCompatibility |= Microsoft.OData.ODataLibraryCompatibility.Version7;
                },
                configureUriParserAction: null);
        }));

 

Conclusion

We invite you to try out ASP.NET Core OData 9 and share your feedback with us. Thank you for your continued support and contributions to the OData ecosystem.

If you have any questions, or comments, or if you run into any issues, feel free to reach out on this blog post or report on GitHub repo for ASP.NET Core OData.

Category
OData

Author

John Gathogo
Senior Software Engineer

2 comments

  • Brian Watts 1 week ago

    Trying to get my database selections to be terse and match the $select parameter passed in via route. This documentation makes me think it’s doable. However I’m finding it hard to implement. I would like to see an official GItHub code sample that combines EF Core and the early parsing of ODataQueryOptions.

  • Leo Erlandsson

    Nice, thanks!

    We are updating a major project to OData 9 right now.