loading...
Cover image for Can we make GraphQL type safe in code?
Microsoft Azure

Can we make GraphQL type safe in code?

aaronpowell profile image Aaron Powell ・7 min read

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 by:

aaronpowell profile

Aaron Powell

@aaronpowell

Hi, my name’s Aaron Powell and I’m a Cloud Developer Advocate (CDA) at Microsoft. My area of specialty is front-end web dev and .NET (especially F#), but I enjoy doing silly things with technology.

Microsoft Azure

Any language. Any platform.

Discussion

markdown guide