Example application
This example combines capabilities for filters, sorting and pagination in one Invirt application. The setup is a fairly basic screen to review online orders, and has the following requirements:
- The user can filter orders by their total value: less than £1000, greater than £1000, or "All" (no filtering applied). Only one of the three can be applied as they are mutually exclusive. By default, all orders are listed ("All").
- The user can filter orders by their status (Dispatched, Delivered, etc). Any or all of these can be
selected for filtering and the page will present the combined result set (in essence an
OR
). - Filtering will apply an
AND
between the total order value criteria and the order status criteria, i.e. the orders returned must match the selected total value, as well as matching any selected order statuses. - The user can sort the result set ascending and descending by clicking the listing column headers (Created at, Order status, Total value).
- The results are paginated (page size 10) and a pagination control allows the user to navigate the complete result set.
Query parameters
The query parameter for filtering with the above criteria is defined as follows:
- A
total-order-value
query parameter specifies the filtering to be applied for the total order valuetotal-order-value=less-than-1000
would list orders with a total value less than £1000total-order-value=more-than-1000
would list orders with a total value greater than £1000- A missing
total-order-value
parameters indicates no filter should be applied for the total order value
- A
status
query parameter specifies the statuses of the orders to return. This can be combined by passing the parameter multiple times:?status=DELIVERED&status=IN_TRANSIT
- A
sort
query parameter specifies the sort order.sort=createdAt:ASC|DESC
- sort orders by their creation timestampsort=status:ASC|DESC
- sort orders by their current statussort=totalMinorUnit:ASC|DESC
- sort orders by their current total value in minor currency units (pence for GBP, cents for USD, etc, a convenience for modeling this example application more than anything else)- When the
sort
parameter is missing, the sort defaults tocreatedAt:DESC
- Pagination information is passed using the
from
andsize
query parameters:&from=0&size=10
Core components definition
An OrderService
exposes a function that allows the application to search orders based on a filtering criteria,
a sort order, and a pagination constraint. This function returns a RecordsPage.
class OrderService {
fun searchOrders(filter: DataFilter?, sort: Sort, page: Page): RecordsPage<Order> {
// Search in an orders repository and return a RecordsPage<Order>
}
}
An OrderHandler
maps the default (/
) route to the page in the screenshot above.
object OrderHandler {
operator fun invoke(orderService: OrderService): RoutingHttpHandler = routes(
"/" GET { request ->
// Get filters/sort/pagination from request parameters
// and call OrderService.searchOrders()
}
)
}
Handler implementation
The handler is responsible for reading the query parameters defined above and creating the relevant
objects to be passed to the OrderService
. Starting with the easier ones, we can use Invirt's built-in
extensions to read sort and
page information from the request's query parameters.
val sort = request.sort() ?: Sort.desc(Order::createdAt.name)
val page = request.page()
Filtering logic
For filtering we need to first define the handling of the filter query parameters total-order-value
and status
based on the logic described earlier. For this, we will use Invirt's built-in queryValuesFilter
.
val ordersFilter = queryValuesFilter {
Query.optional("total-order-value").filter { value ->
when (value) {
"less-than-1000" -> Order::totalMinorUnit.lt(1000_00)
"more-than-1000" -> Order::totalMinorUnit.gt(1000_00)
else -> null
}
}
Query.enum<OrderStatus>().multi.optional("status").or { status ->
Order::status.eq(status)
}
}
Several things to unpack here, so let's start with queryValuesFilter()
itself.
This function builds a QueryValuesFilter
object which stores the configuration
(lambdas) for how query parameters are converted to DataFilter
objects at runtime. This object can then be applied to a Request
to produce the final
DataFilter
, or null
if none of the expected parameters are present.
val ordersFilter = queryValuesFilter {...}
"/" GET { request ->
val filter: DataFilter? = ordersFilter(request)
...
}
The constructs inside the lambda will use http4k's built-in parameter lensing
to start defining an expression for processing a parameter, hence the Query.
chained calls.
By default, queryValuesFilter
builds an AND
compound filter from the individual parameter filters defined in its lambda.
This can be overridden by passing DataFilter.Compound.Operator.OR
.
queryValuesFilter { ... } // Defaults to DataFilter.Compound.Operator.AND
queryValuesFilter(DataFilter.Compound.Operator.AND) { ... }
queryValuesFilter(DataFilter.Compound.Operator.OR) { ... }
Handling total-order-value
total-order-value
is handled as a query parameter that is only passed (and handled) once, i.e. passing the query
below will cause the second value to be ignored, which is what we want in this case.
&total-order-value=less-than-1000&total-order-value=more-than-1000
To convert total-order-value
to a DataFilter
we start with http4k built-in Query.optional("total-order-value")
and then call Invirt's .filter
to specify the lambda returning a DataFilter
for the current value of this parameter.
In our case, we have a when
clause defining explicitly what filter is produced for each value, or null
if
the passed value doesn't match any of them.
Handling status
Http4k's built-ins lensing is used again to convert the parameter to OrderStatus
enum values and specify that it can
appear multiple times in the query (.multi
).
Query.enum<OrderStatus>().multi.optional("status")
This then allows us to call an Invirt extension (.or
in this case) to specify
- How the individual
DataFilters
must be combined when there are multiple values: OR or AND (.or
vs.and
). For our requirements we need an OR for the order status filter. - How each of the individual
OrderStatus
convert to aDataFilter
, a simple equals filter, in this case, viaOrder::status.eq(status)
Response and view wiring
To render the orders we will use ViewResponse, which will store
the RecordsPage<Order>
returned by OrderService.searchOrders()
, to be consumed directly by the template.
We also want to provide the OrderStatus
enum values to render the possible options for the order status
filter (see image below), so we don't hardcode these in the template which can cause them to drift from the internal enum definition.
The code for this response component would then be fairly straightforward.
private class ListOrdersResponse(
val ordersPage: RecordsPage<Order>
) : ViewResponse("list-orders") {
val orderStatusValues = OrderStatus.entries
}
Complete handler code
object OrderHandler {
operator fun invoke(orderService: OrderService): RoutingHttpHandler = routes(
"/" GET { request ->
val filter = filter(request)
val sort = request.sort() ?: Sort.desc(Order::createdAt.name)
val page = request.page()
val ordersPage = orderService.searchOrders(filter, sort, page)
ListOrdersResponse(ordersPage).ok()
}
)
}
private val filter = queryValuesFilter {
Query.optional("total-order-value").filter { value ->
when (value) {
"less-than-1000" -> Order::totalMinorUnit.lt(1000_00)
"more-than-1000" -> Order::totalMinorUnit.gt(1000_00)
else -> null
}
}
Query.enum<OrderStatus>().multi.optional("status").or { status ->
Order::status.eq(status)
}
}
private class ListOrdersResponse(val ordersPage: RecordsPage<Order>) : ViewResponse("list-orders") {
val orderStatusValues = OrderStatus.entries
}
OrderService
For the OrderService we've used a mock implementation using a local list of random orders, which we then sort, filter and paginate in memory. This is done to avoid wiring a persistence layer for this example, and to demonstrate the concept and the separation of concerns in its raw form. Because in-memory query processing isn't something that any application would normally do, we won't go into the details of this, but you can check out the complete code for it here.