DEV Community

Cover image for Building a GraphQL server with GraphQL Helix 🧬
Daniel Rearden
Daniel Rearden

Posted on

Building a GraphQL server with GraphQL Helix 🧬

Earlier this week I released GraphQL Helix, a new JavaScript library that lets you take charge of your GraphQL server implementation.

There's a couple of factors that pushed me to roll my own GraphQL server library:

  • I wanted to use bleeding-edge GraphQL features like @defer, @stream and @live directives.
  • I wanted to make sure I wasn't tied down to a specific framework or runtime environment.
  • I wanted control over how server features like persisted queries were implemented.
  • I wanted to use something other than WebSocket (i.e. SSE) for subscriptions.

Unfortunately, popular solutions like Apollo Server, express-graphql and Mercurius fell short in one or more of these regards, so here we are.

Existing libraries like Apollo Server provide you with either a complete HTTP server or else a middleware function that you can plug into your framework of choice. GraphQL Helix takes a different approach -- it just provides a handful of functions that you can use to turn an HTTP request into a GraphQL execution result. In other words, GraphQL Helix leaves it up to you to decide how to send back the response.

Let's see how this works in practice.

A Basic Example

We'll start by building an express application and adding a /graphql endpoint.

import express from "express";
import { schema } from "./my-awesome-schema";

const app = express();

app.use(express.json());

app.use("/graphql", async (res, req) => {
  // TODO
});

app.listen(8000);
Enter fullscreen mode Exit fullscreen mode

Note that we're assuming here we already have a GraphQL schema we've created. However you build your schema (GraphQL Tools, TypeGraphQL,
graphql-compose, GraphQL Nexus, etc.) is irrelevant -- as long as you have a GraphQLSchema object, you're good to go.

Next, let's extract the relevant bits from our request into a standard GraphQL Helix object:

app.use("/graphql", async (res, req) => {
  const request = {
    body: req.body,
    headers: req.headers,
    method: req.method,
    query: req.query,
  };
});
Enter fullscreen mode Exit fullscreen mode

More astute readers might note that we could have just used the req object as-is — and that's true! However, this step will look a little different depending on the framework or runtime we use, so I'm being more explicit about how we define this object.

Now let's extract the relevant parameters from the request and process them.

import {
  getGraphQLParameters,
  processRequest
} from "graphql-helix";

...

app.use("/graphql", async (res, req) => {
  const request = {
    body: req.body,
    headers: req.headers,
    method: req.method,
    query: req.query,
  };

  const {
    query,
    variables,
    operationName
  } = getGraphQLParameters(request);

  const result = await processRequest({
    schema,
    query,
    variables,
    operationName,
    request,
  })
});
Enter fullscreen mode Exit fullscreen mode

processRequest still takes our Request object as a parameter, so why doesn't it just call getGraphQLParameters for us? As we'll see later on, this is an intentional design choice that gives us the flexibility to decide how the parameters are actually derived from the request.

So, we've processed our request and now have a result. Groovy. Let's do something with that result.

app.use("/graphql", async (res, req) => {
  const request = {
    body: req.body,
    headers: req.headers,
    method: req.method,
    query: req.query,
  };

  const {
    query,
    variables,
    operationName
  } = getGraphQLParameters(request);

  const result = await processRequest({
    schema,
    query,
    variables,
    operationName,
    request,
  })

  if (result.type === "RESPONSE") {
    result.headers.forEach(({ name, value }) => {
      res.setHeader(name, value)
    });
    res.status(result.status);
    res.json(result.payload);
  } else {
    // TODO
  }
});
Enter fullscreen mode Exit fullscreen mode

Our result includes the headers we should send back, an HTTP status code and the response payload (i.e. an object containing the data and errors we get by actually validating and executing the request).

And that's it! We now have a working /graphql endpoint that can process our requests. Neat.

So why are we writing all this extra boilerplate when I could do the same thing in a few lines of code in Apollo Server? In a word: flexibility. If we swap out Express for another framework like Fastify, we only have to change how we construct our request object and how we handle the result. In fact, we could use the meat of our implementation in virtually any other runtime -- serverless, Deno or even in the browser.

Moreover, we can process the result however our business needs dictate. We have a GraphQL over HTTP specification, but if for some reason you need to deviate from it, you can. It's your application -- send back the status, headers or response that are right for your use case.

So... what's up with that else block? As it turns out, processRequest will return one of three types of results:

  • RESPONSE for standard queries and mutations,
  • MULTIPART_RESPONSE for requests that include the new @defer and @stream directives, and
  • PUSH for subscriptions

Again, it's up to us to implement how to send back these responses, so let's do that now!

Subscriptions

We'll implement our subscriptions using Server Sent Events (SSE). There's a lot of advantages of using SSE over something like WebSockets for subscriptions, like being able to use the same middleware for all your requests, but a deeper comparison of the two approaches will be the topic of a future article.

There's a few libraries out there that can make integrating SSE with Express easier, but we'll do it from scratch for this example:

if (result.type === "RESPONSE") {
  ...
} else if (result.type === "PUSH") {
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    Connection: "keep-alive",
    "Cache-Control": "no-cache",
  });

  req.on("close", () => {
    result.unsubscribe();
  });

  await result.subscribe((result) => {
    res.write(`data: ${JSON.stringify(result)}\n\n`);
  });
}
Enter fullscreen mode Exit fullscreen mode

Here, our result includes two methods -- subscribe and unsubscribe. We call subscribe with a callback that's passed the result each time a new subscription event is pushed -- within this callback, we just write to the response with a SSE-compatible payload. And we call unsubscribe when the request is closed (i.e. when the client closes the connection) to prevent memory leaks.

Easy, peasy. Now let's take a look at MULTIPART_RESPONSE.

Multipart Responses

If our request includes @stream or @defer directives, our request needs to be sent down to the client in chunks. For example, with @defer, we send down everything except the deferred fragment and eventually send down the deferred fragment data when its finally resolved. As such, our MULTIPART_RESPONSE result looks a lot like the PUSH result with one key difference -- we do want to eventually end our response once all parts have been sent.

if (result.type === "RESPONSE") {
  ...
} else if (result.type === "PUSH") {
  ...
} else {
  res.writeHead(200, {
    Connection: "keep-alive",
    "Content-Type": 'multipart/mixed; boundary="-"',
    "Transfer-Encoding": "chunked",
  });

  req.on("close", () => {
    result.unsubscribe();
  });

  await result.subscribe((result) => {
    const chunk = Buffer.from(
      JSON.stringify(result),
      "utf8"
    );
    const data = [
      "",
      "---",
      "Content-Type: application/json; charset=utf-8",
      "Content-Length: " + String(chunk.length),
      "",
      chunk,
      "",
    ].join("\r\n");
    res.write(data);
  });

  res.end("\r\n-----\r\n");  
}
Enter fullscreen mode Exit fullscreen mode

Note that the Promise returned by subscribe won't resolve until the request has been fully resolved and the callback has been called with all the chunks, at which point we can safely end our response.

Congrats! Our API now has support for @defer and @stream (provide you're using the correct version of graphql-js).

Adding GraphiQL

GraphQL Helix comes with two additional functions that can be used to expose a GraphiQL interface on your server.

shouldRenderGraphiQL takes a Request object and returns a boolean that indicates, as you may have already guessed, whether you should render the interface. This is helpful when you have a single endpoint for both your API and the interface and only want to return the GraphiQL interface when processing a GET request from inside a browser.

renderGraphiQL just returns a string with the HTML necessary to the render the interface. If you want to create a separate endpoint for your documentation, you can use this function without using shouldRenderGraphiQL at all.

app.use("/graphql", async (req, res) => {
  const request = {
    body: req.body,
    headers: req.headers,
    method: req.method,
    query: req.query,
  };

  if (shouldRenderGraphiQL(request)) {
    res.send(renderGraphiQL());
  } else {
    // Process the request
  }
});
Enter fullscreen mode Exit fullscreen mode

The returned GraphiQL has a fetcher implementation that will work with multipart requests and SSE as shown in the examples above. If you need to do something else for your server, you can roll your own using renderGraphiQL as a template only.

Evolving your server implementation

GraphQL Helix is, by design, light-weight and unopinionated. Libraries like Apollo Server are bloated with a lot of features that you may never need.

Screen Shot 2020-11-05 at 11.22.49 AM

However, that doesn't mean you can't add those features back if you need them. For example, we can add uploads to our server by adding the Upload scalar and using the appropriate middleware from graphql-upload

import { graphqlUploadExpress } from "graphql-upload";

app.use(
  "/graphql",
  graphqlUploadExpress({
    maxFileSize: 10000000,
    maxFiles: 10,
  }),
  (req, res) => {
    // Our implementation from before
  }
)
Enter fullscreen mode Exit fullscreen mode

Similarly, we can add support for live queries with the @live directive by adding @n1ru4l/graphql-live-query and @n1ru4l/in-memory-live-query-store. We just need to add the directive to our schema and provide the appropriate execute implementation:

import {
  InMemoryLiveQueryStore
} from "@n1ru4l/in-memory-live-query-store";

const liveQueryStore = new InMemoryLiveQueryStore();

...

const result = const result = await processRequest({
  schema,
  query,
  variables,
  operationName,
  request,
  execute: liveQueryStore.execute,
});
Enter fullscreen mode Exit fullscreen mode

Tracing, logging, persisted queries, request batching, response deduplication and any number of other features can be added just as easily without the bloat and without having to wrestle with some plugin API or unfriendly abstraction.

You can check the repository for more examples and recipes (I'll be adding more as time allows and also accepting PRs!).

Conclusion

So when should you use Apollo Server instead of GraphQL Helix? If you need to throw together a quick POC or tutorial, Apollo Server is great. If you want to use federation, you might want to stick with Apollo (and even then there are better alternatives to doing GraphQL with microservices).

GraphQL Helix offers a flexible, extensible approach to building a GraphQL server, without the bloat. If you're building something other than another to-do tutorial, I highly recommend checking it out :)

Top comments (6)

Collapse
 
koresar profile image
Vasyl Boroviak

If this existed before the Apollo I'd certainly use it everywhere.

Collapse
 
advaiyalad profile image
Advaiya Lad

What is the difference between this and graphql-yoga?

Collapse
 
tgmarinhodev profile image
Thiago Marinho
Collapse
 
sgentile profile image
Steve Gentile

do you have this in a repo I can pull down ?

Collapse
 
delanyoyoko profile image
delanyo agbenyo

You said it's not tied down to any specific framework, so it's possible to use fastify in place of express right?

Collapse
 
tariqjawed83 profile image
Tariq Jawed

can we run this in a gateway mode?