It has been over a year since I last wrote about JSON:API, since then the team behind JSON:API has published version 1.1 of the JSON:API specification. I would like to continue my journey of documenting JOSN:API in .NET by introducing a really cool feature to my Chinook JSON:API project, filtering.
The first thing to know about filtering in JSON:API is that the spec itself is agnostic to any filtering strategies. Meaning it is up to you to define how filtering should be handled by your API. In my opinion, this has always been a drawback of the JSON:API spec, I believe in that it would have been a better choice for the spec if it had decided on a filtering strategy, but that is discussion for another day. While the spec does not favor any filtering strategy it does have some recommendations.
The spec recommends using the LHS bracket syntax to denote which resource the filtering should be applied to. For example, imagine you are dealing with a post resource and each post resource can expose a relationship to an author resource collection, that is to say, each post has a 1 to many relationship with with the authors resource.
As a client of the API if you may want to find out which post has an author with a firstName of “Dan”, typically what you see in most APIs is that you would first need to filter the authors resource to only those that have “Dan” as a first name, then once you have those resources, you can filters the posts resource to only post that have a matching author’s resource. This type of filtering is not ideal even though it is how many REST APIs out in the wild are implemented, this is the well-know problem of over-fetching and under-fetching, a selling point of GraphQL.
We can do better, by properly defining the relationships between our resources a client application should be able to make the following request to handle the scenario I just described.
Note the usage of LSH brackets and ODATA syntax, we’ll talk about that later on this post. If the request above is valid, then it should in theory yield the following JSON:API response.
In a single request, the client has requested that the API should get all posts where the published field is true and to include the all the related authors, the request also states that out of that list of posts, the API should only return posts where the author is name Dan. Using a nested filtering allows a client of the API to overcome the over-fetching and under-fetching problems that many REST APIs have.
So, how can we implement this in feature in .NET? Let’s take a look.
The first step in implementing filtering will be to take the filter query parameter from the incoming HTTP request URL and tokenize them. You have the option to implement a custom tokenizer, see my Parsing in C# blog post, or use one of the many awesome parsing libraries that exit in .NET. Personally, I have always relied on SuperPower as it easily allows you define a tokenizer and parsers. Once we have a tokenizer and a parser, the next step is to build an Abstract Syntax Tree the generate runtime expression that can be then be given to an ORM system like EF Core or Dapper.
Let’s review the Chinook project I have been building for the last two years, as I mentioned before, it is a level 3 REST API that implements the JSON:API specification. The API exposes a number of resources, one of them, the invoices resource, has a one to one relationship to the customer resource, a single customer resource is represented with the following JSON payload.
Back to the Chinook project, imagine the following scenario, a client of the Chinook API would like to query the API to find all invoices where the billing country is Germany and the customer, which is a related resource of invoices has a first name equal to Leonie, as it stands, a client of the Chinook API today could do it with the following actions.
- Navigating to the invoice resource collection, pulling all records in-memory, about 412 in total.
- Loop through the invoice collection to find any invoice where the billing country is Germany.
- For any invoice where the billing country is Germany, navigate to the related customer.
- Check if the customer’s first name is Leonie.
What I have just described is totally inefficient, as I mention before this is a well-known problem, Over Fetching And Under Fetching and it often refer as the main reason to use GraphQL.
In order to provide better usability and developer experience I will introduce resource filtering to the Chinook project, the invoice resource collection will now allow a client to specify a filtering criteria. This filtering criteria can work against the invoice resource collection as well as any related resources but for the purposes of this demo I will only add filtering support for the related customer resource.
Adding filtering to an API means that you will need to come up with a filtering language, I am going to stick to OData, why? Simple, it is well known standard that many developers are already familiar with and comes with well defined operators, you can of course come up with your own if desired or follow the ones recommended by the JSON:API community
Let’s take a look at the operators offered by OData as defined in Built-in Filter Operations
The filter operators offered by OData are as follows.
|eq||Equal||Address/City eq ‘Redmond’|
|ne||Not equal||Address/City ne ‘London’|
|gt||Greater than||Price gt 20|
|ge||Greater than or equal||Price ge 10|
|lt||Less than||Price lt 20|
|le||Less than or equal||Price le 100|
|has||Has flags||Style has Sales.Color’Yellow'|
|in||Is a member of||Address/City in (‘Redmond’, ‘London’)|
|and||Logical and||Price le 200 and Price gt 3.5|
|or||Logical or||Price le 3.5 or Price gt 200|
|not||Logical negation||not endswith(Description,‘milk’)|
|add||Addition||Price add 5 gt 10|
|sub||Subtraction||Price sub 5 gt 10|
|mul||Multiplication||Price mul 2 gt 2000|
|div||Division||Price div 2 gt 4|
|divby||Decimal Division||Price divby 2 gt 3.5|
|mod||Modulo||Price mod 2 eq 0|
|( )||Precedence grouping||(Price sub 5) gt 10|
OData also offers Built-in Query Functions but those functions are beyond the scope of this blog post.
In order for the a client of the Chinook API to find an invoice where the billing country is Germany and the related customer’s first name is Leonie the client would have to use the following query string.
Let’s break down the request above, /invoices is the resource collection we are dealing with, then we have the query string parameters, the first one is the JSON:API keyword filter and it is used by the client to inform the server that the resource should be filtered. The first LHS bracket with invoices is used to inform the server that filtering will be done against the resource invoices, remember JSON:API has compound documents, a single JSON:API document can have multiple resources. The next part, =billingCountry eq ‘Germany’ is used to inform the server that the filter should be against the property billingCountry on the source invoices, with OData syntax, eq as mention above being used as the equals operators, since invoice billingCountry is a string type we use single quotes to specify the value. The second filter is against the customers resource, in this filter the client tells the server to use the firstName property to and to filter the customer resource where that name is Leonie.
Meaning the request above, when executed correctly by the server, will only return invoices where the customer’s first name is Leonie and the invoice was in Germany.
A quick aside, JSON is case sensitive, you can encounter APIs in different case formats, i.e. snake vs camel, therefore, when doing filtering, take into consideration the casing being used on the field you plan to filter one.
Now that I know what the HTTP request will look like let’s switch to the Chinook API and the code required to support filtering.
First thing I am going to do is to install Superpower on the Chinook Core Project by running the following dotnet command.
Now that Superpower is installed I will modify the existing UriKeyWords class that was added in JSON:API - Pagination Links.
Here is what the class looks like after adding the OData operators.
Note the addition of some of the OData operators we previously defined, I kept the list small since we don’t need to support all OData operators at the moment. Next, for each operator type that you plan to support you will need to add a corresponding token, represent as an enum value. For example, for the “eq” operator types which falls under the equality operator you would have the following enum.
Adding the rest of the support operators yields the following ODataTokens class.
Up next, we need to build our OData parsers with Superpower, let’s gets started with the most basic, an OData string. As shown before, an OData request may look like the following HTTP GET request.
Note the usage of ’ to denote when a string starts and ends, this is what the we need to parse and Superpower allows us to build a parser with LINQ, the following LINQ expression can be used by Superpower to parse an OData string.
Here you start to see the beauty of parser combinators like Superpower, you can compose parsers out of other parsers. In the code above, the ODataString parser is composed by the Content parser and the Characters parser.
Now that I have the tokens and parser I need to create the Tokenizer. For the tokenizer we can use the built in TokenizerBuilder provided by Superpower, along with their helpers functions, like Match, Ignore, Build and so on. Below is how the Tokenizer looks so far, note the usage of the key words defined in ODataParser and the Enum ODataTokens.
Something worth mentioning here, Superpower has a good support for error handling, when an error is encountered Superpower will report that error and you can set how to handle it, I won’t cover error handling of the tokenizer on this blog post as I feel it is beyond the scope of this post, but essentially what you would do is take the error reported by Superpower and convert it into an Errors Document.
Next, I will add a Tokenizer Builder, the builder will take expose method that take in a generic type, TObject, inspect the properties in this generic type and tokenize them along with the OData tokenizer above. Here is the tokenizer builder.
With the code above, when I call the method TokenizeObjectProperties and use Invoices as the generic type, all the properties that currently exist in the Invoices class will get tokenized.
Taking an incoming query string, tokenize it, parsing it, building an AST, then finally getting an expression to pass to an ORM like EF Core or Dapper takes a look of work, whenever I have face this before I have relied on the builder pattern, and since what I am building is rather complex structure, the builder pattern will allow me delegate different parts of the process to small parts of the builder.
Building a DSL
The one thing I want to ensure is that on top of the builder pattern there needs to be a DSL to ensure proper usage of the builder pattern. For example, I want to expose the following DSL.
In the DSL above, ordering of operations can be controlled, I want to shield the any consumers from incorrectly tokenizing, parsing, building the AST and finally the expression, a DSL works create for this as the fluent style API controls the way the final result can be assembled. How to create a fluent interface in C# by Scott Lilly can probably explain the benefits of this type of code better than I can, if you can, I do recommend reading it.
The DSL I built has support for pagination, ordering, but for now we are only interesting in filtering. As you can see there is a AddFilter method that accepts a generic type, the Invoice class in this case, this filtering method is responsible for a good chuck of the work I have described so far. Let’s take a deeper look at this method.
The first part of the AddFilter method in the ResourceQueryBuilder class is to call the tokenizer as shown in the code above to tokenize the properties on the resource, the next step is to call ParseFilterQueryString in the QueryParameterService class, this class was introduce in JSON:API - Pagination Links as UriQueryParametersReader I just consolidated the reader and writer into a service. The new method, ParseFilterQueryString, was added in to the class due to a limitation in JsonApiFramework, which powers the Chinook project, JsonApiFramework was never built to handle multiple query filters.
With the code above the API can now handle multiple query parameters being used in the URL query string. Back to the ResourceQueryBuilder class, the next step is to get the resource type that was used to call AddFilter. This is essential as we need to know which service model we need to use when building our expressions.
The next part of the AddFilter method is looking at the parsed query strings values return from ParseFilterQueryString to see if the current resource matches up with a filter used in the URL query string. If no matched then we add a default expression to a global dictionary that is keeping track of all the filters being applied.
Notice the usage of the PredicateBuilder class, this is a helper class that allows us to work with expressions, the new method creates a starting expression that evaluates to true, essentially it creates the following code.
As mentioned in JSON:API - Pagination Links, the code above is great because the Linq Provide will see this code and do nothing, in other words, this is a good default value to have when the client doesn’t specify any filters.
The final part of the AddFilter method to loop through each node that was tokenized, then to use the visitor patter to visit each node grabbing the value and composing an expression, here is the code.
In the code above we call the TryTokenize method in the tokenizer to tokenize the query parameter string, if successful, we loop through the list then begin to visit each known using the Visitor Pattern. After each node is visited an expression factory is responsible for putting together a complete C# expression which is returned by the method GetFilterExpression.
Now that we have an expression that can be given to EF Core all that I need to do is modify the InvoiceResource class and the GetInvoiceResourceCollectionHandler to accept the output of the ResourceQueryBuilder which is a specification.
Here is the structure of the specification that is generated by the ResourceQueryBuilder class.
And here is the code required to add filtering support to the Invoice resource.
As I said, a DSL is super useful here, ideally, this is the only code developer of the API would use to add filtering, and in the future pagination, includes and ordering.
Back to the modified GetInvoiceResourceCollectionHandler, the code below is all that is needed to retrieve the filters from the ResoureQuerySpecification class.
Let’s break down the code, the GetInvoiceResourceCollectionCommand exposes now the specification we need to retrieve the filter expression, here the GetFilter is used to retrieve the generated filter expression, as mentioned before, if the filter doesn’t exist we default to the expression created by the PredicateBuilder class. Once we have the expression, a query is created for each resource we want to support in this case there is query for the Invoice and Customer resource. The two queries are then combined in a joining query, the joining query is executed, here EF Core will take the expression and translate it to the proper SQL code.
Let’s run a few examples. If I navigate to the invoices resource collection without any query parameters I get back 412 records in a single call, remember we haven’t added pagination.
The database was queried using the following SQL query.
So far so good, let’s run another test.
The API returns now only 35 records and not the usual 412, so some type of filtering appears to be happening. Let’s confirm by looking at the SQL generated.
That looks right.
One more test, filtering a related resource which is what started this blog post.
Returns only 7 records, so now we have further filtering, let’s look at the generated SQL.
Oh yeah, that looks right, this is perfect, filtering appears to be working as expected and now our clients can query not just top level resource but also related resource in a single HTTP request.
Here are a list of resource related to everything that I just talked about, these resource will come in handy if run into any issues.