From 4405faa6af6614ea837dd247750c4d6292e54238 Mon Sep 17 00:00:00 2001 From: Simon Johansson Date: Thu, 26 Sep 2024 14:53:20 +0200 Subject: [PATCH] Add TS GraphQL tutorial --- docs/tutorials/graphql.mdx | 401 ++++++++++++++++++++++++++++++++++++- 1 file changed, 400 insertions(+), 1 deletion(-) diff --git a/docs/tutorials/graphql.mdx b/docs/tutorials/graphql.mdx index 6d8b3c3238..9b99f7253c 100644 --- a/docs/tutorials/graphql.mdx +++ b/docs/tutorials/graphql.mdx @@ -297,6 +297,405 @@ see the [gqlgen documentation](https://gqlgen.com/). - A TypeScript tutorial for using GraphQL with Encore is coming soon. + +In this tutorial we will build a GraphQL API using [Apollo](https://www.apollographql.com/docs/apollo-server/) and Encore.ts. + +The final code will look like this: + +
+ +
+ + + +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 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: + +```json +-- 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.graphqls -- +type 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: + +```shell +$ 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`. + +```shell +$ mkdir book +$ touch book/encore.service.ts +``` + +🥐 Add the following code to `book/encore.service.ts`: + +```ts +-- book/encore.service.ts -- +import { 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: + +```ts +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, "__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/ts/primitives/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: + +```ts +-- graphql/encore.service.ts -- +import { 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`: + +```ts +-- resolvers/index.ts -- +import { Resolvers } from "../__generated__/resolvers-types"; +import Query from "./queries.js"; +import Mutation from "./mutations.js"; + +const resolvers: Resolvers = { Query, Mutation }; + +export default resolvers; +-- resolvers/queries.ts -- +import { book } from "~encore/clients"; +import { QueryResolvers } from "../__generated__/resolvers-types"; + +// Use the generated `QueryResolvers` type to type check our queries! +const queries: QueryResolvers = { + books: async () => { + const { books } = await book.list(); + return books; + }, +}; + +export default queries; +-- resolvers/mutations.ts -- +import { book } from "~encore/clients"; +import { MutationResolvers } from "../__generated__/resolvers-types"; +import { APIError } from "encore.dev/api"; + +// Use the generated `MutationResolvers` type to type check our mutations +const mutations: MutationResolvers = { + addBook: async (_, { title, author }) => { + try { + const resp = await book.add({ title, author }); + return { + book: resp.book, + success: true, + code: "ok", + message: "New book added", + }; + } catch (err) { + const apiError = err as APIError; + + return { + book: null, + success: false, + code: apiError.code, + message: apiError.message, + }; + } + }, +}; + +export default mutations; +``` + +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: + +```ts +-- graphql/graphql.ts -- +import { 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](https://encore.dev/docs/ts/primitives/raw-endpoints) 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 then Apollo the concept still would be the same: +1. Take client requests with a Raw endpoint. +2. Pass along the request and response objects to the GraphQL library of your choice. +3. Use the library to handle the GraphQL queries and mutations. +4. 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](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: +```graphql +mutation AddBook { + addBook(author: "J.R.R. Tolkien", title: "The Hobbit") { + success + message + code + } +} +``` + +Now try the GetBooks query: + +```graphql +query GetBooks { + books { + author + title + } +} +``` + +And you should see the "The Hobbit" in the list of books. + +🥐 Try opening the Local Development Dashboard at [http://localhost:9400](http://localhost:9400) and view the traces that got generated when calling your GraphQL API. + +## 7. Deploy to the cloud + +🥐 Push your changes and deploy your application to Encore's free development cloud by running: + +```shell +$ 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 Encore's [Cloud Dashboard](https://app.encore.dev). It will look something like: `https://app.encore.dev/$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! + +![Fireworks](/assets/docs/fireworks.jpg) + +## 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. +