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.
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.
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:
A middleware extracts the current user from the request, e.g. from a cookie or a JWT token
The middleware injects the user object into the resolver context
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?
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.
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.
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.
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.
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.
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
.
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.
actors
makes data access easy to auditWhen 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.
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.