Implementing a Custom Skip Token Handler
Applies To:# OData Web API 8 supported OData Web API v8
Skip tokens are used in server-side paging to keep track of the last record that was sent to the client so that it can generate the next page of results. The skip token is opaque to the client, this means that the server has freedom to decide what the contents of the skip token should be and how to interpret them. ASP.NET Core OData by default generates the skip token by combining the fields and values of the reference record. However, you can override this behavior by providing your own custom logic for generating and processing skip tokens.
This tutorial shows how to customize the skip token logic using a custom SkipTokenHandler
.
You'll learn:
✅ How ASP.NET Core OData uses SkipTokenHandler
to generate and handle next links
✅ How to create a custom SkipTokenHandler
that extends the DefaultSkipTokenHandler
✅ How to configure an ASP.NET Core OData application to use your custom SkipTokenHandler
This tutorial assumes a basic understanding of how to create and run an ASP.NET Core OData 8 application. If you're unfamiliar with ASP.NET Core OData 8, you may want to go through the Getting started tutorial.
To demonstrate how to create a custom SkipTokenHandler
, let's build a sample OData service.
Prerequisites
Visual Studio 2022 with the ASP.NET and web development workload
Packages
Install the Microsoft.AspNetCore.OData 8.x Nuget package:
In the Visual Studio Package Manager Console:
Install-Package Microsoft.AspNetCore.OData
Models
For this exercise, we only create a single model class:
namespace CustomSkipTokenSample
{
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
}
}
EDM model and configuration
The logic for building the Edm model and configuring the OData service is as follows:
// Program.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.OData;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OData.ModelBuilder;
using CustomSkipTokenSample.Models;
var builder = WebApplication.CreateBuilder(args);
var modelBuilder = new ODataConventionModelBuilder();
modelBuilder.EntitySet<Customer>("Customers");
builder.Services.AddControllers().AddOData(
options => options.SkipToken().AddRouteComponents(
"odata",
modelBuilder.GetEdmModel()));
var app = builder.Build();
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapControllers());
app.Run();
The important thing to note here is that we enable the $skiptoken
query option by calling the options.SkipToken()
method. We can also enable all query features using options.EnableQueryFeatures(null)
.
Controllers
We create a simple controller for the Customers
entity set. We also include some dummy data in the controller for simplicity:
using System.Collections.Generic;
using CustomSkipTokenSample.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OData.Query;
using Microsoft.AspNetCore.OData.Routing.Controllers;
public class CustomersController : ODataController
{
private static List<Customer> customers = new List<Customer>
{
new Customer
{
Id = 1,
Name = "Customer 1"
},
new Customer
{
Id = 2,
Name = "Customer 2"
},
new Customer
{
Id = 3,
Name = "Customer 3"
},
new Customer
{
Id = 4,
Name = "Customer 4"
},
new Customer
{
Id = 5,
Name = "Customer 5"
}
};
[EnableQuery(PageSize = 2)]
public ActionResult Get()
{
return customers;
}
}
The important thing to note here is that we applied [EnableQuery]
attribute to our controller's Get
method and set the PageSize
property to 2
. This means that the server will send up to two items per response page for requests made to GET http://localhost:5000/odata/Customers
.
Paging using the default skip token handler
By default, ASP.NET.Core OData uses a default implementation of SkipTokenHandler
named DefaultSkipTokenHandler
. This generates skip tokens by combining keys and values of the last resource in the response.
Let's execute the following request and inspect the response:
GET http://localhost:5000/odata/Customers
Response:
{
"@odata.context": "http://localhost:5000/odata/$metadata#Customers",
"value": [
{
"Id": 1,
"Name": "Customer 1"
},
{
"Id": 2,
"Name": "Customer 2"
}
],
"@odata.nextLink": "http://localhost:5000/odata/Customers?$skiptoken=Id-2"
}
Notice the skip token Id-2
, this was generated by the DefaultSkipTokenHandler
based on the last customer in the resource. It combines the field Id
and value 2
. This assumes that the Id
field has unique value for each customer, it also assumes the collection has a natural order.
If you order the results by a different field, this will also be encoded in the skip token.
For example, let's order the results by `Name``, in descending order:
GET http://localhost:5000/odata/Customers?$orderby=Name desc
Note
Ensure you enable the $orderby
query option in your service configuration using options.OrderBy()
or options.EnableQueryFeatures(null)
Response:
{
"@odata.context": "http://localhost:5000/odata/$metadata#Customers",
"value": [
{
"Id": 5,
"Name": "Customer 5"
},
{
"Id": 4,
"Name": "Customer 4"
}
],
"@odata.nextLink": "http://localhost:5000/odata/Customers?$orderby=Name%20desc$skiptoken=Name-%27Customer%205%27,Id-4"
}
In this case, the skip token encodes both the Name
and Id
of the last customer in the response. It will use the name to determine which customer should appear first in the next page, and if there are customers with the same name, it will use the ID as a tie-breaker.
Important
You should not rely on the DefaultSkipTokenHandler
generating the skip token in a specific format. We make no guarantee that this format may not change between library versions.
If you wish to change how skip tokens are generated or how they're processed, you can replace the DefaultSkipTokenHandler
with your own custom implementation.
Creating a custom skip token handler
The skip token handler generates the next link in an OData response when using server-driven paging. It's also responsible for applying the $skiptoken
to the results. SkipTokenHandler
is an abstract class that defines the following methods to be implemented by child classes:
GenerateNextPageLink
: generates theUri
that's used as the next link in the response.ApplyTo
: applies the$skiptoken
to anIQueryable
collection. This should transform the query to perform the paging. It has two overloads, a genericApplyTo<T>
and non-genericApplyTo
to handle the genericIQueryable<T>
and non-genericIQueryable
respectively.
As an example, let's say we want to represent the skip token using base-64 encoding. This may be useful if we want to "hide" the implementation details and encoding from clients. This is a good use case for a custom skip token handler. We can create our handler from scratch by extending SkipTokenHandler
directly, or we could extend DefaultSkipTokenHandler
. The latter is usually more convenient as it allows us to re-use the default logic. That's the option we'll go with for this sample service.
Creating and registering the custom handler
Let's create a class called CustomSkipTokenHandler
that extends DefaultSkipTokenHandler
. You can put the class anywhere in the project where it makes sense to you.
using Microsoft.AspNetCore.OData.Query;
public class CustomSkipTokenHandler : DefaultSkipTokenHandler
{
}
To configure our service to use the CustomSkipTokenHandler
, we have to inject it into the service container. To do this, we'll make use of an AddRouteComponents
overload that takes an action that configures required services.
Let's rewrite our service configuration to look as follows:
options.SkipToken().AddRouteComponents(
routePrefix: "odata",
model: modelBuilder.GetEdmModel(),
configureServices: services => services.AddSingleton<SkipTokenHandler, CustomSkipTokenHandler>())
This tells the service to use CustomSkipTokenHandler
where a SkipTokenHandler
is required.
With this configuration, requests that require a $skiptoken
will go through our CustomSkipTokenHandler
. But this handler behaves exactly like the default skip token handler because we have not overridden the default implementation. Let's start by base64-encoding the skip token when generating the next link.
Generating custom skip tokens
We'll need to override the GenerateNextPageLink
method. This method returns a Uri
that will be used as the next link. The URI should contain the $skiptoken
if applicable. Since we only want to base64-encode the default skip token, we can leverage the GenerateNextPageLink
of the parent DefaultSkipTokenHandler
class to generate the URI, then extract the skip token and encode it.
We can implement that with the following code:
public override Uri GenerateNextPageLink(
Uri baseUri,
int pageSize,
object instance,
ODataSerializerContext context)
{
var uri = base.GenerateNextPageLink(baseUri, pageSize, instance, context);
if (uri == null)
{
return null;
}
var queryOptions = HttpUtility.ParseQueryString(uri.Query);
string skipToken = queryOptions.Get("$skiptoken");
if (skipToken == null)
{
return uri;
}
string base64SkipToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(skipToken));
queryOptions.Set("$skiptoken", base64SkipToken);
string encodedQuery = queryOptions.ToString();
UriBuilder builder = new UriBuilder(uri);
builder.Query = encodedQuery;
return builder.Uri;
}
Now let's explain what this code does.
The GenerateNextPageLink
takes the following arguments:
baseUri
: This is the request URL. This is the URL we want to add a$skiptoken
to. It's possible that it contains the$skiptoken
from the previous page.pageSize
: The maximum number of items per page. This is the same value that was passed to thePageSize
property of theEnableQuery
attribute.instance
: The entity that is used to generate the skip token. It corresponds to the last item in the previous page.context
: Allows you to access information about the current serialization pipeline, such as the request instance, the EDM model, etc.
The method first calls the GenerateNextPageLink
method of the parent class (DefaultSkipTokenHandler
) to generate the next link. The method returns null
if we're on the last page.
var uri = base.GenerateNextPageLink(baseUri, pageSize, instance, context);
if (uri == null)
{
return null;
}
The next block of code extracts the value of the $skiptoken
query option from the generated URI. We use the HttpUtility
class from the System.Web
namespace to parse the query options.
var queryOptions = HttpUtility.ParseQueryString(uri.Query);
string skipToken = queryOptions.Get("$skiptoken");
if (skipToken == null)
{
return uri;
}
The next block of code generates a base64-encoded version of the skip token to replace the original value.
string base64SkipToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(skipToken));
queryOptions.Set("$skiptoken", base64SkipToken);
Finally, we create a new URI by replacing the original query options with the updated version:
string encodedQuery = queryOptions.ToString();
UriBuilder builder = new UriBuilder(uri);
builder.Query = encodedQuery;
return builder.Uri;
Testing the customized next link
Let's make the following request to see the customized skip tokens in action:
GET http://localhost:5000/odata/Customers
Response:
**Response**:
```json
{
"@odata.context": "http://localhost:5000/odata/$metadata#Customers",
"value": [
{
"Id": 1,
"Name": "Customer 1"
},
{
"Id": 2,
"Name": "Customer 2"
}
],
"@odata.nextLink": "http://localhost:5000/odata/Customers?$skiptoken=SWQtMg%3d%3d"
}
The $skiptoken
is now SWQtMg%3d%3d
instead of Id-2
.
Note
The actual base64 encoding of Id-2
is SWQtMg==
, but the final form eventually gets URL encoded to SWQtMg%3d%3d
.
Let's follow the next link to fetch the next page:
GET http://localhost:5000/odata/Customers?$skiptoken=SWQtMg%3d%3d
This returns an error response like the following:
{
"error": {
"code": "",
"message": "The query specified in the URI is not valid. Unable to parse the skiptoken value 'SWQtMg=='. Skiptoken value should always be server generated.",
}
}
The reason we get the error is because our custom skip token handler was not able to process the base64-encoded skip token. The skip token is processed in the ApplyTo
method. At the moment, we are still using the ApplyTo
method in the DefaultSkipTokenHandler
class because we have not implemented our own. The base ApplyTo
expects the skip token to be in a specific format, it cannot parse the base64-encoded value.
Processing custom skip tokens
To fix the error in the previous section, we have to decode the skip token first then pass the decoded version to the default skip token handler's ApplyTo
method.
We can achieve that by overriding the following methods in our CustomSkipTokenHandler
class:
public override IQueryable<T> ApplyTo<T>(IQueryable<T> query, SkipTokenQueryOption skipTokenQueryOption, ODataQuerySettings querySettings, ODataQueryOptions queryOptions)
{
var decodedSkipTokenQueryOption = DecodeSkipToken(skipTokenQueryOption);
return base.ApplyTo(query, decodedSkipTokenQueryOption, querySettings, queryOptions);
}
public override IQueryable ApplyTo(IQueryable query, SkipTokenQueryOption skipTokenQueryOption, ODataQuerySettings querySettings, ODataQueryOptions queryOptions)
{
var decodedSkipTokenQueryOption = DecodeSkipToken(skipTokenQueryOption);
return base.ApplyTo(query, decodedSkipTokenQueryOption, querySettings, queryOptions);
}
private static SkipTokenQueryOption DecodeSkipToken(SkipTokenQueryOption skipTokenQueryOption)
{
string encodedSkipToken = skipTokenQueryOption.RawValue;
string decodedSkipToken = Encoding.UTF8.GetString(Convert.FromBase64String(encodedSkipToken));
return new SkipTokenQueryOption(decodedSkipToken, skipTokenQueryOption.Context);
}
We override both the non-generic ApplyTo
and generic ApplyTo<T>
methods with our CustomSkipTokenHandler
. Both methods accept the following parameters:
query
: TheIQueryable
orIQueryable<T>
that represents the query that will fetch the results.skipTokenQueryOption
: Contains information about the$skiptoken
query option.querySettings
: The query settings to use while applying the$skiptoken
(e.g. the page size).queryOptions
: Contains information about the other query options in the request.
The ApplyTo
method should transform the query
and return a new query that implements the paging logic, i.e. ensures that we only return records from where we left off in the last page, and that we do not exceed the page size. Fortunately, the default skip token handler already implements this logic.
Our implementation simply decodes the query option and lets the base class take care of the rest:
var decodedSkipTokenQueryOption = DecodeSkipToken(skipTokenQueryOption);
return base.ApplyTo(query, decodedSkipTokenQueryOption, querySettings, queryOptions);
To decode the skip token, we created a private helper method DecodeSkipToken
. This method extracts the value of the $skiptoken
, decodes it from its base64 encoding then creates a new SkipTokenQueryOption
instance with the decoded value:
string encodedSkipToken = skipTokenQueryOption.RawValue;
string decodedSkipToken = Encoding.UTF8.GetString(Convert.FromBase64String(encodedSkipToken));
return new SkipTokenQueryOption(decodedSkipToken, skipTokenQueryOption.Context);
Now let's try to fetch the second page again:
GET http://localhost:5000/odata/Customers?$skiptoken=SWQtMg%3d%3d
Response:
{
"@odata.context": "http://localhost:5000/odata/$metadata#Customers",
"value": [
{
"Id": 3,
"Name": "Customer 3"
},
{
"Id": 4,
"Name": "Customer 4"
}
],
"@odata.nextLink": "http://localhost:5000/odata/Customers?$skiptoken=SWQtNA%3d%3d"
}
This time we get a successful response!
Complete CustomSkipTokenHandler
source code
Here's the full source code for our CustomSkipTokenHandler
:
using System;
using System.Linq;
using System.Text;
using System.Web;
using Microsoft.AspNetCore.OData.Formatter.Serialization;
using Microsoft.AspNetCore.OData.Query;
namespace ODataRoutingSample
{
public class CustomSkipTokenHandler : DefaultSkipTokenHandler
{
public override IQueryable<T> ApplyTo<T>(IQueryable<T> query, SkipTokenQueryOption skipTokenQueryOption, ODataQuerySettings querySettings, ODataQueryOptions queryOptions)
{
var decodedSkipTokenQueryOption = DecodeSkipToken(skipTokenQueryOption);
return base.ApplyTo(query, decodedSkipTokenQueryOption, querySettings, queryOptions);
}
public override IQueryable ApplyTo(IQueryable query, SkipTokenQueryOption skipTokenQueryOption, ODataQuerySettings querySettings, ODataQueryOptions queryOptions)
{
var decodedSkipTokenQueryOption = DecodeSkipToken(skipTokenQueryOption);
return base.ApplyTo(query, decodedSkipTokenQueryOption, querySettings, queryOptions);
}
public override Uri GenerateNextPageLink(
Uri baseUri,
int pageSize,
object instance,
ODataSerializerContext context)
{
var uri = base.GenerateNextPageLink(baseUri, pageSize, instance, context);
if (uri == null)
{
return null;
}
var queryOptions = HttpUtility.ParseQueryString(uri.Query);
string skipToken = queryOptions.Get("$skiptoken");
if (skipToken == null)
{
return uri;
}
string base64SkipToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(skipToken));
queryOptions.Set("$skiptoken", base64SkipToken);
string encodedQuery = queryOptions.ToString();
UriBuilder builder = new UriBuilder(uri);
builder.Query = encodedQuery;
return builder.Uri;
}
private static SkipTokenQueryOption DecodeSkipToken(SkipTokenQueryOption skipTokenQueryOption)
{
string encodedSkipToken = skipTokenQueryOption.RawValue;
string decodedSkipToken = Encoding.UTF8.GetString(Convert.FromBase64String(encodedSkipToken));
return new SkipTokenQueryOption(decodedSkipToken, skipTokenQueryOption.Context);
}
}
}