Introducing batch support in Web API and Web API OData

Javier Calvarro Nelson

With the release of Microsoft ASP.NET Web API 2 OData, we have introduced support for batching requests. Batching is a web API feature that allows a customer to pack several API requests and send them to the web API service in one HTTP request and receive a single HTTP response with the response to all their requests. This way, the client can optimize calls to the server and improve the scalability of its service.

For a more in depth look at the batch support, you can take a look at the specification.

Batch in Web API

In order to start using batch in Web API, the only requirement is to register a route with a batch handler. Let’s start by creating the server. For this sample we will be using the brand new OWIN host for Web API. The code below creates the server and configures a web API route and a batch route.

static void Main(string[] args)
{
    string serviceUrl = "http://localhost:12345";
    using (WebApp.Start(serviceUrl, Configuration))
    {

        Console.WriteLine("Service listening at {0}", serviceUrl);
        Console.WriteLine("Press any key to stop the service and exit the application");
        Console.ReadKey();
    }
}

private static void Configuration(IAppBuilder builder)
{
    HttpConfiguration configuration = new HttpConfiguration();
    HttpServer server = new HttpServer(configuration);
    configuration.Routes.MapHttpBatchRoute(
        routeName:"batch",
        routeTemplate:"api/batch",
        batchHandler:new DefaultHttpBatchHandler(server));
    configuration.Routes.MapHttpRoute("api", "api/{controller}/{id}", new { id = RouteParameter.Optional });
    builder.UseWebApi(server);
}

If we look in detail to the code in the Configuration method, we can see the following method call is the only thing required to enable batching in your Web API service:

configuration.Routes.MapHttpBatchRoute(
        routeName:"batch",
        routeTemplate:"api/batch",
        batchHandler:new DefaultHttpBatchHandler(server));

The important things to notice are the following:

  • The DefaultHttpBatchHandler requires an instance of an HttpServer to work. We can create this instance ourselves in the self-host scenario or we can get it by accessing it from GlobalConfiguration.DefaultServer.
  • The DefaultHttpBatchHandler executes each request sequentially, but in case you don’t have any dependency between requests, you can set a property to make execution non-sequential.
  • The MapHttpBatchRoute route template can contain parameters. So, in case you create your own batch handler, you can pass in parameters to it.

That’s all that is required to enable batch in your service. Let’s see how to send batch requests and read batch responses using HttpClient. In order to do that, we are going to need a model, a web API controller and a route to dispatch the incoming requests. We will be using Entity Framework as the backend and AutoFixture to generate some sample data for the service. Here is the code for all of it:

Route:

configuration.Routes.MapHttpRoute(
    name: "api",
    routeTemplate: "api/{controller}/{id}",
    defaults: new { id = RouteParameter.Optional });

Model:

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
}

Controller:

public class WebCustomersController : ApiController
{
    CustomersContext context = new CustomersContext();
    [Queryable(PageSize = 10, MaxExpansionDepth = 2)]
    public IHttpActionResult Get()
    {
        return Ok(context.Customers);
    }

    public async Task<IHttpActionResult> Post([FromBody] Customer entity)
    {
        if (entity == null)
        {
            return BadRequest(ModelState);
        }
        context.Customers.Add(entity);
        await context.SaveChangesAsync();
        return CreatedAtRoute("api", new { controller = "ApiCustomers" }, entity);
    }

    public async Task<IHttpActionResult> Put(int id, [FromBody] Customer entity)
    {
        if (entity == null)
        {
            return BadRequest(ModelState);
        }
        else if (id != entity.Id)
        {
            return BadRequest("The key from the url must match the key of the entity in the body");
        }
        var originalCustomer = await context.Customers.FindAsync(id);
        if (originalCustomer == null)
        {
            return NotFound();
        }
        else
        {
            context.Entry(originalCustomer).CurrentValues.SetValues(entity);
            await context.SaveChangesAsync();
        }
        return Content(HttpStatusCode.OK, entity);
    }

    public async Task<IHttpActionResult> Delete(int id)
    {
        Customer entity = await context.Customers.FindAsync(id);
        if (entity == null)
        {
            return NotFound();
        }
        else
        {
            context.Customers.Remove(entity);
            await context.SaveChangesAsync();
            return StatusCode(HttpStatusCode.NoContent);
        }
    }
}

Entity framework context:

public class CustomersContext : DbContext
{
    static CustomersContext()
    {
        Database.SetInitializer<CustomersContext>(new CustomersContextInitializer());
    }

    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
    }

    private class CustomersContextInitializer : DropCreateDatabaseAlways<CustomersContext>
    {
        protected override void Seed(CustomersContext context)
        {
            Fixture fixture = new Fixture();
            IEnumerable<Customer> customers = fixture.CreateMany<Customer>(20).ToList();
            context.Customers.AddRange(customers);
        }
    }

    public DbSet<Customer> Customers { get; set; }
}

Now that we have a running service with some data on it, and we have enabled batching support, we can move on to actually perform batch requests. The default Web API batch implementation is based on the mime/multipart content type. It accepts mime/multipart requests containing multiple HTTP Requests and processes all the requests sending a mime/multipart response containing the individual responses. Let’s start by creating some requests:

Fixture fixture = new Fixture();
HttpClient client = new HttpClient();
dynamic listOfCustomers = JToken.Parse(await client.GetStringAsync("http://localhost:12345/api/WebCustomers"));
dynamic firstCustomer = listOfCustomers[0];
firstCustomer.Name = "Peter";
dynamic secondCustomer = listOfCustomers[1];
JsonMediaTypeFormatter formatter = new JsonMediaTypeFormatter();

//Create a request to query for customers
HttpRequestMessage queryCustomers = new HttpRequestMessage(HttpMethod.Get, "http://localhost:13245/api/WebCustomers");
//Create a message to add a customer
HttpRequestMessage addCustomer = new HttpRequestMessage(HttpMethod.Post, "http://localhost:13245/api/WebCustomers");
addCustomer.Content = new ObjectContent<Customer>(fixture.Create<Customer>(), formatter);
//Create a message to update a customer
HttpRequestMessage updateCustomer = new HttpRequestMessage(HttpMethod.Put, string.Format("http://localhost:13245/api/WebCustomers/{0}", firstCustomer.Id));
updateCustomer.Content = new ObjectContent<dynamic>(firstCustomer, formatter);
//Create a message to remove a customer.
HttpRequestMessage removeCustomer = new HttpRequestMessage(HttpMethod.Delete, string.Format("http://localhost:13245/api/WebCustomers/{0}", secondCustomer.Id));

The first block before the blank line only performs a query to get some customers that we can update and delete in a request later. At this point, we could just send four requests using HttpClient, and our service would just process the requests and send individual responses back.

In order to batch those requests together into a single HTTP request, we need to encapsulate them into HttpMessageContent instances and add those instances to a MultipartContent instance. Here is the code to do that:

//Create the different parts of the multipart content
HttpMessageContent queryContent = new HttpMessageContent(queryCustomers);
HttpMessageContent addCustomerContent = new HttpMessageContent(addCustomer);
HttpMessageContent updateCustomerContent = new HttpMessageContent(updateCustomer);
HttpMessageContent removeCustomerContent = new HttpMessageContent(removeCustomer);

//Create the multipart/mixed message content
MultipartContent content = new MultipartContent("mixed", "batch_" + Guid.NewGuid().ToString());
content.Add(queryContent);
content.Add(addCustomerContent);
content.Add(updateCustomerContent);
content.Add(removeCustomerContent);

If you look at the code above, the multipart content needs to have a subtype of mixed, and a boundary which is a unique identifier that determines the separation between the different parts of the content.

Now that we have created the multipart/mixed content, the only last thing that we need to do in order to have a valid batch request is create the HTTP request and associate the content to it. We can do that as we see in the following fragment:

//Create the request to the batch service
HttpRequestMessage batchRequest = new HttpRequestMessage(HttpMethod.Post, "http://localhost:12345/api/batch");
//Associate the content with the message
batchRequest.Content = content;

With this last part, we are ready to send a batch request to the server and get a response. To do that, we just use an HttpClient instance and call SendAsync passing the request message to it and capture the associated response. If we just do that, the following request gets send and the following response comes back:

Request:

POST http://localhost:12345/api/batch HTTP/1.1
Content-Type: multipart/mixed; boundary="batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e"
Host: localhost:12345
Content-Length: 857
Expect: 100-continue

--batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e
Content-Type: application/http; msgtype=request

GET /api/WebCustomers HTTP/1.1
Host: localhost:13245


--batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e
Content-Type: application/http; msgtype=request

POST /api/WebCustomers HTTP/1.1
Host: localhost:13245
Content-Type: application/json; charset=utf-8

{"Id":129,"Name":"Name4752cbf0-e365-43c3-aa8d-1bbc8429dbf8"}
--batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e
Content-Type: application/http; msgtype=request

PUT /api/WebCustomers/1 HTTP/1.1
Host: localhost:13245
Content-Type: application/json; charset=utf-8

{"Id":1,"Name":"Peter"}
--batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e
Content-Type: application/http; msgtype=request

DELETE /api/WebCustomers/2 HTTP/1.1
Host: localhost:13245


--batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e--

If we look at the request above, we can see, as we have said before, that all the messages are separated with a boundary, which in this case is “batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e”. Also, we can see that every request message has a Content-Type of application/http. This header is introduced by the HttpContent where we wrapped all our requests. If we wanted to, we could have added extra headers that we could read and use for handling the processing of the message in a custom batch handler.

If we look at the response below we can clearly see that it follows the same pattern, with a multipart/mixed content type, a boundary to separate the different parts of the multipart and a collection of responses encapsulated in HttpContent parts.

Response:

HTTP/1.1 200 OK
Content-Length: 1373
Content-Type: multipart/mixed; boundary="61cfbe41-7ea6-4771-b1c5-b43564208ee5"
Server: Microsoft-HTTPAPI/2.0
Date: Fri, 25 Oct 2013 06:30:14 GMT

--61cfbe41-7ea6-4771-b1c5-b43564208ee5
Content-Type: application/http; msgtype=response

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

[{"Id":1,"Name":"Namefc4b8794-943b-487a-9049-a8559232b9dd"},{"Id":2,"Name":"Name244bbada-3e83-43c8-82f7-5b2c4d72f2ed"},{"Id":3,"Name":"Nameec11d080-7f2d-47df-a483-7ff251cdda7a"},{"Id":4,"Name":"Name14ff5a3d-ad92-41f6-b4f6-9b94622f4968"},{"Id":5,"Name":"Name00f9e4cc-673e-4139-ba30-bfc273844678"},{"Id":6,"Name":"Name01f6660c-d1de-4c05-8567-8ae2759c4117"},{"Id":7,"Name":"Name60030a17-6316-427c-a744-b2fff6d9fe11"},{"Id":8,"Name":"Namefa61eb4c-9f9e-47a2-8dc5-15d8afe33f2d"},{"Id":9,"Name":"Name9b680c10-1727-43f5-83cf-c8eda3a63790"},{"Id":10,"Name":"Name9e66d797-d3a9-44ec-814d-aecde8040ced"}]
--61cfbe41-7ea6-4771-b1c5-b43564208ee5
Content-Type: application/http; msgtype=response

HTTP/1.1 201 Created
Location: http://localhost:13245/api/ApiCustomers
Content-Type: application/json; charset=utf-8

{"Id":21,"Name":"Name4752cbf0-e365-43c3-aa8d-1bbc8429dbf8"}
--61cfbe41-7ea6-4771-b1c5-b43564208ee5
Content-Type: application/http; msgtype=response

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"Id":1,"Name":"Peter"}
--61cfbe41-7ea6-4771-b1c5-b43564208ee5
Content-Type: application/http; msgtype=response

HTTP/1.1 204 No Content


--61cfbe41-7ea6-4771-b1c5-b43564208ee5--

Finally, now that we have sent and received a response we need to read it and extract all the individual responses from the content. For accomplishing this task, we can use the ReadAsMimeMultipartAsync and ReadAsHttpResponseMessageAsync methods on the Content property of the response.

HttpResponseMessage response = await client.SendAsync(batchRequest);
//Reads the individual parts in the content and loads them in memory
MultipartMemoryStreamProvider responseContents = await response.Content.ReadAsMultipartAsync();
//Extracts each of the individual Http responses
HttpResponseMessage queryResponse = await responseContents.Contents[0].ReadAsHttpResponseMessageAsync();
HttpResponseMessage addResponse = await responseContents.Contents[1].ReadAsHttpResponseMessageAsync();
HttpResponseMessage updateResponse = await responseContents.Contents[2].ReadAsHttpResponseMessageAsync();
HttpResponseMessage removeResponse = await responseContents.Contents[3].ReadAsHttpResponseMessageAsync();

And with this, we have successfully sent and read a batch request to Web API. Let’s look at how to do the same thing with Web API OData.

Batch in OData

In order to use batch with OData services, the flow is very similar to the flow in Web API. We will be reusing the model and the backend from the above sample, and the only things we’ll need are an OData model a route that defines the OData endpoint and a controller to handle the incoming requests. All of which can be shown below:

Route:

configuration.Routes.MapODataRoute("odata", "odata", GetModel(), new DefaultODataBatchHandler(server));

As you can see from the fragment above, MapODataRoute accepts an ODataBatchHandler as a parameter. In this case, we are using the DefaultODataBatchHandler, but we could also use the UnbufferedODataBatchHandler.

OData model:

private static IEdmModel GetModel()
{
    ODataModelBuilder builder = new ODataConventionModelBuilder();
    builder.ContainerName = "CustomersContext";
    EntitySetConfiguration<Customer> customers = builder.EntitySet<Customer>("Customers");
    return builder.GetEdmModel();
}

Controller:

public class CustomersController : ODataController
{
    CustomersContext context = new CustomersContext();
    [Queryable(PageSize = 10, MaxExpansionDepth = 2)]
    public IHttpActionResult Get()
    {
        return Ok(context.Customers);
    }

    public async Task<IHttpActionResult> Post([FromBody] Customer entity)
    {
        if (entity == null)
        {
            return BadRequest(ModelState);
        }
        context.Customers.Add(entity);
        await context.SaveChangesAsync();
        return Created(entity);
    }

    public async Task<IHttpActionResult> Put([FromODataUri] int key, [FromBody] Customer entity)
    {
        if (entity == null)
        {
            return BadRequest(ModelState);
        }
        else if (key != entity.Id)
        {
            return BadRequest("The key from the url must match the key of the entity in the body");
        }
        var originalCustomer = await context.Customers.FindAsync(key);
        if (originalCustomer == null)
        {
            return NotFound();
        }
        else
        {
            context.Entry(originalCustomer).CurrentValues.SetValues(entity);
            await context.SaveChangesAsync();
        }
        return Updated(entity);
    }

    [AcceptVerbs("PATCH", "MERGE")]
    public async Task<IHttpActionResult> Patch([FromODataUri] int key, Delta<Customer> patch)
    {
        object id;
        if (patch == null)
        {
            return BadRequest("The entity is malformed");
        }
        else if (patch.TryGetPropertyValue("Id", out id) && (int)id != key)
        {
            return BadRequest("The key from the url must match the key of the entity in the body");
        }
        Customer originalEntity = await context.Customers.FindAsync(key);
        if (originalEntity == null)
        {
            return NotFound();
        }
        else
        {
            patch.Patch(originalEntity);
            await context.SaveChangesAsync();
        }
        return Updated(originalEntity);
    }


    public async Task<IHttpActionResult> Delete([FromODataUri]int key)
    {
        Customer entity = await context.Customers.FindAsync(key);
        if (entity == null)
        {
            return NotFound();
        }
        else
        {
            context.Customers.Remove(entity);
            await context.SaveChangesAsync();
            return StatusCode(HttpStatusCode.NoContent);
        }
    }
}

We could use HttpClient to send batch requests to our OData service, but it’s much easier if we make use of the generated client built in through the Add Service Reference dialog in Visual Studio 2013.

To do that, we can just start the service by pressing Ctrl+F5, then we only need to go to the References icon of the project Right Click, Add Service Reference and give it the metadata url of our service which in this case is http://localhost:12345/odata/$metadata.

clip_image002

Now that we have a generated client, we can just start using it to work with the OData endpoint and batch requests. The generated client supports two flows. It can batch a set of queries and return a batch response with the responses to the individual queries, or it can batch a change set of requests that perform modifications on the server.

In order to batch and read a set of queries, we can do the following:

CustomersContext context = new CustomersContext(new Uri("http://localhost:12345/odata/"));
context.Format.UseJson();
//Create the queries
DataServiceRequest<Customer> firstTwoCustomers = new DataServiceRequest<Customer>(new Uri("http://localhost:12345/odata/Customers?$top=2&$orderby=Id"));
DataServiceRequest<Customer> nextTwoCustomers = new DataServiceRequest<Customer>(new Uri("http://localhost:12345/odata/Customers?$skip=2&$top=2&$orderby=Id"));
//Send the queries
DataServiceResponse batchResponse = context.ExecuteBatch(firstTwoCustomers, nextTwoCustomers);
foreach (QueryOperationResponse response in batchResponse)
{
    foreach(Customer c in response.Cast<Customer>())
    {
        //Do something
    }
}

In order to batch a set of changes to the server, we can do the following:

Fixture fixture = new Fixture();
CustomersContext context = new CustomersContext(new Uri("http://localhost:12345/odata/"));
context.Format.UseJson();
IList<Customer> customers = context.Customers.ToList();
Customer customerToAdd = fixture.Create<Customer>();
Customer customerToUpdate = customers.Skip(1).First();
Customer customerToDelete = customers.Skip(2).First();
context.AddToCustomers(customerToAdd);
customerToUpdate.Name = "Peter";
context.UpdateObject(customerToUpdate);
context.DeleteObject(customerToDelete);
DataServiceResponse response = context.SaveChanges(SaveChangesOptions.Batch | SaveChangesOptions.ReplaceOnUpdate);

Finally, the wire format for OData batch requests is the same as the format for Web API batch requests but with a few minor differences. In OData, requests in a batch are divided in two categories. query operations and change sets. query operations, as it name implies, don’t perform any modification on the server, in contrast with change sets, that basically group a set of state changing operations as a unit.

These concepts get reflected on the format of the request and of the response. Basically change sets get encoded as nested mime/multipart parts of the external mime/multipart content that is the batch request. The response follows the same structure, as we can see in the response below:

HTTP/1.1 202 Accepted
Content-Length: 1088
Content-Type: multipart/mixed; boundary=batchresponse_34d95fb9-d930-4443-8b8b-774b467ba1af
Server: Microsoft-HTTPAPI/2.0
DataServiceVersion: 3.0
Date: Fri, 25 Oct 2013 08:01:17 GMT

--batchresponse_34d95fb9-d930-4443-8b8b-774b467ba1af
Content-Type: multipart/mixed; boundary=changesetresponse_7b32b21f-547b-4eb5-a1ca-cd7b28753fec

--changesetresponse_7b32b21f-547b-4eb5-a1ca-cd7b28753fec
Content-Type: application/http
Content-Transfer-Encoding: binary

HTTP/1.1 201 Created
Location: http://localhost:12345/odata/Customers(21)
Content-ID: 11
Content-Type: application/json; odata=minimalmetadata; charset=utf-8
DataServiceVersion: 3.0

{
  "odata.metadata":"http://localhost:12345/odata/$metadata#Customers/@Element","Id":21,"Name":"Name7a88d78d-61e0-4951-8852-6b05be5e913b"
}
--changesetresponse_7b32b21f-547b-4eb5-a1ca-cd7b28753fec
Content-Type: application/http
Content-Transfer-Encoding: binary

HTTP/1.1 204 No Content
Content-ID: 12


--changesetresponse_7b32b21f-547b-4eb5-a1ca-cd7b28753fec
Content-Type: application/http
Content-Transfer-Encoding: binary

HTTP/1.1 204 No Content
Content-ID: 13


--changesetresponse_7b32b21f-547b-4eb5-a1ca-cd7b28753fec--
--batchresponse_34d95fb9-d930-4443-8b8b-774b467ba1af--

With this, I conclude our blog post on the support for batch in Web API and Web API OData. I hope you enjoy it and happy coding :).

0 comments

Discussion is closed.

Feedback usabilla icon