DEV Community

loading...
Cover image for Typing and mocking a GraphQL API server with Apollo

Typing and mocking a GraphQL API server with Apollo

Eka
Web developer, late bloomer. Can center an element with grace and style (pun intended). Mostly sensible (citation needed).
・11 min read

Previously in this series, I made a GraphQL server that returns nothing. Now I'm going to design the schema (type defintions) and set up mocking functionality. Since I have yet to build the resolvers (which retrieve data from a database or other sources), the server will now return mock data based on the schema.

Overview

I'm building the API for a hypothetical app that displays my favourite music releases. The API server shall return a list of music releases; each list item shall have a title, an artwork image, artist name(s), and URL to play on Spotify.

I borrowed the basic models and descriptions from Spotify Web API official documentation via an app that converts Spotify API’s objects into GraphQL schema I’d built earlier, liberally simplified and modified for this purpose.

TL;DR? View (and fork) the live CodeSandbox app

Part 1: Typing

A schema describes our GraphQL API’s data models, relations, and operations. It is comprised of type definitions written in a syntax called the Schema Definition Language (also called the "GraphQL schema").

So... what exactly are these types that we define? GraphQL recognizes a number of type categories.

  • Entry-point types: Query and Mutation
  • Object types
  • (Default/built-in) Scalar types
  • Custom scalar types
  • Enum types
  • Input types
  • Abstract types: unions and interfaces

We are only using some of those now, but I'm including references at the end of the post if you’d like to learn more.

Quick copypasta of types we're going to use:

# Query type
type Query { ... }

# Object types
type Artist { ... }
type Album { ... }
type Track { ... }
type Image { ... }

# Enum type
enum AlbumType { ... }

# Interface type and object type that implements it
interface Release { ... }
type Album implements Release { ... }

# Union type (we're discussing but _not_ using this)
union Release = Album | Track
Enter fullscreen mode Exit fullscreen mode

Object types

An object type represents a kind of object (...who would’ve thought? 😬) containing a collection of fields (properties) that can be fetched from some data source(s). A GraphQL schema is usually largely comprised of these types.

For our schema, let’s define the object types Artist, Album, Track, and Image (truncated for brevity).

type Artist {
  id: ID!
  name: String!
  images: [Image!]
}

type Album {
  id: ID!
  name: String!
  artists: [Artist!]!
  images: [Image!]
  release_date: String
}

type Track {
  id: ID!
  name: String!
  artists: [Artist!]!
  album: Album!
  track_number: Int
  preview_url: String
}

type Image {
  url: String!
  height: Int
  width: Int
}
Enter fullscreen mode Exit fullscreen mode
  • An object type must contain at least one field. The object type Artist contains fields called id, name, and images (and so on).
  • The field type can be...
    • a scalar type (ID, String, Int—details below),
    • or another object type (eg. in the Track object type, the album field contains an Album object).
  • The exclamation mark ! indicates a required field; lack of which indicates nullable.
  • The square brackets [] indicates a list/array of the type inside the brackets.
    • eg. [String] = a list of strings, [Artist] = a list of Artist objects.
  • artists: [Artist!]! means the artists field is required and cannot be null (right-side/outer exclamation mark). It cannot return an empty array, either; it must contain at least one Artist object (inner exclamation mark).

Note that the SDL is agnostic with regards to data sources; we don’t have to define object types and fields in relation to any database table/column structure.

Query type

This is the only required part of a schema. Technically, it’s possible to have a server without a single object type. But we will get an error if the Query type does not exist.

Query and Mutation are GraphQL’s special top-level, entry point types. Clients, ie. front-end apps, can only retrieve (read) data through Query and store/update/delete (write) data through Mutation.

Unlike a REST API, a GraphQL API has no multiple endpoints. Clients don't send a GET request to /albums for a list of albums and another to /artists/:id for the artist data; they simply send a single query with a list of fields they need, which are the fields in our Query type.

type Query {
+  albums: [Album!]
+  tracks: [Track!]
}
Enter fullscreen mode Exit fullscreen mode

With our schema above, clients can query for albums, which will include the relevant artists and images data (if they include those fields in the request). Likewise for tracks. But they cannot get a list of artists or images, since our type definition does not include such fields.

Like object types and Mutation, Query fields can accept arguments.

type Query {
  albums: [Album!]
  tracks: [Track!]
+ album(id: ID!): Album
+ track(id: ID!): Track
}
Enter fullscreen mode Exit fullscreen mode

The last two fields enable clients to query a single album or a single track by passing an id as argument.

Note: We are not discussing Mutation here, but it's essentially Query's counterpart for POST/PUT/DELETE operations.

Scalar types

Some fields above have types like Int and String—these are the default/built-in scalar types, which are GraphQL schema’s primitive types equivalent. There are five scalar types: ID, String, Int, Float, Boolean.

We can also define our own custom scalar types. Apollo Server provides the GraphQLScalarType constructor class to facilitate defining our custom scalar logic, which we then pass to a custom resolver. I'm going to discuss this part in a separate post.

Enum types

Like other typed languages, GraphQL SDL has an enumerated type, a.k.a. enum. An enum field type can only have one of the predefined string values.

The Spotify API’s AlbumObject has an album_type field, originally typed String, whose value is one of "album", "single", or "compilation". Let’s define it as an enum type in our schema.

type Album {
  id: ID!
  name: String!
  artists: [Artist!]!
  images: [Image!]
  release_date: String
+ album_type: AlbumType
}

+ enum AlbumType {
+   ALBUM
+   SINGLE
+   COMPILATION
+ }
Enter fullscreen mode Exit fullscreen mode

In addition to type safety (when querying and displaying on the frontend, when passing as query argument, as well as when making mutations), using enum types also gives us the benefit of intellisense/autocompletion in supported IDEs.

IDE showing available AlbumType values

Note that the enum values have to be in UPPERCASE as per GraphQL specs. If the values in the data source (eg. database) are in lowercase, we can map them in a custom resolver function. Not only for mapping cases of course, we can use it eg. to map colour shorthands into corresponding hex values, as shown in the Apollo Server documentation.

Union and interface types

The last types we're going to discuss are the abstract types. Essentially, they represent a type that could be any of multiple object types. Quick definitions by GraphQL Ruby:

  • Interface: "a list of fields which may be implemented by object types"
  • Union: "a set of object types which may appear in the same spot"

(🤔 ...huh?)

We're going to see how either one could be used in our schema to describe "a music release, which could either be an album or a track".

Option A — with union

One possible approach is by defining a union type called Release, which could either be an Album or a Track object type. Then we add a field called releases to the query, which returns a list of Release objects.

+ union Release = Album | Track

type Query {
  albums: [Album!]
  tracks: [Track!]
+ releases: [Release!]
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • The Album and Track definitions stay the same.
  • A union can only consist of two or more object types, not scalar or any other types.

Unexpectedly (to me), this type is not intended for mixed scalar and object types, eg. a field that could be an integer or a particular object type. We need custom scalars for that instead. Related discussions:

Option B — with interface

Alternatively, we can define an interface type called Release, which is then implemented by the Album and Track object types. It contains the fields id, name, and artists, which both Album and Track have. Like in option A, we add a field called releases to the query, which returns a list of Release objects.

+ interface Release {
+   id: ID!
+   name: String!
+   artists: [Artist!]!
+ }


- type Album {
+ type Album implements Release {
  id: ID!
  name: String!
  artists: [Artist!]!
  images: [Image!]
  release_date: String
}

- type Track {
+ type Track implements Release {
  id: ID!
  name: String!
  artists: [Artist!]!
  album: Album!
  track_number: Int
  preview_url: String
}

type Query {
  albums: [Album!]
  tracks: [Track!]
+ releases: [Release!]
}
Enter fullscreen mode Exit fullscreen mode

Another unexpected-to-me finding: We still have to include the common interface fields in the object type fields (in this case id, name, artists). Yes, we write id: ID! three times and they have to be identical.

So... are they interchangeable?

I ended up in a rabbit hole researching these two types’ difference in usage, but it is an interesting topic! I will probably revisit this in a separate post.

TL;DR + IMO: They are conceptually different, but share similar characteristics as abstract types, which enable them to solve the same problems (such as our Release type) albeit in different ways.

On a more practical level, when querying, we use inline fragments differently with union and interface types.

A union type does not know what fields each object type does/does not have. So we have to specify all fields inside the inline fragments, including the identical ones (eg. name and artists).

query GetUnionReleases {
  latest_releases {
    ... on Album {
      # name and artists fields here...
      name
      artists
      images
    }
    ... on Track {
      # ...name and artists fields again here
      name
      artists
      album
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Meanwhile, with an interface type we can specify the common fields (the interface’s fields) outside the fragments. The Release interface always contains name and artists fields, so we can query them outside the inline fragments.

query GetInterfaceReleases {
  latest_releases {
    # interface’s fields
    name
    artists
    # object type specific fields in the fragments
    ... on Album {
      images
    }
    ... on Track {
      album
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

These queries will be made from the clients that consume our API, but you can try it from the server app’s GraphQL Playground IDE. For this app, I decided to use the interface type to define Release (option B).

Half-time

Let’s see our code so far.

// index.js
const { ApolloServer, gql } = require("apollo-server");

const typeDefs = gql`
  type Query {
    albums: [Album!]
    tracks: [Track!]
    releases: [Release!]
  }
  # ... the rest of the schema
`;

const server = new ApolloServer({ typeDefs });

server.listen().then(({ url }) => {
  console.log(`🚀 Server ready at ${url}`);
});
Enter fullscreen mode Exit fullscreen mode

The server is up and running, and the GraphQL Playground IDE shows the documentation and autocomplete feature based on our type definitions.

GraphQL Playground IDE showing releases query with autocomplete fields and list of available objects on the right pane

We can also use Apollo Studio to access—among other things—a more powerful and dev-friendly query builder.

Apollo Studio query builder

Above we can see the Release interface detail, including the types that implement it. When I select the images field under Album, it automatically builds the inline fragment in the query. Smooth! 😎

But when we run the query, the server returns null because we have not set up the resolvers, which will return each field value. Fortunately, Apollo Server has an extensive mocking feature based on our type definitions.

Part 2: Mocking

Mocks out of the box

Add a mocks: true option when creating a server instance to enable the default mock resolvers.

const server = new ApolloServer({
  typeDefs,
  mocks: true,
});
Enter fullscreen mode Exit fullscreen mode

When we send the query... the returned data is no longer null.

GraphQL IDE showing mock data

Let's query some more data...

query Query {
  releases {
    id
    name
    artists {
      name
    }
    ... on Album {
      album_type
      name
      release_date
    }
    ... on Track {
      name
      track_number
      explicit
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

...and here is the returned mock data.

{
  "data": {
    "releases": [
      {
        "id": "f4570e2d-9221-4a8e-ae5d-6cf0bcb3c10e",
        "name": "Hello World",
        "artists": [
          {
            "name": "Hello World"
          },
          {
            "name": "Hello World"
          }
        ],
        "track_number": -65,
        "explicit": false
      },
      {
        "id": "be550404-7bad-46c7-a314-cb88ceec1710",
        "name": "Hello World",
        "artists": [
          {
            "name": "Hello World"
          },
          {
            "name": "Hello World"
          }
        ],
        "album_type": "COMPILATION",
        "release_date": "Hello World"
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode
  • ID is mocked as random string uuid. Nice!
  • String is mocked as Hello world.
  • Int is randomly generated.
  • Boolean and enum types are correctly typed!
  • Array types always return 1-2 items (depending on the number of fields).
  • We don't have custom scalar type here. If we did, we would have to pass a custom resolver for the mock to work.

This is pretty cool, considering it takes literally five seconds to implement. But let's make the mock data resemble our expected data better.

Custom mock resolvers

Pass an object to the mocks option to enable custom mock resolvers. As the name suggests, they are identically shaped to the actual resolvers, both of which correspond to the schema. Each property is a function that returns the corresponding mock value.

For example, this replaces "Hello world" with "My custom mock string" for the String scalar type value.

- const mocks = true;
+ const mocks = {
+   String: () => "My custom mock string"
+ };

const server = new ApolloServer({
   typeDefs,
+  mocks,
});
Enter fullscreen mode Exit fullscreen mode

But the album, track, and artist names are all String. Does not make much sense if they all return "My custom mock string", does it?

Luckily, we can mock our object types the same way. Remove the String function and add Album, Track, and Artist functions.

const mocks = {
-   String: () => "My custom mock string"
+   Album: () => {
+     return { name: () => "Album Title" };
+   },
+   Track: () => {
+     return { name: () => "Track Title" };
+   },
+   Artist: () => {
+     return { name: () => "Artist Name" };
+   }
};
Enter fullscreen mode Exit fullscreen mode

Let's send another query and see the returned mock data.

query Query {
  releases {
    __typename
    name
    artists {
      name
    }
    ... on Album {
      album_type
    }
    ... on Track {
      track_number
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
{
  "data": {
    "releases": [
      {
        "__typename": "Track",
        "name": "Track Title",
        "artists": [
          {
            "name": "Artist Name"
          },
          {
            "name": "Artist Name"
          }
        ],
        "track_number": 33
      },
      {
        "__typename": "Album",
        "name": "Album Title",
        "artists": [
          {
            "name": "Artist Name"
          },
          {
            "name": "Artist Name"
          }
        ],
        "album_type": "ALBUM"
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that although we only pass the name function, the mock logic uses the default mock logic for the remaining fields (eg. track_number in Track, album_type in Album). Nice!

MockList

Lists—such as releases and artists—return two items by default. What if we need different number of items mocked? Apollo Server provides the MockList constructor to customize the number of items in a list.

const { 
   ApolloServer,
+  MockList 
} = require("apollo-server");

const mocks = {
  Album: () => {
-   return { name: () => "Album Title" };
+   return { name: () => "Album Title", artists: () => new MockList(1) };
  },

  Track: () => {
-   return { name: () => "Track Title" };
+   return { name: () => "Track Title", artists: () => new MockList(1) };
  },

+ Query: () => {
+   return { releases: () => new MockList([0, 15]) };
+ }
}
Enter fullscreen mode Exit fullscreen mode
  • Although an album or track can have more than one artists (eg. featuring/collaboration), most albums and tracks just have one artist. Let's add an artists mock resolver to Album and Track which returns a MockList object. The integer argument 1 means always return one item.
  • Meanwhile in Query, we add releases that return between 0 to 15 items, using the array argument [0, 15]. This is particularly useful for testing and UI development/prototyping—what an empty state looks like, what the pagination looks like, what an "awkward" number of item (eg. just 1) looks like.

Now when we run our query, the returned mock data looks like this.

GraphQL IDE showing more than 2 releases

It's still not ideal (track_number: -60 anyone? 😬), but we've got some mock data to test and start developing the UI/frontend app with.

Putting it together

In this post, we define our API’s types and their relations (Query, object types, enums, interface) in the GraphQL schema language and pass it as template literal to Apollo Server’s gql parser. Then we use the built-in mocks option so our server returns mock data based on our type definitions. We use custom mock resolvers to customize our mock data behaviour.

Stay tuned for the next post in this series, where I'm going to improve my mocks with casual fake data generator!

References

Discussion (0)