Tutorial: Build gRPC & OData in ASP.NET Core

Sam Xu

Introduction

gRPC (google Remote Procedure Call) is a high-performance remote procedure call framework that helps developers to build and consume remote services using the same way as calling local APIs. Different from gRPC, OData (Open Data Protocol) is an OASIS standard that defines a set of best practices for developers to build and consume RESTful APIs using HTTP request/response. Both approaches have their own strong points, for example, gRPC has less network usage with protobuf binary serialization, on the contrary, OData uses JSON as data format for human readability. In addition, OData has powerful query options functionality that can be used to sharpen the data from service.

Given these, it could be difficult to choose which one to use. But, why? Why we must choose one. In this post, I will create an ASP.NET Core Web Application containing the gRPC service and OData service together. I’d like to use this post to share with you the idea of how to build a gRPC service from scratch, and how to build the OData service using gRPC auto-generated classes. Most importantly, how to consume gRPC and OData service from the client. Below is my simple design structure, an ASP.NET Core server that provides the OData endpoint and the gRPC service. Both depend on a data repository interface to provide data access.

Shape, polygon Description automatically generated

Let’s get started!

Prerequisites

Our first step is to create a blank solution called gRPC.OData using Visual Studio 2022. In the solution, create an ASP.NET Core web application called gRPC.OData.Server targeting .NET 6.0 platform, uncheck OpenApi support for simplicity.

Once we have our project generated, let’s install the following Nuget packages that help generate gRPC proxy classes and build the application.

  • Install-Package Microsoft.AspNetCore.OData -version 8.0.8
  • Install-Package Grpc.AspNetCore -version 2.44.0
  • Install-Package Grpc.Tools -version 2.45.0

Protocol Buffer

gRPC is contract-based design workflow which means we first need a .proto file to define the structure of the data (or the messages) and the corresponding service.

So, let’s create a “protos” folder in the server project. In the folder, create a file named bookstore.proto with the following content. (Note: I only list and implement parts of methods in the post for simplicity. You can find the whole CRUD scenario here.)

syntax = "proto3";

package bookstores;

import "google/protobuf/empty.proto";

// The API manages shelves and books resources. Shelves contain books.
service Bookstore {

    // Returns a list of all shelves in the bookstore.
    rpc ListShelves(google.protobuf.Empty) returns (ListShelvesResponse) {}

    // Returns a list of books on a shelf.
    rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {}
}

// A shelf resource.
message Shelf {
    int64 id = 1;
    string theme = 2;
}

// A book resource.
message Book {
    int64 id = 1;
    string author = 2;
    string title = 3;
}

// Response to ListShelves call.
message ListShelvesResponse {
    repeated Shelf shelves = 1;
}

// Request message for ListBooks method.
message ListBooksRequest {
    int64 shelf = 1;
}

// Response message to ListBooks method.
message ListBooksResponse {
    repeated Book books = 1;
}

Where:

  • Bookstore is the gRPC service that contains two RPC definitions, ListShelves & ListBooks.
  • Shelf & Book are two resource/entity types.
  • ListShelvesResponse, ListBooksRequest, ListBooksResponse are types for proto request and response.

Now, right click the bookstore.proto from the solution explorer in Visual Studio, select properties menu, do the following configuration in the Property pages:

This configuration inserts the following lines into gRPC.OData.Server.csproj.

<ItemGroup>
  <Protobuf Include="proto\BookStore.proto" GrpcServices="Server" />
</ItemGroup>

We finished the contract, let’s build the solution, and let the protobuf compiler generate the proxy classes based on the bookstore.proto.

After building, you can find two C# files created under the “obj\debug\net6.0\protos” folder, where:

  1. BookStore.cs: contains the proxy classes for the messages defined in the .proto file.
  2. BookStoreGrpc.cs: contains the proxy classes for the service and RPC methods defined under service section in the .proto file.

Here’s the part-codes of auto-generated class Shelf. Anytime when bookstore.proto gets changed, the proxy classes will be re-generated automatically.

Data Repository

As mentioned, we need a data repository to provide data access both for gRPC and OData. Since we have the proxy classes, such as C# class Shelf and Book, we can define the repository interface. Let’s create a new folder named Models, create an interface as follows in this folder:

using Bookstores;

namespace gRPC.OData.Server.Models
{
    public interface IShelfBookRepository
    {
        IEnumerable<Shelf> GetShelves();

        IEnumerable<Book> GetBooks(long shelfId);
    }
}

Shelf and Book have an one-to-many relationship. However, the proto and the corresponding proxy classes don’t include such. We need to add such relationship ourselves by creating the partial class for Shelf as:

namespace Bookstores // keep the same namespace
{
    public sealed partial class Shelf
    {
        public IList<Book> Books { get; set; }
    }
}

For simplicity and tutorial only, we can build an in-memory data repository to play the scenarios as:

public class ShelfBookInMemoryRepository : IShelfBookRepository
{
    private static IList<Shelf> _sheves;

    static ShelfBookInMemoryRepository()
    {
        …… // omits the shelves codes, find in the sample repository
    }

    public IEnumerable<Shelf> GetShelves() => _sheves;

    public IEnumerable<Book> GetBooks(long shelfId)
    {
        Shelf shelf = _sheves.FirstOrDefault(s => s.Id == shelfId);

        if (shelf is null)
        {
            return Enumerable.Empty<Book>();
        }

        return shelf.Books;
    }
}

Be noted, that I only list some parts of the implementation, you can find the whole implementation of the in-memory repository here.

Build OData Model

We can use the auto-generated Shelf and Book types as our Data model to generate the OData Edm model. Let’s create a new class named EdmModelBuilder in Models folder as:

public static IEdmModel GetEdmModel()
{
    var builder = new ODataModelBuilder();

    var shelf = builder.EntityType<Shelf>();
    shelf.HasKey(b => b.Id);
    shelf.Property(b => b.Theme);

    var bookMessage = builder.EntityType<Book>();
    bookMessage.HasKey(b => b.Id);
    bookMessage.Property(b => b.Title);
    bookMessage.Property(b => b.Author);

    builder.EntitySet<Book>("Books");
    builder.EntitySet<Shelf>("Shelves").HasManyBinding(s => s.Books, "Books");

    return builder.GetEdmModel();
}

Since we create the extra property named “Books” for type Shelf, we can easily build the navigation link between Shelf and Book by calling HasManyBinding fluent API.

Build gRPC service

Protobuf compiler auto-generates the gRPC methods for us in the BookstoreBase proxy class. However, as shown below, the method is unimplemented and useless.

To make the gRPC work, we must override the methods auto generated from compiler. Let’s create a new folder named services, create a new file named BookstoreService.cs in the folder with the following contents:

using Bookstores;

namespace gRPC.OData.Services
{
    public class BookstoreService : Bookstore.BookstoreBase
    {
        private readonly ILogger _logger;

        private readonly IShelfBookRepository _shelfBookRepository;

        public BookstoreService(ILoggerFactory loggerFactory, IShelfBookRepository shelfBookRepository)
        {
            _logger = loggerFactory.CreateLogger<BookstoreService>();

            _shelfBookRepository = shelfBookRepository;
        }
    }
}

Be noted, that we injected the data repository through the constructor injection.

Web Application configuration

Since we have the BookstoreService and the OData Edm model ready, we can config the ASP.NET core web application to enable gRPC and OData endpoints. First, we register gRPC (by calling AddGrpc()) and OData (by calling AddOData(…)) services into the service collections in the Program.cs as follows (Be noted, we also register the repository as transient service.)

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddTransient<IShelfBookRepository, ShelfBookInMemoryRepository>();

builder.Services.AddControllers()

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

builder.Services.AddGrpc();

Second, we must register the gRPC service class (by calling MapGrpcService<T>()) into the request pipeline to build the gRPC endpoints. Since OData is built upon the ASP.NET Core endpoint routing, there’s no extra configuration needed. Below is the finished configuration:

var app = builder.Build();

// Configure the HTTP request pipeline.

app.UseEndpointDebug(); // send "/$endpoint" for route debug

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapGrpcService<BookstoreService>();

app.MapControllers();

app.Run();

Be noted, ‘app.UseEndpointDebug();’ is a middleware added in the sample project (find here) to help debug the endpoints. Since we enabled this middleware, you can run the project and view https://localhost:7260/$endpoint in your browser (remember change the port number to your own), to receive an endpoint mappings page as follows. (You can get a full endpoint mappings page using the final project here):

From the endpoint mapping page, we can find:

  1. All gRPC methods defined in the bookstore.proto are built as an endpoint with POST HTTP Method.
  2. The route pattern of gRPC endpoint is “/{servicefullname}/{methodname}”, for example: “/bookstores.Bookstore/ListShelves
  3. gRPC also generates two endpoints to handle unimplemented gRPC requests.
  4. Two OData endpoints are built by default for accessing the metadata.

You can send a HTTP request at “GET https://localhost:7260/odata/$metadata” to view the OData metadata.

<?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="Bookstores" xmlns="http://docs.oasis-open.org/odata/ns/edm">
      <EntityType Name="Shelf">
        <Key>
          <PropertyRef Name="Id" />
        </Key>
        <Property Name="Id" Type="Edm.Int64" Nullable="false" />
        <Property Name="Theme" Type="Edm.String" />
        <NavigationProperty Name="Books" Type="Collection(Bookstores.Book)" />
      </EntityType>
      <EntityType Name="Book">
        <Key>
          <PropertyRef Name="Id" />
        </Key>
        <Property Name="Id" Type="Edm.Int64" Nullable="false" />
        <Property Name="Title" Type="Edm.String" />
        <Property Name="Author" Type="Edm.String" />
      </EntityType>
    </Schema>
    <Schema Namespace="Default" xmlns="http://docs.oasis-open.org/odata/ns/edm">
      <EntityContainer Name="Container">
        <EntitySet Name="Books" EntityType="Bookstores.Book" />
        <EntitySet Name="Shelves" EntityType="Bookstores.Shelf">
          <NavigationPropertyBinding Path="Books" Target="Books" />
        </EntitySet>
      </EntityContainer>
    </Schema>
  </edmx:DataServices>
</edmx:Edmx>

Implement gRPC Endpoint

As mentioned, we must override the methods auto generated by gRPC compiler to make gRPC endpoint work. Let’s open BookstoreService class we created above and add the following codes:

public class BookstoreService : Bookstore.BookstoreBase
{
    …… // omit the constructor

    // list shelves
    public override Task<ListShelvesResponse> ListShelves(Empty request, ServerCallContext context)
    {
        IEnumerable<Shelf> shelves = _shelfBookRepository.GetShelves();

        ListShelvesResponse response = new ListShelvesResponse();
        foreach (var shelf in shelves)
        {
            response.Shelves.Add(shelf);
        }

        return Task.FromResult(response);
    }

    // list the books
    public override Task<ListBooksResponse> ListBooks(ListBooksRequest request, ServerCallContext context)
    {
        IEnumerable<Book> books = _shelfBookRepository.GetBooks(request.Shelf);

        ListBooksResponse response = new ListBooksResponse();
        foreach (Book book in books)
        {
            response.Books.Add(book);
        };

        return Task.FromResult(response);
    }
}

Where:

  1. We override the ListShelves and ListBooks methods from ‘Bookstore.BookstoreBase’ base class.
  2. We use repository service injected from the constructor to do the real data access.

You can find the whole implementation of BookstoreService here.

Implement OData Endpoint

Let’s create a controller named ShelfBooksController under Controllers folder to handle OData HTTP requests:

[Route("odata")]
public class ShelfBooksController : ODataController
{
    private readonly IShelfBookRepository _shelfBookRepository;

    public ShelfBooksController(IShelfBookRepository shelfBookRepository)
    {
        _shelfBookRepository = shelfBookRepository;
    }

    [HttpGet("Shelves")]
    [EnableQuery]
    public IActionResult ListShelves()
    {
        return Ok(_shelfBookRepository.GetShelves());
    }

    [HttpGet("Shelves/{shelf}/Books")]
    [EnableQuery]
    public IActionResult ListBooks(long shelf)
    {
        return Ok(_shelfBookRepository.GetBooks(shelf));
    }
}

Where:

  1. We use the OData attribute routing by decorating [Route(“odata”)] on controller and [HttGet(“……”)] on action.
  2. We use the constructor dependency injection to inject the repository service to do the data access also.
  3. We enable the OData query by decorating [EnableQuery] on action.

You can find the whole implementation of ShelfBooksController here.

Build gRPC and OData client

Ok, we finished our service for gRPC and OData, it’s time to build a client to consume them. Basically, you can use any HTTP client as OData client. In this post, I’d like to build a console application both for the gRPC and OData.

Let’s create a new console application named gRPC.OData.client in the gRPC.OData solution.

Create a Protos folder in the client project, and copy the ‘Protos\bookstore.proto’ file from the gRPC.OData.Server project to the Protos folder in the client project.

Double click the gRPC.OData.client project in the solution explorer, add the following contents into the gRPC.OData.Client.csproj file as:

<ItemGroup>
  <PackageReference Include="Google.Protobuf" Version="3.20.0" />
  <PackageReference Include="Grpc.Net.Client" Version="2.44.0" />
  <PackageReference Include="Grpc.Tools" Version="2.45.0">
    <PrivateAssets>all</PrivateAssets>
    <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
  </PackageReference>
</ItemGroup>

<ItemGroup>
  <Protobuf Include="proto\BookStore.proto" GrpcServices="Client" />
</ItemGroup>

Be noted, the value of GrpcServices attribute in the above <Protobuf …/> XML tag is “client” for the client project, meanwhile, it’s “server” value at the server project.

Build the client project and let the protobuf compiler generate the client proxy classes. You can find the auto-generated client C# files under “obj” subfolder.

Let’s create a class to handle the gRPC client:

internal class GrpcBookstoreClient
{
    private readonly string _baseUri;

    public GrpcBookstoreClient(string baseUri)
    {
        _baseUri = baseUri;
    }

    public async Task ListShelves()
    {
        Console.WriteLine("\ngRPC: List shelves:");

        using var channel = GrpcChannel.ForAddress(_baseUri);
        var client = new Bookstore.BookstoreClient(channel);

        var listShelvesResponse = await client.ListShelvesAsync(new Empty());
        foreach (var shelf in listShelvesResponse.Shelves)
        {
            Console.WriteLine($"\t-{shelf.Id}): {shelf.Theme}");
        }

        Console.WriteLine();
    }

    public async Task ListBooks(long shelfId)
    {
        Console.WriteLine($"\ngRPC: List books at shelf '{shelfId}':");

        using var channel = GrpcChannel.ForAddress(_baseUri);
        var client = new Bookstore.BookstoreClient(channel);

        var listBooksResponse = await client.ListBooksAsync(new ListBooksRequest { Shelf = shelfId });
        foreach (var book in listBooksResponse.Books)
        {
            Console.WriteLine($"\t-{book.Id}): <<{book.Title}>> by {book.Author}");
        }
    }
}

Create another class to handle the OData client:

internal class ODataBookstoreClient
{
    private readonly string _baseUri;

    public ODataBookstoreClient(string baseUri)
    {
        _baseUri = baseUri;
    }

    public async Task ListShelves()
    {
        Console.WriteLine($"\nOData: List Shelves:");

        string requestUri = $"{_baseUri}/odata/shelves";
        using var client = new HttpClient();
        var response = await client.GetAsync(requestUri);

        Console.WriteLine("--Status code: " + response.StatusCode.ToString());
        string body = await response.Content.ReadAsStringAsync();
        Console.WriteLine("--Response body:");
        Console.WriteLine(BeautifyJson(body)); // find BeautifyJson from sample repository
        Console.WriteLine();
    }

    public async Task ListBooks(long shelfId)
    {
        Console.WriteLine($"\nOData: List books at shelf '{shelfId}':");

        string requestUri = $"{_baseUri}/odata/shelves/{shelfId}/books";
        using var client = new HttpClient();
        var response = await client.GetAsync(requestUri);

        Console.WriteLine("--Status code: " + response.StatusCode.ToString());
        string body = await response.Content.ReadAsStringAsync();
        Console.WriteLine("--Response body:");
        Console.WriteLine(BeautifyJson(body));
        Console.WriteLine();
    }
}

Now, update Program.cs as:

using gRPC.OData.Client;

string baseUri = "https://localhost:7260";

GrpcBookstoreClient gRPCbookStore = new GrpcBookstoreClient(baseUri);

await gRPCbookStore.ListShelves();

await gRPCbookStore.ListBooks(2);

ODataBookstoreClient oDataBookStore = new ODataBookstoreClient(baseUri);

await oDataBookStore.ListShelves();

await oDataBookStore.ListBooks(2);

Ok, we’re ready to run the whole solution. Right-click the properties menu from the solution, select the multiple startup projects option, and move the server project to the top as shown below.

Now, Ctrl+F5 to run the projects. The service runs first as:

Then the client project runs and outputs the following result:

The final sample project has the whole CRUD implementations for both gRPC and OData. You can find it here.

Apply OData query

gRPC response body, which is protobuf binary format, is smaller than OData response body, which is JSON human-readable format. However, one of the advantages of OData is its powerful query option mechanism. For example, we can send the following OData request with a query in any HTTP client:

Get https://localhost:7260/odata/Shelves?$expand=Books($filter=Id lt 12)

We can get the following result:

{
  "@odata.context": "https://localhost:7260/odata/$metadata#Shelves(Books())",
  "value": [
    {
      "Id": 1,
      "Theme": "Fiction",
      "Books": [
        {
          "Id": 11,
          "Title": "西游记",
          "Author": "吳承恩"
        }
      ]
    },
    {
      "Id": 2,
      "Theme": "Classics",
      "Books": []
    },
    {
      "Id": 3,
      "Theme": "Computer",
      "Books": []
    }
  ]
}

That is OData built-in functionality, no extra coding is required.

Summary

This post went through a process to build and consume gRPC and OData service together in one ASP.NET Core Web Application. gRPC and OData have their own usage scenarios, advantages, and disadvantages. Hope the contents and implementations in this post can help you to build your own service easier in the future. 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

  • Mark Radcliffe

    Have you considered adding a way for a query to be applied via the gRPC client that is handled by the OData library? Concepts like expand and aggregates may not make sense.

    Filter could make sense
    Select may still make sense as you’d be limiting the database side of the request just with leaving the content blank/default for the fields you aren’t setting.