GraphQL Query Rewriter
Seamlessly turn breaking GraphQL schema changes into non-breaking changes by rewriting queries in middleware.
Full API docs are available at https://ef-eng.github.io/graphql-query-rewriter
The Problem
GraphQL is great at enforcing a strict schema for APIs, but its lack of versioning makes it extremely difficult to make changes to GraphQL schemas without breaking existing clients. For example, take the following query:
query getUserById($id: String!) {
userById(id: $id) {
...
}
}
Oh no! We should have used ID!
as the type for userById(id)
instead of String!
, but it's already in production! Now if we change our schema to use ID!
instead of String!
then our old clients will start getting the error Variable "$id" of type "String!" used in position expecting type "ID!"
. Currently your only options are to continue using the incorrect String!
type forever (eeew), or make a new query with a new name, like userByIdNew(id: ID!)
(gross)!
Wouldn't it be great if you could change the schema to use ID!
, but just silently replace String!
in old queries with ID!
in your middleware so the old queries will continue to work just like they had been?
Rewrite it!
GraphQL Query Rewriter provides a way to rewrite deprecated queries in middleware so they'll conform to your new schema without needing to sully your API with awkwardly renamed and deprecated fields like doTheThingNew
or doTheThingV3
.
In the above example, we can set up a rewrite rule so that userById(id: String!)
will be seamlessly rewritten to userById(id: ID!)
using the following middleware (assuming express-graphql):
import { FieldArgTypeRewriter } from 'graphql-query-rewriter';
import { graphqlRewriterMiddleware } from 'express-graphql-query-rewriter';
const app = express();
app.use('/graphql', graphqlRewriterMiddleware({
rewriters: [
new FieldArgTypeRewriter({
fieldName: 'userById',
argName: 'id',
oldType: 'String!',
newType: 'ID!'
}),
]
}));
app.use('/graphql', graphqlHTTP( ... ));
...
Now, when old clients send the following query:
query getUserById($id: String!) {
userById(id: $id) {
...
}
}
It will be rewritten before it gets processed to:
query getUserById($id: ID!) {
userById(id: $id) {
...
}
}
Now your schema is clean and up to date, and deprecated clients keep working! GraphQL Schema Rewriter can rewrite much more complex queries than just changing a single input type as well.
Installation
Installation requires the base package graphql-query-rewriter
and a middleware adapter for the web framework you use. Currently works with express-graphql
and apollo-server
.
For express-graphql
npm install graphql-query-rewriter express-graphql-query-rewriter
For Apollo-server
Apollo server works with express-graphql-query-rewriter
via Apollo server middleware.
npm install graphql-query-rewriter express-graphql express-graphql-query-rewriter
Usage
First you need to set up an appropriate middleware for your server.
For express-graphql
With express-graphql, you can use the express-graphql-query-rewriter
middleware. This middleware goes directy before your graphql
handler in express:
import { graphqlRewriterMiddleware } from 'express-graphql-query-rewriter';
...
app.use('/graphql', graphqlRewriterMiddleware({
rewriters: [ ]
})
app.use('/graphql', graphqlHTTP( ... ));
...
For apollo-server
Apollo-server can also use the express-graphql-query-rewriter
middleware like below:
const { ApolloServer, gql } = require("apollo-server-express");
const express = require("express");
const { graphqlRewriterMiddleware } = require("express-graphql-query-rewriter");
const server = new ApolloServer({ typeDefs, resolvers });
const app = express();
const path = "/graphql";
app.use(
path,
graphqlRewriterMiddleware({
rewriters: [ ]
})
);
server.applyMiddleware({ app, path, bodyParserConfig: false });
...
Note that you need to specify bodyParserConfig: false
in applyMiddleware()
since express-graphql-query-rewriter
already parses the graphQL body in order to rewrite it.
FieldArgTypeRewriter
FieldArgTypeRewriter
rewrites the type of an argument to a graphQL query or mutation. For example, to change from Int
to Int!
in a mutation called doTheThing(arg1: Int)
you could add the following:
import { FieldArgTypeRewriter } from 'graphql-query-rewriter';
const rewriter = new FieldArgTypeRewriter({
fieldName: 'doTheThing',
argName: 'arg1',
oldType: 'Int',
newType: 'Int!'
});
Sometimes, you'll need to do some preprocessing on the variables submitted to the rewritten argument to make them into the type needed by the new schema. You can do this by passing in a coerceVariable
function which returns a new value of the variable. For example, the following changes the value of arg1
from Int!
to String!
, and also changes the value of arg1
to a string as well:
import { FieldArgTypeRewriter } from 'graphql-query-rewriter';
const rewriter = new FieldArgTypeRewriter({
fieldName: 'doTheThing',
argName: 'arg1',
oldType: 'Int!',
newType: 'String!'
coerceVariable: (val) => `${val}`,
})
FieldArgNameRewriter
FieldArgNameRewriter
rewrites the name of an argument to a graphQL query or mutation. For example, to change an argument name from userID
to userId
in a mutation called createUser(userID: ID!)
you could add the following:
import { FieldArgNameRewriter } from 'graphql-query-rewriter';
const rewriter = new FieldArgNameRewriter({
fieldName: 'createUser',
oldArgName: 'userID',
newArgName: 'userId'
});
FieldArgsToInputTypeRewriter
FieldArgsToInputTypeRewriter
can be used to move mutation parameters into a single input object, by default named input
. It's a best-practice to use a single input type for mutations in GraphQL, and it's required by the Relay GraphQL Spec. For example, to migrate the mutation createUser(username: String!, password: String!)
to a mutation with a proper input type like:
mutation createUser(input: CreateUserInput!) { ... }
type CreateUserInput {
username: String!
password: String!
}
we can make this change with the following rewriter:
import { FieldArgsToInputTypeRewriter } from 'graphql-query-rewriter';
const rewriter = new FieldArgsToInputTypeRewriter({
fieldName: 'createUser',
argNames: ['username', 'password'],
inputArgName: 'input'
});
For example, This would rewrite the following mutation:
mutation createUser($username: String!, $password: String!) {
createUser(username: $username, password: $password) {
...
}
}
and turn it into:
mutation createUser($username: String!, $password: String!) {
createUser(input: { username: $username, password: $password }) {
...
}
}
ScalarFieldToObjectFieldRewriter
ScalarFieldToObjectFieldRewriter
can be used to rewrite a scalar field into an object selecing a single scalar field. For example, imagine there's a User
type with a full_name
field that's of type String!
. But to internationalize, that full_name
field needs to support different names in different languges, something like full_name: { default: 'Jackie Chan', 'cn': '成龙', ... }
. We could use the ScalarFieldToObjectFieldRewriter
to rewriter full_name
to instead select the default
name. Specifically, given we have the schema below:
type User {
id: ID!
full_name: String!
...
}
and we want to change it to
type User {
id: ID!
full_name: {
default: String!
en: String
cn: String
...
}
...
}
we can make this change with the following rewriter:
import { ScalarFieldToObjectFieldRewriter } from 'graphql-query-rewriter';
const rewriter = new ScalarFieldToObjectFieldRewriter({
fieldName: 'full_name',
objectFieldName: 'default'
});
For example, This would rewrite the following query:
query getUser(id: ID!) {
user {
id
full_name
}
}
and turn it into:
query getUser(id: ID!) {
user {
id
full_name {
default
}
}
}
JsonToTypedObjectRewriter
JsonToTypedObjectRewriter
can be used to rewrite a field that previously is just freeform JSON into a typed graphQL field with proper selections and subseelctions. For example, imagine there's a user
query with type JSON, like user: { type: GraphQLJSON }
. However, we'd like to improve our API by properly typing the fields within user
using a graphQL type named User
with fields id
, name
, isAdmin
, etc.... For example, we have:
query {
user
}
and we want to change it to
query {
user {
id
name
isAdmin
}
}
we can make this change with the following rewriter:
import { JsonToTypedObjectRewriter } from 'graphql-query-rewriter';
const rewriter = new JsonToTypedObjectRewriter({
fieldName: 'user',
objectFields: [
{ name: 'id' },
{ name: 'name' },
{ name: 'isAdmin' },
]
});
This rewriter also supports rewriting subfields recursively by adding a subfields
array inside of an object field. For example, the rewriter below:
import { JsonToTypedObjectRewriter } from 'graphql-query-rewriter';
const rewriter = new JsonToTypedObjectRewriter({
fieldName: 'user',
objectFields: [
{ name: 'id' },
{ name: 'name' },
{
name: 'posts'
subfields: [
{ name: 'id' },
{ name: 'title' },
]
},
]
});
would rewrite query { user }
into:
query {
user {
id
name
posts {
id
title
}
}
}
NestFieldOutputsRewriter
NestFieldOutputsRewriter
can be used to move mutation outputs into a nested payload object. It's a best-practice for each mutation in GraphQL to have its own output type, and it's required by the Relay GraphQL Spec. For example, to migrate the mutation createUser(input: CreateUserInput!): User!
to a mutation with a proper output payload type like:
mutation createUser(input: CreateUserInput!) CreateUserPayload
type User {
id
username
}
type CreateUserPayload {
user: User!
}
we can make this change with the following rewriter:
import { NestFieldOutputsRewriter } from 'graphql-query-rewriter';
const rewriter = new NestFieldOutputsRewriter({
fieldName: 'createUser',
newOutputName: 'user',
outputsToNest: ['id', 'username']
});
For example, This would rewrite the following mutation:
mutation createUser(input: CreateUserInput!) {
createUser(input: $input) {
id
username
}
}
and turn it into:
mutation createUser(input: CreateUserInput!) {
createUser(input: $input) {
user {
id
username
}
}
}
Restricting Matches Further
Sometimes you need more control over which fields get rewritten to avoid accidentally rewriting fields which happen to have the same name in an unrelated query. This can be accomplished by providing a list of matchConditions
to the RewriteHandler
. There are 3 built-in match condition helpers you can use to make this easier, specifically fragmentMatchCondition
, queryMatchCondition
, and mutationMatchCondition
. If any of the conditions passed in to matchConditions
match, then the rewriter will proceed as normal.
For example, to restrict matches to only to the title
field of fragments named thingFragment
, on type Thing
, we could use the following matchConditions
:
import { fragmentMatchCondition, ScalarFieldToObjectFieldRewriter } from 'graphql-query-rewriter';
const rewriter = new ScalarFieldToObjectFieldRewriter({
fieldName: 'title',
objectFieldName: 'text',
matchConditions: [
fragmentMatchCondition({
fragmentNames: ['thingFragment'],
fragmentTypes: ['Thing']
})
]
});
Then, this will rewrite the following query as follows:
query {
articles {
title
things {
...thingFragment
}
}
}
fragment thingFragment on Thing {
id
title
}
You can also pass a pathRegexes
array of regexes to fragmentMatchCondition
if you'd like to restrict the path to the object field within the fragment that you'd like to rewrite. For example:
const rewriter = new ScalarFieldToObjectFieldRewriter({
fieldName: 'title',
objectFieldName: 'text',
matchConditions: [
fragmentMatchCondition({
pathRegexes: [/^innerThing.title$/]
})
]
});
Then, this will rewrite the query below as follows:
query {
things {
...parentThingFragment
}
}
fragment parentThingFragment on Thing {
id
title
innerThing {
title
}
}
There are also queryMatchCondition
and mutationMatchCondition
. These work similarly to fragmentMatchCondition
, except they match only fields directly inside of a query or a mutation, respectively.
All of these matches take pathRegexes
to search for matching paths, but queryMatchCondition
can also take queryNames
, to match only named queries, and likewise mutationMatchCondition
can take mutationNames
to match named mutations.
If there are multiple matchConditions
provided, then if any of the conditions match then the rewriter will continue as normal. For example:
const rewriter = new ScalarFieldToObjectFieldRewriter({
fieldName: 'title',
objectFieldName: 'text',
matchConditions: [
fragmentMatchCondition({
fragmentNames: ['thingFragment']
}),
queryMatchCondition({
queryNames: ['getThing', 'getOtherThing']
})
]
});
The above rewriter will only match on fragments named thingFragment
, or queries named getThing
or getOtherThing
.
Current Limitations
Currently GraphQL Query Rewriter can only work with a single operation per query, and cannot properly handle aliased fields. These limitations should hopefully be fixed soon. Contributions are welcome!
License
GraphQL Query Rewriter is released under a MIT License.
Contributing
Contributions are welcome! These steps will guide you through contributing to this project:
Make and commit your changes. Make sure the commands yarn run build and yarn run test:prod are working.
Finally send a GitHub Pull Request with a clear list of what you've done. Make sure all of your commits are atomic (one feature per commit). Please add tests for any features that you add or change.