Building a GraphQL API
Learn how to build a GraphQL API using Encore
Encore has great support for GraphQL with its type-safe approach to building APIs.
Encore's automatic tracing also makes it easy to find and fix performance issues that often arise in GraphQL APIs (like the N+1 problem).
In this tutorial we will build a GraphQL API using Apollo and Encore.ts.
The final code will look like this:
Project
Please note
To make it easier to follow along, we've laid out a trail of croissants to guide your way. Whenever you see a 🥐 it means there's something for you to do.
1. Create your Encore application
🥐 Create a new application by running encore app create
and select Empty app
as the template.
If this is the first time you're using Encore, you'll be asked if you wish to create a free account. This is optional, but is needed when you want Encore to manage functionality like secrets and handle cloud deployments (which we'll use later on in the tutorial).
2. GraphQL setup
First, we need to install the necessary dependencies:
🥐 Update your package.json
file to look like this:
package.json{
"name": "encore-graphql",
"private": true,
"version": "0.0.1",
"license": "MPL-2.0",
"type": "module",
"scripts": {
"generate": "graphql-codegen --config codegen.yml"
},
"devDependencies": {
"@types/node": "^20.5.7",
"typescript": "^5.2.2",
"@graphql-codegen/cli": "2.16.5",
"@graphql-codegen/typescript": "2.8.8",
"@graphql-codegen/typescript-resolvers": "2.7.13"
},
"dependencies": {
"@apollo/server": "^4.11.0",
"encore.dev": "^1.35.3",
"graphql": "^16.9.0",
"graphql-tag": "^2.12.6"
}
}
🥐 Run npm install
to install the dependencies.
🥐 Next, create a codegen.yml
file in the application root containing:
codegen.yml# This configuration file tells GraphQL Code Generator how to generate types based on our schema.
schema: './schema.graphql'
generates:
# Specify where our generated types should live.
./graphql/__generated__/resolvers-types.ts:
plugins:
- 'typescript'
- 'typescript-resolvers'
config:
useIndexSignature: true
3. Add GraphQL schema
Now it's time to define the GraphQL schema.
🥐 Create a schema.graphqls
file in the application root containing:
schema.graphqlstype Query { books: [Book] } type Book { title: String! author: String! } type AddBookMutationResponse { code: String! success: Boolean! message: String! book: Book } type Mutation { addBook(title: String!, author: String!): AddBookMutationResponse }
🥐 Run the code generation script to generate the resolver types:
$ npm run generate
The types will be written to graphql/__generated__/resolvers-types.ts
and will contain a bunch of types that we can use when implementing the resolvers.
4. Create a Book service
Let's create a simple book service that we can later query using GraphQL. It's a good idea to to make the GraphQL library query Encore endpoints because that will result in traces being created for each called endpoint. Having tracing makes it easy to find and fix performance issues that often arise in GraphQL APIs.
🥐 In your application's root folder, create a directory named book
containing a file named encore.service.ts
.
$ mkdir book$ touch book/encore.service.ts
🥐 Add the following code to book/encore.service.ts
:
book/encore.service.tsimport { Service } from "encore.dev/service";
export default new Service("book");
This is how you define a service with Encore. Encore will now consider files in the book
directory and all its subdirectories as part of the book
service.
🥐 Next, create a book/book.ts
file containing:
import { api, APIError } from "encore.dev/api";
import { Book } from "../graphql/__generated__/resolvers-types";
const db: Book[] = [
{
title: "To Kill a Mockingbird",
author: "Harper Lee",
},
{
title: "1984",
author: "George Orwell",
},
{
title: "The Great Gatsby",
author: "F. Scott Fitzgerald",
},
{
title: "Moby-Dick",
author: "Herman Melville",
},
{
title: "Pride and Prejudice",
author: "Jane Austen",
},
];
export const list = api(
{ expose: true, method: "GET", path: "/books" },
async (): Promise<{ books: Book[] }> => {
return { books: db };
},
);
// Omit the "__typename" field from the request
type AddRequest = Omit<Required<Book>, "__typename">;
export const add = api(
{ expose: true, method: "POST", path: "/book" },
async (book: AddRequest): Promise<{ book: Book }> => {
if (db.some((b) => b.title === book.title)) {
throw APIError.alreadyExists(
`Book "${book.title}" is already in database`,
);
}
db.push(book);
return { book };
},
);
The book
service contains two endpoint, one for listing all books and another to add a new book to the database. Our "database" is hardcoded just to limit the scope of this example. Take a look at the Using SQL databases docs to learn how to set up and use a database.
We get the Book
type from the generated resolver types. This will make it easier later when we create the resolver functions.
5. Create the GraphQL service
Now it's time to create our Encore service that will provide the GraphQL API.
🥐 In the graphql
directory, add a encore.service.ts
file with the following content:
graphql/encore.service.tsimport { Service } from "encore.dev/service";
export default new Service("graphql");
Now, we need to create resolvers that call the book
service. Since the GraphQL API uses the same types as the Encore API exposes (we import types form resolvers-types.ts
in book.ts
), our resolver can just be thin wrapper around out API endpoints.
🥐 Create the directory resolvers
in the graphql
directory. In the resolvers directory we want to place three files: index.ts
, queries.ts
and mutations.ts
:
resolvers/index.tsresolvers/queries.tsresolvers/mutations.tsimport { Resolvers } from "../__generated__/resolvers-types";
import Query from "./queries.js";
import Mutation from "./mutations.js";
const resolvers: Resolvers = { Query, Mutation };
export default resolvers;
Now we are ready can create the ApolloServer that makes use of our resolvers and to expose our GraphQL endpoint.
🥐 Still in the graphql
directory, create a graphql.ts
file containing:
graphql/graphql.tsimport { api } from "encore.dev/api";
import { ApolloServer, HeaderMap } from "@apollo/server";
import { readFileSync } from "node:fs";
import resolvers from "./resolvers";
import { json } from "node:stream/consumers";
const typeDefs = readFileSync("./schema.graphql", { encoding: "utf-8" });
const server = new ApolloServer({
typeDefs,
resolvers,
});
await server.start();
export const graphqlAPI = api.raw(
{ expose: true, path: "/graphql", method: "*" },
async (req, res) => {
server.assertStarted("/graphql");
const headers = new HeaderMap();
for (const [key, value] of Object.entries(req.headers)) {
if (value !== undefined) {
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
}
}
// More on how to use executeHTTPGraphQLRequest: https://www.apollographql.com/docs/apollo-server/integrations/building-integrations/
const httpGraphQLResponse = await server.executeHTTPGraphQLRequest({
httpGraphQLRequest: {
headers,
method: req.method!.toUpperCase(),
body: await json(req),
search: new URLSearchParams(req.url ?? "").toString(),
},
context: async () => {
return { req, res };
},
});
for (const [key, value] of httpGraphQLResponse.headers) {
res.setHeader(key, value);
}
res.statusCode = httpGraphQLResponse.status || 200;
if (httpGraphQLResponse.body.kind === "complete") {
res.end(httpGraphQLResponse.body.string);
return;
}
for await (const chunk of httpGraphQLResponse.body.asyncIterator) {
res.write(chunk);
}
res.end();
},
);
This creates an Raw API endpoint available on /graphql
. In the endpoint we use ApolloServer to handle the GraphQL queries and mutations. We then return the response to the client.
If we were to use another GraphQL library other than Apollo, the concept would still be the same:
- Take client requests with a Raw endpoint.
- Pass along the request and response objects to the GraphQL library of your choice.
- Use the library to handle the GraphQL queries and mutations.
- Return the GraphQL response from the Raw endpoint.
6. Trying it out
With that, the GraphQL API is done!
🥐Try it out by running encore run
and opening https://studio.apollographql.com/sandbox in your browser. Set http://localhost:4000/graphql as your endpoint URL. You should now be able to read the schema and execute queries.
Enter the query:
mutation AddBook { addBook(author: "J.R.R. Tolkien", title: "The Hobbit") { success message code } }
Now try the GetBooks query:
query GetBooks { books { author title } }
And you should now see the "The Hobbit" in the list of books.
🥐 Try opening the Local Development Dashboard at http://localhost:9400 and view the traces that were generated when calling your GraphQL API.
7. Deploy to the cloud
You can see how to configure and deploy your application in the self-hosting documentation, or you can use Encore Cloud Platform to automate infrastructure provisioning. More on that below:
🥐 Push your changes and deploy your application to Encore's free development cloud by running:
$ git add -A .$ git commit -m 'Initial commit'$ git push encore
Encore will now build and test your app, provision the needed infrastructure, and deploy your application to the cloud.
After triggering the deployment, you will see a URL where you can view its progress in the Encore Cloud dashboard. It will look something like: https://app.encore.cloud/$APP_ID/deploys/...
From there you can also see metrics, traces, link your app to a GitHub repo to get automatic deploys on new commits, and connect your own AWS or GCP account to use for production deployment.
Celebrate with fireworks
Now that your app is running in the cloud, let's celebrate with some fireworks:
🥐 In the Cloud Dashboard, open the Command Menu by pressing Cmd + K (Mac) or Ctrl + K (Windows/Linux).
From here you can easily access all Cloud Dashboard features and for example jump straight to specific services in the Service Catalog or view Traces for specific endpoints.
🥐 Type fireworks
in the Command Menu and press enter. Sit back and enjoy the show!
Conclusion
We've now built a GraphQL API gateway that forwards requests to the application's underlying Encore services in a type-safe way with minimal boilerplate.
Note that the concepts discussed here are general and can be easily adapted to any GraphQL schema.
Whenever you make a change to the schema or configuration, re-run npm run generate
to
regenerate the resolver types.