How we built Propel

How we migrated to Apollo Server 4

Peek behind the curtain and see how we upgraded our GraphQL API to the latest version of Apollo Server.

Photo: Propel

Apollo logo

At Propel, it’s no secret we’re fans of GraphQL: our entire product is centered around serving in-product analytics and customer dashboards using this flexible and performant technology.

We chose GraphQL first because it empowers clients to fetch exactly the data they need, usually in a single round-trip to the server. This is great for building customer dashboards, which may include multiple time series, counters, and leaderboards in a single page. It’s part of what makes using Propel so snappy.

Second, the developer experience around GraphQL is amazing, and we’ve been fortunate to use some great tools from The Guild and Apollo in building our product. For example, we publish our GraphQL schemas to Apollo Studio, we embed the Apollo Studio Explorer in our docs, and our GraphQL API is actually built on top of Apollo Server.

I want to take a moment to share part of our journey on the server-side, since we just recently upgraded our GraphQL APIs from Apollo Server 3 to 4. Apollo did a great job documenting the expected effort in their “Migrating to Apollo Server 4” guide, but we still encountered (and solved!) some unexpected differences in Apollo Server 4.

💡 Apollo Server 3 is officially deprecated and will reach end-of-life October 22, 2023. Read along to see how we at Propel migrated to Apollo Server 4.

Basic changes

The apollo-server library is now @apollo/server

In Apollo Server 3, you depended on version 3.x of the apollo-server library and any supporting libraries, like apollo-server-errors or apollo-server-core.

In Apollo Server 4, everything is published in a single, namespaced library. You only have to depend on version 4.x of @apollo/server.

New <span class="code-exp2">startStandaloneServer</span> method

In Apollo Server 3, the apollo-server library wrapped apollo-server-express and offered a “batteries-included” HTTP server for handling GraphQL requests.

In Apollo Server 4, the “batteries-included” approach is now <span class="code-exp">startStandaloneServer</span>. Migrating to this new method is straightforward and well-described in the migration guide. At Propel, we tried to use this new method; however, for reasons described below, we ultimately needed to use <span class="code-exp">expressMiddleware</span>.

The <span class="code-exp">gql</span> template literal tag has been removed

In Apollo Server 3, you could import the <span class="code-exp">gql</span> template literal tag directly from the apollo-server library. This template literal tag is provided by the graphql-tag library and allows parsing a GraphQL query string to an AST that can be used by Apollo and other GraphQL libraries.

import { gql } from 'apollo-server'

In Apollo Server 4, this template literal tag is no longer exported. In fact, it’s no longer depended on by the @apollo/server library at all. At Propel, we had a few usages of <span class="code-exp">gql</span> in our backend, so we had to add a dependency on graphql-tag and change our import in order to fix this. Easy fix. 👍

import { gql } from 'graphql-tag'

ApolloError is replaced by GraphQLError

In Apollo Server 3, the apollo-server-errors library defined an ApolloError class. At Propel, we sub-classed ApolloError in our backend to create a hierarchy of errors. Any time we needed to throw an error from a resolver and have it appear in the GraphQL response’s <span class="code-exp">errors</span> array, we would use this class. For example, one of our NotFoundErrors looked like this:

import { ApolloError } from 'apollo-server-errors'

export class NotFoundError extends ApolloError {  
  readonly name = 'NotFoundError'  
  readonly code = 'NOT_FOUND'  

  constructor (resourceName: string) {    
    super(`${resourceName} not found`, this.code)    
    Object.defineProperty(this, 'name', { value: this.name })  
  }
  
// Members omitted…
}

In Apollo Server 4, ApolloError is removed. Instead, we should now use the GraphQLError class from the graphql library. We already had a dependency on graphql, so we just needed to update our sub-classes. After migrating, our NotFoundError looked like this:

import { GraphQLError } from 'graphql'

export class NotFoundError extends GraphQLError {  
  readonly name = 'NotFoundError'  
  readonly code = 'NOT_FOUND'  

  constructor (resourceName: string) {    
    super(`${resourceName} not found`, {      
      extensions: {        
        code: this.code      
      }    
    })    
    Object.defineProperty(this, 'name', { value: this.name })  
   }  

  // Members omitted…
}

Additionally, Apollo Server 4 adds the ability to control HTTP response status codes by including an <span class="code-exp">http</span> extension in the GraphQLError. For example, throwing a GraphQLError like the following will cause the HTTP server to respond with 418 I’m a teapot. 🫖

throw new GraphQLError("I'm a teapot", {  
  extensions: {    
    code: 'TEAPOT',    
    http: { status: 418 }  
  }
})

Plugin changes

Plugin error-handling has changed

In Apollo Server 3, plugins could receive the incoming request object in their <span class="code-exp">requestDidStart</span> method. At Propel, we used this to build an authentication and authorization plugin (our “AuthPlugin”).

Our AuthPlugin determines if incoming requests are authentic and if they are authorized. If an incoming request is inauthentic or unauthorized, we throw an HttpQueryError with status code 403. This would cause Apollo Server to return a 403 Unauthorized HTTP response back to the user. It looked something like this:

import { ApolloServer } from 'apollo-server'
import { HttpQueryError } from 'apollo-server-core'

class AuthPlugin {  
  requestDidStart ({ request, context }) {    
    if (!isAuthenticated || !isAuthorized) {      
      throw new HttpQueryError(403, 'Unauthorized')    
    }    
    context.authCtx = authCtx  
  }
}

const server = new ApolloServer({  
  typeDefs,  
  plugins: [new AuthPlugin()],  
  context: () => ({})
})

In Apollo Server 4, not only is HttpQueryError removed, but throwing an error from a plugin’s <span class="code-exp">requestDidStart</span> method results in a 500 Internal Server Error. We already knew we would need to migrate from HttpQueryError to GraphQLError, but how could we control the HTTP status code if Apollo Server was re-throwing plugin errors as 500s?

After some head-scratching, I opened an issue on Apollo Server’s GitHub repository. There, Apollo Server contributor @glasser shared a helpful suggestion: why not invoke our AuthPlugin from Apollo Server’s <span class="code-exp">context</span> function? Throwing from <span class="code-exp">context</span> would ensure we can control the HTTP status response without having to introduce more methods and error checks to our AuthPlugin (like <span class="code-exp">unexpectedErrorProcessingRequest</span>). With that suggestion in mind, we rewrote our AuthPlugin as follows:

import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { GraphQLError } from 'graphql'

class AuthPlugin {  
  getAuthContext (req) { // ← Method renamed (not a true plugin anymore).    
    if (!isAuthenticated || !isAuthorized) {      
      throw new GraphQLError('Unauthorized', {        
        extensions: {          
          code: 'UNAUTHORIZED',          
          http: { status: 403 }        
        }      
      })    
    }    
    return authCtx  
  }
}

const server = new ApolloServer({  
  typeDefs,  
  plugins: [] // ← AuthPlugin is omitted here.
})

const authPlugin = new AuthPlugin()

startStandaloneServer(server, {  
  context: async ({ req }) => {    
    // Plugin invoked inside of `context`. Read on for details.    
    return { authCtx: await authPlugin.getAuthContext(req) }  
  }
})

It’s a few extra lines, but it works well. 👍

Plugins no longer receive the request URL

In Apollo Server 3, the incoming request object passed to <span class="code-exp">requestDidStart</span> included a URL property. Our AuthPlugin would read the request’s URL and headers to determine if the request was authentic and authorized.

In Apollo Server 4, this URL property has been removed. I was curious about this change, so I asked about it on Apollo Server’s GitHub repository. Apollo Server contributor @glasser helpfully pointed out that the URL was still accessible in Apollo Server’s <span class="code-exp">context</span> function. Because we had already moved our AuthPlugin inside of context, we could accept the request object provided by <span class="code-exp">startStandaloneServer</span> and extract the URL from there. The fix was straightforward:

startStandaloneServer(server, {  
  context: async ({ req }) => {    
    // `req` here is an `http.IncomingMessage` with `url`.    
    return { authCtx: await authPlugin.requestDidStart(req) }  
  }
})

Server changes

HTTP-level health check endpoint removed

In Apollo Server 3, there was an HTTP-level health check endpoint included at <span class="code-exp">/.well-known/apollo/server-health</span>. This would return 200 OK for any GET request. While too simple to evaluate if the server process was truly healthy, it did indicate whether the Node.js process and HTTP server were running. At Propel, we deploy our GraphQL API servers as Fargate tasks behind Application Load Balancers (ALBs), so this was a natural choice to configure as our target groups’ health check endpoint:

<span class="code-example">https://your.server/.well-known/apollo/server-health</span>

In Apollo Server 4, the HTTP-level health check endpoint has been removed. Apollo Server recommends issuing a simple GraphQL query instead. For example,

<span class="code-example">https://your.server/?query=%7B__typename%7D</span>

Indeed, this is a more sophisticated health check than the HTTP-level health check in Apollo Server 3: by executing a GraphQL query, we can ensure that Apollo Server is working in addition to the Node.js process and HTTP server.

At Propel, two issues prevented us from adopting this:

  • Our entire GraphQL API is protected by our AuthPlugin, so by default the ALBs’ health check requests would be rejected with status code 403.
  • Apollo Server’s CSRF protection would require the ALB to send an “Apollo-Require-Preflight” header with each health check request, which is unsupported.

Instead, we followed Apollo Server’s “Swapping to <span class="code-exp">expressMiddleware</span>” guide. By swapping to <span class="code-exp">expressMiddleware</span>, we would get access to the underlying express HTTP server. This way, we can install a health check endpoint at a new URL, <span class="code-exp">/.well-known/server-health</span>. This health check endpoint is completely private, and so it’s safe to leave unauthenticated.

const app = express()
// Setup Apollo Server with `expressMiddleware`…

app.get('/.well-known/server-health', (_, res) => {  
  // Response follows 
  res.type('application/health+json')  
  res.json({ status: 'pass' })
})

We followed the same response format as Apollo Server 3. Indeed, this is a very simple check; however, we plan to incorporate more checks in the future.

Conclusion

Migrating from Apollo Server 3 to Apollo Server 4 took us less than a week, and we received a lot of help from Apollo’s own migration guide as well as on Apollo Server’s GitHub issues tracker. If any of this interests you — be it GraphQL or in-product analytics — reach out, follow us on Twitter @PropelDataCloud, or subscribe to our email newsletter.

If you’d like to learn more about our GraphQL API, check out our GraphQL API documentation or book a demo!

Related Content

We are creating more content for this category