Build flexible GraphQL APIs by treating the Schema like a Database

ยท

7 min read

image.png

In this post, I'd like to talk about a new Architecture pattern for building flexible GraphQL APIs. By treating your GraphQL Schema like a Database, you're able to build use-case agnostic and flexible GraphQL APIs.

We're currently in the works of building WunderGraph Cloud, a Serverless GraphQL API Platform with integrated CI/CD, similar to Vercel. Our goal is to offer the best possible Developer Experience for building APIs, with branching and the ability to deploy multiple versions of the same API just by opening a Pull Request. You can sign up for early access if you're interested.

Building WunderGraph Cloud means that we have to build APIs ourselves. We made the decision to use the Open Source WunderGraph Framework to build our own APIs. We believe that the best way to build a great Developer Experience is to use the same tools that we're building for our customers.

Using WunderGraph to build APIs is fundamentally different from the traditional approach. Instead of directly exposing the GraphQL Layer, we're hiding it behind a JSON-HTTP/JSON-RPC API. All the plumbing is handled by our Framework.

This protection against direct exposure of the GraphQL layer has a lot of advantages. But before we get there, let's discuss the traditional approach.

Building GraphQL APIs: The viewer root field

One common pattern you often see in GraphQL APIs is the viewer root field. It's a field that returns the currently authenticated user. Here's a simple example:

type Query {
  viewer: User!
}
type User {
  id: ID!
  name: String!
}

If you send a GraphQL query like below, it will return the currently authenticated user:

query {
  viewer {
    id
    name
  }
}

The implementation of the viewer field usually looks like this:

  1. A middleware extracts the current user from the request, e.g. from a cookie or a JWT token
  2. The middleware injects the user object into the resolver context
  3. The resolver of the viewer field uses the userID from the context to fetch the user from the database

With this pattern, you can add relationships like groups, friends, etc. to the User type, allowing each user to access "their" data.

What sounds great at first glance comes with a lot of drawbacks, actually. What if we don't have a user? What if we want to see the data of another user? What if we don't know exactly how authentication will be ultimately implemented?

The limits of building GraphQL APIs with a user context in mind

Sometimes, you don't have a user. If you're building a frontend, this might not be obvious, but there are a lot of use cases where we actually don't have one.

Our API could be used by other microservices. They don't operate in the context of a user, but in the context of a service. They could be authenticated, but they will not have a user ID.

Another example is when we build a CLI tool. The CLI might be used from a CI/CD pipeline. In this case, we might be injecting service credentials into the environment of the CI runner. Again, no user.

How about building an admin dashboard to help your users? WunderGraph Cloud will allow users to deploy "projects". If there's a problem with one of the projects, how could our support team access the data if they need to be authenticated as a user?

As you can see, there are a lot of use cases where we'd have to circumvent the viewer root field. Possible workarounds might be to create a second GraphQL API only for admin users. Another option would be to create fields prefixed with admin_. These special fields would grant you more flexible access and require you to be authenticated as an admin user.

Either way, you'd have to maintain another service or another set of resolvers. It's a costly approach that we'd ideally want to avoid.

Building Authentication-agnostic GraphQL APIs around the idea of "actors"

As we've stated above, WunderGraph hides your GraphQL API behind a JSON-RPC layer. This means, you can build your API Authentication-agnostic.

Let's take a look at the first iteration of our API.

type Query {
    userByID(id: ID!, actorID: ID!): MaybeUser!
}
type User {
    id: ID!
    email: String!
    firstName: String!
    lastName: String!
    slug: String!
}
union MaybeUser = NotFound | User
type NotFound {
    message: String!
}

There's no viewer root field. Instead, we have a userByID field that takes a second argument, the actorID. You might be thinking that this API is insecure, because it allows you to query any user by ID. But that's not the case. Let's see how we can leverage WunderGraph to implement all use cases we've discussed above without compromising security.

Let's build the first Operation for the user to view their own profile

query($currentUserID: ID! @fromClaim(name: USERID)) {
    userByID(id: $currentUserID, actorID: $currentUserID) {
        ... on User {
            id
            email
            firstName
            lastName
            slug
        }
        ... on NotFound {
            message
        }
    }
}

This Operation leverages the @fromClaim directive. This directives injects the userID into both query variables, userID and actorID are the same. The user must be authenticated to access this field. They can either be authenticated via a cookie or a JWT token.

The logic to implement the underlying resolver could check if the actorID is the same as the userID. If that's the case, the user is allowed to access the data.

Next, let's build the Operation for the admin to view any user's profile

query($userID: ID! $actorID: ID! @fromClaim(name: USERID)) {
    userByID(id: $userID, actorID: $actorID) {
        ... on User {
            id
            email
            firstName
            lastName
            slug
        }
        ... on NotFound {
            message
        }
    }
}

In this case, we're only injecting the actorID from the user context. The userID variable can be defined by the viewer of the API.

For this Operation to work, we need to extend the logic of our resolver. E.g. we can check in the database if the actorID (injected) is an admin user.

Next, let's build an Operation that can be called from another microservice

Actually, we've solved this problem already. A microservice can use the OpenID Connect client credentials flow to authenticate. This means it will acquire a JWT token with a sub claim. The sub claim is injected into the @fromClaim(name: USERID) directive. This means, the Operation above can be called from a microservice.

This can be implemented in the resolver by checking the actorID for a specific prefix, e.g. svc_. If the actorID starts with svc_, we know that the request is coming from a microservice. We can then check in our database whether the service is allowed to access the data.

Finally, let's build an Operation that can be called from a CI/CD pipeline

Again, this is the same as the previous use case. The only difference might be the issuing of an access token that grants access to all users of an organization. This means the actorID would be something like org_1234.

Treating our API like a database makes it flexible and easy to maintain

As you've seen, we've been able to implement all use cases with a single resolver. We didn't need to create a second API or a second set of resolvers. All of this is possible because we're hiding the GraphQL layer behind the WunderGraph API Gateway.

Due to the fact that we know we're going to hide the GraphQL layer, we can design it differently. That's why the title states that we're treating our API like a database. A database usually doesn't care about the user context. This makes it very flexible but also vulnerable. However, we're (hopefully) hiding the database behind an API layer, so it's not an issue. With WunderGraph, we're applying the same principle to our GraphQL API.

The flexibility is great! We're able to write and maintain less code. But there's another benefit in terms of security: We're able to easily audit access to our data.

Building GraphQL APIs with actors makes data access easy to audit

When every API call needs to have an actor, we know exactly who had access to which data, and when.

If an access token is compromised, We know exactly which Operations were called with that token (actorID) in question The WunderGraph API Gateway can produce an audit log for us.

Conclusion

We've shown a pattern to build flexible and secure GraphQL APIs with the additional benefit of easy auditing. I hope this inspires you to think in new ways about how to build your GraphQL APIs.

If you're interested in learning more about how we're building WunderGraph and how you can use it, please follow us on twitter, linkedin, or join our discord to get updates.

ย