GraphQL
GraphQL is a fundamental part of Cedar. Having said that, you can get going without knowing anything about it, and can actually get quite far without ever having to read the docs. But to master Cedar, you'll need to have more than just a vague notion of what GraphQL is. You'll have to really grok it.
GraphQL 101
GraphQL is a query language that enhances the exchange of data between clients (in Cedar's case, a React app) and servers (a Cedar API).
Unlike a REST API, a GraphQL Client performs operations that allow gathering a rich dataset in a single request. There's three types of GraphQL operations, but here we'll only focus on two: Queries (to read data) and Mutations (to create, update, or delete data).
The following GraphQL query:
query GetProject {
project(name: "GraphQL") {
id
title
description
owner {
id
username
}
tags {
id
name
}
}
}
returns the following JSON response:
{
"data": {
"project": {
"id": 1,
"title": "My Project",
"description": "Lorem ipsum...",
"owner": {
"id": 11,
"username": "Cedar"
},
"tags": [{ "id": 22, "name": "graphql" }]
}
},
"errors": null
}
Notice that the response's structure mirrors the query's. In this way, GraphQL makes fetching data descriptive and predictable.
Again, unlike a REST API, a GraphQL API is built on a schema that specifies exactly which queries and mutations can be performed.
For the GetProject query above, here's the schema backing it:
type Project {
id: ID!
title: String
description: String
owner: User!
tags: [Tag]
}
# ... User and Tag type definitions
type Query {
project(name: String!): Project
}
More information on GraphQL types can be found in the official GraphQL documentation.
Finally, the GraphQL schema is associated with a resolvers map that helps resolve each requested field. For example, here's what the resolver for the owner field on the Project type may look like:
export const Project = {
owner: (args, { root, context, info }) => {
return db.project.findUnique({ where: { id: root.id } }).user()
},
// ...
}
You can read more about resolvers in the dedicated Understanding Default Resolvers section below.
To summarize, when a GraphQL query reaches a GraphQL API, here's what happens:
+--------------------+ +--------------------+
| | 1.send operation | |
| | | GraphQL Server |
| GraphQL Client +----------------->| | |
| | | | 2.resolve |
| | | | data |
+--------------------+ | v |
^ | +----------------+ |
| | | | |
| | | Resolvers | |
| | | | |
| | +--------+-------+ |
| 3. respond JSON with data | | |
+-----------------------------+ <--------+ |
| |
+--------------------+
In contrast to most GraphQL implementations, Cedar provides a "deconstructed" way of creating a GraphQL API:
- You define your SDLs (schema) in
*.sdl.jsfiles, which define what queries and mutations are available, and what fields can be returned - For each query or mutation, you write a service function with the same name. This is the resolver
- Cedar then takes all your SDLs and Services (resolvers), combines them into a GraphQL server, and expose it as an endpoint
Cedar and GraphQL
Besides taking care of the annoying stuff for you (namely, mapping your resolvers, which gets annoying fast if you do it yourself!), there's not many gotchas with GraphQL in Cedar. The only Cedar-specific thing you should really be aware of is resolver args.
Since there's two parts to GraphQL in Cedar, the client and the server, we've divided this doc up that way.
On the web side, Cedar uses Apollo Client by default though you can swap it out for something else if you want.
The api side offers a GraphQL server built on GraphQL Yoga and the Envelop plugin system from The Guild.
Cedar's api side is "serverless first", meaning it's architected as functions which can be deployed on either serverless or traditional infrastructure, and Cedar's GraphQL endpoint is effectively "just another function" (with a whole lot more going on under the hood, but that part is handled for you, out of the box). One of the tenets of the Cedar philosophy is "Cedar believes that, as much as possible, you should be able to operate in a serverless mindset and deploy to a generic computational grid.”
GraphQL Yoga and the Generic Computation Grid
To be able to deploy to a “generic computation grid” means that, as a developer, you should be able to deploy using the provider or technology of your choosing. You should be able to deploy to Netlify, Vercel, Fly, Render, AWS Serverless, or elsewhere with ease and no vendor or platform lock in. You should be in control of the framework, what the response looks like, and how your clients consume it.
The same should be true of your GraphQL Server. GraphQL Yoga from The Guild makes that possible.
The fully-featured GraphQL Server with focus on easy setup, performance and great developer experience.
Cedar leverages Yoga's Envelop plugins to implement custom internal plugins to help with authentication, logging, directive handling, and more.
Security Best Practices
Cedar implements GraphQL Armor from Escape Technologies to make your endpoint more secure by default by implementing common GraphQL security best practices.
GraphQL Armor, developed by Escape in partnership with The Guild, is a middleware for JS servers that adds a security layer to the Cedar GraphQL endpoint.
Trusted Documents
In addition, Cedar can be setup to enforce persisted operations -- alternatively called Trusted Documents.
See Configure Trusted Documents for more information and usage instructions.
Conclusion
All this gets us closer to Cedar's goal of being able to deploy to a "generic computation grid". And that’s exciting!
Client-side
RedwoodApolloProvider
By default, Cedar Apps come ready-to-query with the RedwoodApolloProvider. As you can tell from the name, this Provider wraps ApolloProvider. Omitting a few things, this is what you'll normally see in Cedar Apps:
import { RedwoodApolloProvider } from '@cedarjs/web/apollo'
// ...
const App = () => (
<RedwoodApolloProvider>
<Routes />
</RedwoodApolloProvider>
)
// ...
You can use Apollo's useQuery and useMutation hooks by importing them from @cedarjs/web, though if you're using useQuery, we recommend that you use a Cell:
import { useMutation } from '@cedarjs/web'
const MUTATION = gql`
# your mutation...
`
const MutateButton = () => {
const [mutate] = useMutation(MUTATION)
return (
<button onClick={() => mutate({ ... })}>
Click to mutate
</button>
)
}
Note that you're free to use any of Apollo's other hooks, you'll just have to import them from @apollo/client instead. In particular, these two hooks might come in handy:
| Hook | Description |
|---|---|
| useLazyQuery | Execute queries in response to events other than component rendering |
| useApolloClient | Access your instance of ApolloClient |
Customizing the Apollo Client and Cache
By default, RedwoodApolloProvider configures an ApolloClient instance with 1) a default instance of InMemoryCache to cache responses from the GraphQL API and 2) an authMiddleware to sign API requests for use with Cedar's built-in auth. Beyond the cache and link params, which are used to set up that functionality, you can specify additional params to be passed to ApolloClient using the graphQLClientConfig prop. The full list of available configuration options for the client are documented here on Apollo's site.
Depending on your use case, you may want to configure InMemoryCache. For example, you may need to specify a type policy to change the key by which a model is cached or to enable pagination on a query. This article from Apollo explains in further detail why and how you might want to do this.
To configure the cache when it's created, use the cacheConfig property on graphQLClientConfig. Any value you pass is passed directly to InMemoryCache when it's created.
For example, if you have a query named search that supports Apollo's offset pagination, you could enable it by specifying:
<RedwoodApolloProvider graphQLClientConfig={{
cacheConfig: {
typePolicies: {
Query: {
fields: {
search: {
// Uses the offsetLimitPagination preset from "@apollo/client/utilities";
...offsetLimitPagination()
}
}
}
}
}
}}>
Swapping out the RedwoodApolloProvider
As long as you're willing to do a bit of configuring yourself, you can swap out RedwoodApolloProvider with your GraphQL Client of choice. You'll just have to get to know a bit of the make up of the RedwoodApolloProvider; it's actually composed of a few more Providers and hooks:
FetchConfigProvideruseFetchConfigGraphQLHooksProvider
For an example of configuring your own GraphQL Client, see the redwoodjs-react-query-provider. If you were thinking about using react-query, you can also just go ahead and install it!
Note that if you don't import RedwoodApolloProvider, it won't be included in your bundle, dropping your bundle size quite a lot!
Server-side
Understanding Default Resolvers
According to the spec, for every field in your sdl, there has to be a resolver in your Services. But you'll usually see fewer resolvers in your Services than you technically should. And that's because if you don't define a resolver, GraphQL Yoga server will.
The key question the Yoga server asks is: "Does the parent argument (in Cedar apps, the parent argument is named root—see Cedar's Resolver Args) have a property with this resolver's exact name?" Most of the time, especially with Prisma Client's ergonomic returns, the answer is yes.
Let's walk through an example. Say our sdl looks like this:
export const schema = gql`
type User {
id: Int!
email: String!
name: String
}
type Query {
users: [User!]!
}
`
So we have a User model in our schema.prisma that looks like this:
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
}
If you create your Services for this model using Cedar's generator (yarn rw g service user), your Services will look like this:
import { db } from 'src/lib/db'
export const users = () => {
return db.user.findMany()
}
Which begs the question: where are the resolvers for the User fields—id, email, and name?
All we have is the resolver for the Query field, users.
As we just mentioned, GraphQL Yoga defines them for you. And since the root argument for id, email, and name has a property with each resolvers' exact name (i.e. root.id, root.email, root.name), it'll return the property's value (instead of returning undefined, which is what Yoga would do if that weren't the case).
But, if you wanted to be explicit about it, this is what it would look like:
import { db } from 'src/lib/db'
export const users = () => {
return db.user.findMany()
}
export const Users = {
id: (_args, { root }) => root.id,
email: (_args, { root }) => root.email,
name: (_args, { root }) => root.name,
}
The terminological way of saying this is, to create a resolver for a field on a type, in the Service, export an object with the same name as the type that has a property with the same name as the field.
Sometimes you want to do this since you can do things like add completely custom fields this way:
export const Users = {
id: (_args, { root }) => root.id,
email: (_args, { root }) => root.email,
name: (_args, { root }) => root.name,
age: (_args, { root }) =>
new Date().getFullYear() - root.birthDate.getFullYear(),
}
Cedar's Resolver Args
According to the spec, resolvers take four arguments: args, obj, context, and info. In Cedar, resolvers do take these four arguments, but what they're named and how they're passed to resolvers is slightly different:
argsis passed as the first argumentobjis namedroot(all the rest keep their names)root,context, andinfoare wrapped into an object,gqlArgs; this object is passed as the second argument
Here's an example to make things clear:
export const Post = {
user: (args, gqlArgs) =>
db.post.findUnique({ where: { id: gqlArgs?.root.id } }).user(),
}
Of the four, you'll see args and root being used a lot.
| Argument | Description |
|---|---|
args | The arguments provided to the field in the GraphQL query |
root | The previous return in the resolver chain |
context | Holds important contextual information, like the currently logged in user |
info | Holds field-specific information relevant to the current query as well as the schema details |
There's so many terms!
Half the battle here is really just coming to terms. To keep your head from spinning, keep in mind that everybody tends to rename
objto something else: Cedar calls itroot, GraphQL Yoga calls itparent.objisn't exactly the most descriptive name in the world.
Context
In Cedar, the context object that's passed to resolvers is actually available to all your Services, whether or not they're serving as resolvers. Just import it from @cedarjs/graphql-server:
import { context } from '@cedarjs/graphql-server'
How to Modify the Context
Because the context is read-only in your services, if you need to modify it, then you need to do so when calling the createGraphQLHandler function.
To populate or enrich the context on a per-request basis with additional attributes, set the context attribute that's passed to createGraphQLHandler to a custom ContextFunction that modifies the context.
For example, if we want to populate a new, custom ipAddress attribute on the context with the information from the request's event, declare the setIpAddress ContextFunction as seen here:
// ...
const ipAddress = ({ event }) => {
return (
event?.headers?.['client-ip'] ||
event?.requestContext?.identity?.sourceIp ||
'localhost'
)
}
const setIpAddress = async ({ event, context }) => {
context.ipAddress = ipAddress({ event })
}
export const handler = createGraphQLHandler({
getCurrentUser,
loggerConfig: {
logger,
options: { operationName: true, tracing: true },
},
schema: makeMergedSchema({
schemas,
services,
}),
context: setIpAddress,
onException: () => {
// Disconnect from your database with an unhandled exception.
db.$disconnect()
},
})
Note: If you use the preview GraphQL Yoga/Envelop
graphql-serverpackage and a custom ContextFunction to modify the context in the createGraphQL handler, the function is provided only the context and not the event. However, theeventinformation is available as an attribute of the context ascontext.event. Therefore, in the above example, one would fetch the ip address from the event this way:ipAddress({ event: context.event }).