fastify-query
v0.6.0
Published
Fastify plugin for QUERY routes with JSONPath and JSON Pointer filtering
Maintainers
Readme
fastify-query
A Fastify plugin that automatically adds QUERY method handlers with JSONPath and JSON Pointer response filtering.
It also provides application/json and x-www-form-urlencoded request body parsing which is translated to request.query object and transparently replaces querystrings.
Its main purpose is to be a drop-in solution for servers that already serve large responses to GET requests and modern clients who only need specific parts of these responses.
Clients can send a query expression in the request body (using Content-Type: application/jsonpath or application/jsonpointer) and receive a filtered version of the original response payload.
Where/why is it needed?
Any API where the server provides arrays and objects with a lot of data, while the client has specific purpose and needs only specific entries. Which is almost any API, depending on how you define "a lot".
For example, take a look at GitHub's REST API endpoints for repositories. Scroll through example responses and responses schemas. Have you ever needed all of these? And how often do you need less than 5 scalar fields?
The gh utility has builtin --jq option for a reason. However, just like | jq pipelines or any other further processing, this is purely client-side: the network traffic is still bloated with unused data, server still serializes whole thing and client still deserializes whole thing.
In the typical usecase, the server doesn't care too much. It runs on a cluster of some Xeon Diamond 9000, has terabytes of RAM, multiple layers of cache, and gigantic uplinks. But for clients, it's noticeable amount of wasted memory and network resources.
Before HTTP QUERY became a thing, there was no well-standardised, flexible way to achieve this. Nowadays, hopefully this package shows how easy and convenient can it be.
If you are implementing QUERY-based filtering on your server to process it immediately and lower resource consumption by retrieving only specific data, this package can be useful as well: use it to enable QUERY support globally, and then gradually populate excludeRequest option with endpoints that have your internal business logic updated.
Installation
npm i fastify-queryUsage
Response filtering
// server
import Fastify from 'fastify';
import fastifyQuery from 'fastify-query';
const app = Fastify();
await app.register(fastifyQuery); // this does the trick!
app.get('/users', async () => {
return [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' },
{ id: 3, name: 'Carol', role: 'user' },
];
});
await app.listen({ port: 3000 });# request example (JSONPath)
curl -X QUERY -H 'Content-Type: application/jsonpath' -d '$[[email protected]=="user"]' http://localhost:3000/users// response (prettified)
[
{ "id": 2, "name": "Bob", "role": "user" },
{ "id": 3, "name": "Carol", "role": "user" }
]# request example (JSON Pointer)
curl -X QUERY -H 'Content-Type: application/jsonpointer' -d '/1/name' http://localhost:3000/users// response
"Bob"Request parsing
// server
import Fastify from 'fastify';
import fastifyQuery from 'fastify-query';
const app = Fastify();
await app.register(fastifyQuery); // again, this does the trick!
app.get('/users', async (request) => {
const { role, limit } = request.query;
return [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' },
{ id: 3, name: 'Carol', role: 'user' },
].filter((user) => user.role === role).slice(0, limit);
});# old request example (GET, using querystring)
curl -X GET http://localhost:3000/users?role=user&limit=10
# new request example (QUERY, using request body)
curl -X QUERY -H 'Content-Type: application/x-www-form-urlencoded' -d 'role=user&limit=10' http://localhost:3000/users
# new request example (QUERY, using json payload)
curl -X QUERY -H 'Content-Type: application/json' -d '{"role":"user","limit":10}' http://localhost:3000/usersOptions
| Option | Type | Default | Description |
|------------------------|------------------------------------------------------------------------|----------------------------|------------------------------------------------------------------------------|
| addQueryTypes | Record<string, QueryType> | {} | Additional query types to merge on top of defaults (or overrideQueryTypes) |
| advertiseAcceptQuery | string[] | ['GET', 'HEAD', 'QUERY'] | HTTP methods on which the Accept-Query header should be set |
| baseMethod | string | 'GET' | Original method implementing server logic for the route |
| decorateReply | boolean | false | Whether to decorate reply with sendQuery method |
| excludeReply | (reply) => boolean | () => false | Whether to skip applying the query filter to the response payload |
| excludeRequest | (request) => boolean | () => false | Whether to skip parsing the request body into request.query |
| excludeRoute | boolean \| string \| RegExp \| string[] \| Set \| (route) => boolean | false | Excludes which routes receive a QUERY variant |
| exposeQueryRoutes | boolean | true | If false, completely disables creating QUERY routes |
| filterReply | (reply) => boolean | status code is 2xx | Whether to apply the query filter to the response payload |
| filterRequest | (request) => boolean | () => true | Whether to parse the request body into request.query |
| filterRoute | boolean \| string \| RegExp \| string[] \| Set \| (route) => boolean | true | Filters which routes receive a QUERY variant |
| overrideQueryTypes | Record<string, QueryType> | defaultQueryTypes | Replaces the default query types entirely |
| strict | boolean | null | null | Whether to throw on unknown Content-Type or return unfiltered response |
Where QueryType is an object that may contain:
queryReply?: (document, query) => value- used to filter/transform the response payloadparseRequest?: (body) => value- used to parse the request body intorequest.query
Default query types
The plugin exports defaultQueryTypes:
import { defaultQueryTypes } from 'fastify-query';
// {
// 'application/json': {
// parseRequest: parseIdentity // body => body (ContentTypeParser pre-parses it)
// },
// 'application/jsonpath': {
// queryReply: queryJsonpath // `query` from jsonpath-rfc9535
// },
// 'application/jsonpointer': {
// queryReply: queryJsonpointer // `get` from jsonpointer
// },
// 'application/x-www-form-urlencoded': {
// parseRequest: parseFormUrlencoded // Object.fromEntries(new URLSearchParams(body).entries())
// }
// }Keys become the accepted Content-Type values (and are advertised in Accept-Query).
Objects may provide queryReply (applied to the response) and/or parseRequest (applied to the request body).
reply.sendQuery
Setting decorateReply option to true enables reply.sendQuery(data) method.
This method can be used as direct replacement to reply.send(data) and it implements similar filtering logic as QUERY handlers added by the plugin.
The options addQueryTypes, overrideQueryTypes, and strict are applied to this method.
filterRoute / excludeRoute examples
// All routes (default for filterRoute)
filterRoute: true
// Exact URL
filterRoute: '/users'
// Multiple URLs
filterRoute: ['/users', '/posts']
// Regular expression
filterRoute: /^\/api\//
// Custom function
filterRoute: ({ url }) => url.startsWith('/api') || url.endsWith('.json')
// Exclude specific routes
excludeRoute: '/admin'
// or
excludeRoute: ({ url }) => url.startsWith('/internal')Custom query types examples
import fastifyQuery, { defaultQueryTypes } from 'fastify-query';
// Add a custom content type alongside the defaults
await app.register(fastifyQuery, {
addQueryTypes: {
'application/x-jsonpath': defaultQueryTypes['application/jsonpath'],
},
});
// Replace defaults entirely
await app.register(fastifyQuery, {
overrideQueryTypes: {
'application/xpath': {
queryReply: (replyBody, query) => {
// your implementation
}
},
},
});Strict handling
The strict option defines behaviour in case no handler is defined for the Content-Type provided by the client.
If it is true, the plugin returns HTTP 415. If it is false, it returns the response without further processing.
If it is undefined or null (default), it relies on the handling parameter in the Prefer request header.
By default it is in strict mode; providing handling=lenient overrides it.
How it works
- Registers content-type parsers for each configured query type (body is kept as a string).
- On every matching route (controlled by
filterRoute/excludeRoute):- Adds
Accept-Queryheader listing the supported query content types (for methods listed inadvertiseAcceptQuery). - Creates a sibling route with the
QUERYmethod that:- Optionally parses the request body via
parseRequest(controlled byfilterRequest/excludeRequest) and stores the result inrequest.query. - Invokes the original route handler.
- Optionally applies
queryReply(controlled byfilterReply/excludeReply) to the result. - Serializes and returns the (possibly filtered) result as the response.
- Optionally parses the request body via
- Adds
How it works internally
Accept-Queryis added usingonSendhooks.- New
QUERYroutes are registered via theonRoutehook. - Request body parsing (when a
parseRequestis defined for the media type) happens in apreHandlerhook. - Response filtering (when a
queryReplyis defined for the media type) happens in apreSerializationhook. - By default, JSONPath filtering uses the
jsonpath-rfc9535package. - By default, JSON Pointer filtering uses the
jsonpointerpackage. application/jsonandapplication/x-www-form-urlencodedare supported natively.
Contributing
Contributions made by humans are welcome. This includes contributions made with non-human assistance, as long as the human submitter takes full responsibility: understands the changes to the dot, verified and tested them.
License
Licensed under MIT.
