DEV Community

Cover image for How we migrated to Apollo Server 4
Mark Roberts for Propel

Posted on • Originally published at propeldata.com

How we migrated to Apollo Server 4

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 startStandaloneServer 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 startStandaloneServer. 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 expressMiddleware.

The gql template literal tag has been removed

In Apollo Server 3, you could import the gql 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'
Enter fullscreen mode Exit fullscreen mode

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 gql 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'
Enter fullscreen mode Exit fullscreen mode

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 errors 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…
}
Enter fullscreen mode Exit fullscreen mode

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…
}
Enter fullscreen mode Exit fullscreen mode

Additionally, Apollo Server 4 adds the ability to control HTTP response status codes by including an http 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 }
  }
})
Enter fullscreen mode Exit fullscreen mode

Plugin changes

Plugin error-handling has changed

In Apollo Server 3, plugins could receive the incoming request object in their requestDidStart 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: () => ({})
})
Enter fullscreen mode Exit fullscreen mode

In Apollo Server 4, not only is HttpQueryError removed, but throwing an error from a plugin’s requestDidStart 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 context function? Throwing from context would ensure we can control the HTTP status response without having to introduce more methods and error checks to our AuthPlugin (like unexpectedErrorProcessingRequest). 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) }
  }
})
Enter fullscreen mode Exit fullscreen mode

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 requestDidStart 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 context function. Because we had already moved our AuthPlugin inside of context, we could accept the request object provided by startStandaloneServer 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) }
  }
})
Enter fullscreen mode Exit fullscreen mode

Server changes

HTTP-level health check endpoint removed

In Apollo Server 3, there was an HTTP-level health check endpoint included at /.well-known/apollo/server-health. 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:

https://your.server/.well-known/apollo/server-health
Enter fullscreen mode Exit fullscreen mode

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

https://your.server/?query=%7B__typename%7D
Enter fullscreen mode Exit fullscreen mode

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 expressMiddleware” guide. By swapping to expressMiddleware, we would get access to the underlying express HTTP server. This way, we can install a health check endpoint at a new URL, /.well-known/server-health. 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 https://tools.ietf.org/html/draft-inadarei-api-health-check-01
  res.type('application/health+json')
  res.json({ status: 'pass' })
})
Enter fullscreen mode Exit fullscreen mode

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 request access!

Top comments (0)