GraphQL on Azure: Part 5 - Can we make GraphQL type safe in code?
Aaron Powell
Posted on September 15, 2020
This article is part of #ServerlessSeptember (https://aka.ms/ServerlessSeptember2020). You’ll find other helpful articles, detailed tutorials, and videos in this all-things-Serverless content collection. New articles from community members and cloud advocates are published every week from Monday to Thursday through September.
Find out more about how Microsoft Azure enables your Serverless functions at https://docs.microsoft.com/azure/azure-functions/
I've been doing a lot of work recently with GraphQL on Azure Functions and something that I find works nicely is the schema-first approach to designing the GraphQL endpoint.
The major drawback I've found though is that you start with a strongly typed schema but lose that type information when implementing the resolvers and working with your data model.
So let's have a look at how we can tackle that by building an application with GraphQL on Azure Functions and backing it with a data model in CosmosDB, all written in TypeScript.
To learn how to get started with GraphQL on Azure Functions, check out this article from #ServerlessSeptember (with the main difference being we're using TypeScript, not JavaScript).
Creating our schema
The API we're going to build today is a trivia API (which uses data from Open Trivia DB as the source).
We'll start by defining a schema that'll represent the API as a file named schema.graphql
within the graphql folder:
type Question {
id: ID!
question: String!
correctAnswer: String!
answers: [String!]!
}
type Query {
question(id: ID!): Question
getRandomQuestion: Question
}
type Answer {
questionId: ID
question: String!
submittedAnswer: String!
correctAnswer: String!
correct: Boolean
}
type Mutation {
answerQuestion(id: ID, answer: String): Answer
}
schema {
query: Query
mutation: Mutation
}
Our schema has defined two core types, Question
and Answer
, along with a few queries and a mutation and all these types are decorated with useful GraphQL type annotations, that would be useful to have respected in our TypeScript implementation of the resolvers.
Creating a resolver
Let's start with the query resolvers, this will need to get back the data from CosmosDB to return the our consumer:
const resolvers = {
Query: {
question(_, { id }, { dataStore }) {
return dataStore.getQuestionById(id);
},
async getRandomQuestion(_, __, { dataStore }) {
const questions = await dataStore.getQuestions();
return questions[Math.floor(Math.random() * questions.length) + 1];
},
},
};
export default resolvers;
This matches the query portion of our schema from the structure, but how did we know how to implement the resolver functions? What arguments do we get to question
and getRandomQuestion
? We know that question
will receive an id
parameter, but how? If we look at this in TypeScript there's any
all over the place, and that's means we're not getting much value from TypeScript.
Here's where we start having a disconnect between the code we're writing, and the schema we're working against.
Enter GraphQL Code Generator
Thankfully, there's a tool out there that can help solve this for us, GraphQL Code Generator. Let's set it up by installing the tool:
npm install --save-dev @graphql-codegen/cli
And we'll setup a config file named config.yml
in the root of our Functions app:
overwrite: true
schema: "./graphql/schema.graphql"
generates:
graphql/generated.ts:
plugins:
- typescript
- typescript-resolvers
This will generate a file named generated.ts
within the graphql
folder using our schema.graphql
as the input. The output will be TypeScript and we're also going to generate the resolver signatures using the typescript
and typescript-resolvers
plugins, so we best install those too:
npm install --save-dev @graphql-codegen/typescript @graphql-codegen/typescript-resolvers
It's time to run the generator:
npx graphql-codegen --config codegen.yml
Strongly typing our resolvers
We can update our resolvers to use this new type information:
import { Resolvers } from "./generated"
const resolvers: Resolvers = {
Query: {
question(_, { id }, { dataStore }) {
return dataStore.getQuestionById(id);
},
async getRandomQuestion(_, __, { dataStore }) {
const questions = await dataStore.getQuestions();
return questions[Math.floor(Math.random() * questions.length) + 1];
},
},
};
export default resolvers;
Now we can hover over something like id
and see that it's typed as a string
, but we're still missing a piece, what is dataStore
and how do we know what type to make it?
Creating a data store
Start by creating a new file named data.ts
. This will house our API to work with CosmosDB, and since we're using CosmosDB we'll need to import the node module:
npm install --save @azure/cosmos
Why CosmosDB? CosmosDB have just launched a serverless plan which works nicely with the idea of a serverless GraphQL host in Azure Functions. Serverless host with a serverless data store, sound like a win all around!
With the module installed we can implement our data store:
import { CosmosClient } from "@azure/cosmos";
export type QuestionModel = {
id: string;
question: string;
category: string;
incorrect_answers: string[];
correct_answer: string;
type: string;
difficulty: "easy" | "medium" | "hard";
};
interface DataStore {
getQuestionById(id: string): Promise<QuestionModel>;
getQuestions(): Promise<QuestionModel[]>;
}
class CosmosDataStore implements DataStore {
#client: CosmosClient;
#databaseName = "trivia";
#containerName = "questions";
#getContainer = () => {
return this.#client
.database(this.#databaseName)
.container(this.#containerName);
};
constructor(client: CosmosClient) {
this.#client = client;
}
async getQuestionById(id: string) {
const container = this.#getContainer();
const question = await container.items
.query<QuestionModel>({
query: "SELECT * FROM c WHERE c.id = @id",
parameters: [{ name: "@id", value: id }],
})
.fetchAll();
return question.resources[0];
}
async getQuestions() {
const container = this.#getContainer();
const question = await container.items
.query<QuestionModel>({
query: "SELECT * FROM c",
})
.fetchAll();
return question.resources;
}
}
export const dataStore = new CosmosDataStore(
new CosmosClient(process.env.CosmosDB)
);
This class will receive a CosmosClient
that gives us the connection to query CosmosDB and provides the two functions that we used in the resolver. We've also got a data model, QuestionModel
that represents how we're storing the data in CosmosDB.
To create a CosmosDB resource in Azure, check out their quickstart and here is a data sample that can be uploaded via the Data Explorer in the Azure Portal._
To make this available to our resolvers, we'll add it to the GraphQL context by extending index.ts
:
import { ApolloServer } from "apollo-server-azure-functions";
import { importSchema } from "graphql-import";
import resolvers from "./resolvers";
import { dataStore } from "./data";
const server = new ApolloServer({
typeDefs: importSchema("./graphql/schema.graphql"),
resolvers,
context: {
dataStore,
},
});
export default server.createHandler();
If we run the server, we'll be able to query the endpoint and have it pull data from CosmosDB but our resolver is still lacking a type for dataStore
, and to do that we'll use a custom mapper.
Custom context types
So far, the types we're generating are all based off what's in our GraphQL schema, and that works mostly but there are gaps. One of those gaps is how we use the request context in a resolver, since this doesn't exist as far as the schema is concerned we need to do something more for the type generator.
Let's define the context type first by adding this to the bottom of data.ts
:
export type Context = {
dataStore: DataStore;
};
Now we can tell GraphQL Code Generator to use this by modifying our config:
overwrite: true
schema: "./graphql/schema.graphql"
generates:
graphql/generated.ts:
config:
contextType: "./data#Context"
plugins:
- "typescript"
- "typescript-resolvers"
We added a new config
node in which we specify the contextType
in the form of <path>#<type name>
and when we run the generator the type is used and now the dataStore
is typed in our resolvers!
Custom models
It's time to run our Function locally.
npm start
And let's query it. We'll grab a random question:
{
getRandomQuestion {
id
question
answers
}
}
Unfortunately, this fails with the following error:
Cannot return null for non-nullable field Question.answers.
If we refer back to our Question
type in the GraphQL schema:
type Question {
id: ID!
question: String!
correctAnswer: String!
answers: [String!]!
}
This error message makes sense as answers
is a non-nullable array of non-nullable strings ([String!]!
), but if that's compared to our data model in Cosmos:
export type QuestionModel = {
id: string;
question: string;
category: string;
incorrect_answers: string[];
correct_answer: string;
type: string;
difficulty: "easy" | "medium" | "hard";
};
Well, there's no answers
field, we only have incorrect_answers
and correct_answer
.
It's time to extend our generated types a bit further using custom models. We'll start by updating the config file:
overwrite: true
schema: "./graphql/schema.graphql"
generates:
graphql/generated.ts:
config:
contextType: "./data#Context"
mappers:
Question: ./data#QuestionModel
plugins:
- "typescript"
- "typescript-resolvers"
With the mappers
section, we're telling the generator when you find the Question
type in the schema, it's use QuestionModel
as the parent type.
But this still doesn't tell GraphQL how to create the answers
field, for that we'll need to define a resolver on the Question
type:
import { Resolvers } from "./generated";
const resolvers: Resolvers = {
Query: {
question(_, { id }, { dataStore }) {
return dataStore.getQuestionById(id);
},
async getRandomQuestion(_, __, { dataStore }) {
const questions = await dataStore.getQuestions();
return questions[Math.floor(Math.random() * questions.length) + 1];
},
},
Question: {
answers(question) {
return question.incorrect_answers
.concat([question.correct_answer])
.sort();
},
correctAnswer(question) {
return question.correct_answer;
},
},
};
export default resolvers;
These field resolvers will receive a parent as their first argument that is the QuestionModel
and expect to return the type as defined in the schema, making it possible to do mapping of data between types as required.
If you restart your Azure Functions and execute the query from before, a random question is returned from the API.
Conclusion
We've taken a look at how we can build on the idea of deploying GraphQL on Azure Functions and looked at how we can use the GraphQL schema, combined with our own models, to enforce type safety with TypeScript.
We didn't implement the mutation in this post, that's an exercise for you as the reader to tackle.
You can check out the full example, including how to connect it with a React front end, on GitHub.
Posted on September 15, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.