Part 3 from series: How to build a real-time chat app

GraphQL Queries

Retrieve a list of messages from Slack to build a GraphQL query

We will create the implementation for our GraphQL messages query to enabled the application to retrieve a list of messages from Slack.

From the web-api directory of your project folder, create the following folder structure:

|--api
|  |--slack-integration
|  |  |--data-mappings
|  |  |--data-sources
|  |  |--resolvers

Alternatively open your terminal or command-prompt and run the following commands to create the same folder structure:

  • cd <project-folder>/web-api/api
  • mkdir -p ./api/slack-integration
  • cd ./api/slack-integration
  • mkdir ./data-mappings
  • mkdir ./data-sources
  • mkdir ./resolvers

Then create a file named messages.messages.query.js in the new resolvers folder and open it in your favourite IDE.

  • cd ./resolvers
  • touch messages.messages.query.js

In this file we will start by defining an asynchronous function for the messages query of our GraphQL schema.

File path icon web-api/api/slack-integration/resolvers/messages.messages.query.js
module.exports = async function messages (parent, args, { dataSources }) {

}

GraphQL query resolvers all take four parameters. A parent parameter is an object containing the result of the parent resolvers postcondition as composed in the GraphQL schema. An args parameter that holds all variables the query was called with. A context argument to store supplemental data defining the context in which the query was called. This is where you can store things like user information for each query. Here we are destructuring the context object to use dataSources as a variable in our new function. There is also a fourth parameter that stores information about the execution state but we won't need this here.

Message conversations

The Slack API has the concept of channels, which fits closely to our world of message conversations. We will need to get the correct conversation associated with the current user before displaying the list of messages. For now we are going to hard-code a channel name that we will always use.

We will also need the Slack client for Node, which has been conveniently installed with the boilerplate project to interact with the Slack API and retrieve conversation data.

We will need to create a new instance of the WebClient with our Slack web token from the environment variables that we previously setup to succesfully communicate and access Slack API data. And create three functions above our messages function to get the conversation and message history from Slack.

File path icon web-api/api/slack-integration/resolvers/messages.messages.query.js
const { WebClient } = require('@slack/client');

const webAPI = new WebClient(process.env.SLACK_WEB_ACCESS_TOKEN);

/**
 * Get the complete list of conversations associated with your Slack web token
 */
function getConversationsList() {
  return webAPI.conversations.list();
}

/**
 * Get the Slack channel we want by 'name' to get associated data such as the
 * channel id
 */
function getConversation(name) {
  return getConversationsList()
    .then(res => res.channels.find(channel => channel.name === name));
}

/**
 * Get the Slack channel history of messages and limit the amount by the 'max'
 * value
 */
function getConversationsHistory({ id: channelId, max = 10 }) {
  return webAPI.conversations.history({ channel: channelId, limit: max })
    .then(res => res.messages);
}


module.exports = async function messages (parent, args, { dataSources }) {}

At the bottom of the file, in our messages function we await the promise returned from Slack to resolve and get the list of messages for the conversation.

File path icon web-api/api/slack-integration/resolvers/messages.messages.query.js
//Existing code...

module.exports = async function messages (parent, args, { dataSources }) {
  const channelName = 'conversation-1';

  const conversation = await getConversation(channelName);
}

But wait, what if we haven't created a conversation with our new channel name yet?

In order to get a list of messages from Slack, we have to get an identifier for the conversation by using our unique name conversation-1 to select the id from the list of conversations. Then with this identifier, we can retrieve a list of messages from the history endpoint associated with the conversation.

If a conversation doesn't exist, we don't want to attempt to get messages, instead we can instantly return an empty array of messages. If a conversation does exist, we can return the messages for the conversation.

File path icon web-api/api/slack-integration/resolvers/messages.messages.query.js
//Existing code...

module.exports = async function messages (parent, args, { dataSources }) {
  const channelName = 'conversation-1';

  const conversation = await getConversation(channelName);

  return (!conversation
    ? Promise.resolve([])
    : getConversationsHistory({
      id: conversation.id,
      max: args.max
    }));
}

We now safely return a list for a conversation whether it has been created or not but there is another problem with our code. The message model from Slack's API doesn't quite fit the model for a Message defined in our schema.

We will need to map the model from Slack to our custom model. We will do this by defining a new function before our messages function and use a Message class for storing all message data in our model.

File path icon web-api/api/slack-integration/resolvers/messages.messages.query.js
class Message {
  constructor(id, userId, text, { timestamp = null, fromYou = false, user = null } = null) {
    this.id = id;
    this.userId = userId;
    this.text = text;
    this.timestamp = timestamp;
    this.fromYou = fromYou;
    this.user = user;
  }
}

function slackMessageMap(messageData, { user } = {}) {
  return new Message(
    messageData.ts,
    messageData.user,
    messageData.text,
    {
      fromYou: !!messageData.bot_id,

      /**
       * Slack appends an ordering integer at the end of the timestamp that
       * we need to remove to correctly format the message time. 
       */
      timestamp: messageData.ts.split('.')[0],
      user
    }
  );
}

module.exports = async function messages (parent, args, { dataSources }) {
  //...
}

Going back to our messages function at the bottom of the file, we can now use our new mapping function to create the correct model for a message used in our schema by using the .then method returned from our promise to transform the result.

File path icon web-api/api/slack-integration/resolvers/messages.messages.query.js
module.exports = async function messages (parent, args, { dataSources }) {
  //...

  return (!conversation
    ? Promise.resolve([])
    : getConversationsHistory({
      id: conversation.id,
      max: args.max
    }))
    .then(messages => messages.map(message => slackMessageMap(message)));
}

All we have to do now is add our messages query resolver to the web-api/index.js file. Import the messages.messages.query.js file at the top of the file:

File path icon web-api/index.js
//...
const { messageGQL } = require('./api/messages/message.model');
const { userGQL } = require('./api/users/user.model');

/**
 * Import our new messages resolver
 */
const messages = require('./api/slack-integration/resolvers/messages.messages.query');

const typeDefs = gql`
  #...
`;
//...

Then add the Query property with messages resolver to the Apollo server instance:

File path icon web-api/index.js
//...
const server = new ApolloServer({
  typeDefs,
  resolvers: {
    /**
     * New Query and messages resolver
     */
    Query: {
      messages
    }
  },
  //...
});
//...

Tidy up

Before we move on, it's a good idea to tidy up our code so we can reuse code units in other areas of our application. The example code below will show the code reorganised into separate files so slight adjustments will need to be made.

Note: Open the burger menu on the left to see the complete file structure.

Overview of changes to files

  • Primarily removing logic from the messages.messages.query.ts file that we've been using.
  • We introduce a new file for holding and reusing the Slack connection at slack/connection.js.
  • We remove the calls to the Slack API from the messages.messages.query.ts file and place them in a new DataSource extended class in the file data-sources/conversations.js.
  • We move the Message model next to the associated GraphQL query in api/messages/message.model.js. This makes sense as they are tightly coupled in structure and have the same reason to change.
  • Move the slackMessageMap to the api/slack-integration/data-mappings/message.js file.
  • Adjust the index.js file to reflect the above changes.

These changes allow us to reuse this logic in other areas of our API.

Note: The Codesandbox example provides a working demonstration loaded into the browser. The API will fail when trying to use a test query because no environment variables are supplied for security reasons.

Please run the API locally using the environment variables you created in the setup guide.

Add user data on to our GraphQL message model

Every message in the list will require associated information of the message owner such as the user name and avatar. We can create a resolver for our Message model to describe how to resolve user information within the schema.

Within your terminal window change directory to your web-api folder of your project folder and use the following commands to create some new files:

  • touch ./api/slack-integration/data-mappings/user.js
  • touch ./api/slack-integration/data-sources/users.js
  • touch ./api/slack-integration/resolvers/message.js

Open the file api/users/user.model.js we created early, we're going to need to add a Javascript constructor to create instances for storing User values, which we'll put after our userGQL variable. This will also need to be exported at the bottom of the file.

File path icon web-api/api/users/user.model.js
// Existing code...

const userGQL = gql`
  type User {
    //...
  }
`;

class UserModel {
  constructor(id, name, { colour, avatarUrl, team } = {}) {
    this.id = id;
    this.name = name;
    this.colour = colour;
    this.avatarUrl = avatarUrl;
    this.team = team;
  }
}

module.exports = { userGQL, User: UserModel };

As with our Message model, we also need to create a mapping to transform the data provided by Slack to create the new User model so we can use it in our schema. Open the new file api/slack-integration/data-mappings/user.js and add the following code:

File path icon web-api/api/slack-integration/data-mappings/user.js
const { User } = require('../../users/user.model');

module.exports = (userData) => {
  return new User(
    userData.id,
    (userData.profile && userData.profile.display_name) || userData.real_name,
    {
      colour: userData.color,
      avatarUrl: userData.profile.image_48,
    }
  );
};

We now need a way to get the user information from Slack. This time we will extend Apollo's RESTDataSource that has useful methods for implementing a REST interface. Open our new file api/slack-integration/data-sources/users.js and add the following code:

File path icon web-api/api/slack-integration/data-sources/users.js
const { RESTDataSource } = require('apollo-datasource-rest');

module.exports = class UsersAPI extends RESTDataSource {
  constructor() {
    super();
    this.baseURL = 'https://slack.com/api/';
  }

  async getUsersInfo(id) {
    const { user } = await this.get('users.info', {
      ttl: 10000,
      user: id,
      token: process.env.SLACK_WEB_ACCESS_TOKEN
    });

    return user;
  }
};

The async keyword will create a Promise of our getUsersInfo method and return data from the users.info endpoint. You will need to pass the user id that will be supplied in a message and your SLACK_WEB_ACCESS_TOKEN token that we created in the setup pages of this tutorial as parameters. We also pass a time-to-live parameter (ttl) that Apollo will use to know how long to memoize the result of the response returned by the endpoint.

Using ttl with RESTDataSource, the same result will be returned when calling users.info repeated times within this time limit. This effectively saves multiple Slack calls for the same user and can drastically increase user-perceived performance. Remember to set the baseURL property and call the super(); constructor.

We now need to register our new data source so that we can use this in our resolvers. Open the file web-api/index.js and add the UsersAPI constant to the Data sources import list.

File path icon web-api/index.js
/**
 * Data sources
 */
const ConversationsAPI = require('./api/slack-integration/data-sources/conversations');
const UsersAPI = require('./api/slack-integration/data-sources/users'); // <-- New import

And further down the document add a new UsersAPI() instance to the dataSources object so that it can be access through the context parameter of each requests resolver. This can now be used in every resolver function.

File path icon web-api/index.js
//Existing code above...

const typeDefs = gql`
    //Existing code...
`;

const dataSources = {
  conversationsAPI: new ConversationsAPI(),
  usersAPI: new UsersAPI() // <-- New property with instance
};

const server = new ApolloServer({
  //Existing code...
});

//Existing code below...

Resolve user data on each message

We can now specify a resolver function for our Message model to get user data. GraphQL will take the properties defined here and mixin the results of any synchronous/asynchronous operations to use as the value to create the final response. We're going to add the following code to the file api/slack-integration/resolvers/message.js.

File path icon web-api/api/slack-integration/resolvers/message.js
const { User } = require('../../users/user.model');
const slackUserMap = require('../../slack-integration/data-mappings/user');

const Message = {
  user: (message, args, { dataSources }) => message.userId
    ? dataSources.usersAPI.getUsersInfo(message.userId).then(slackUserMap)
    : anonymousUser
};

const anonymousUser = new User(
  'user',
  'You'
);

module.exports = Message;

This function has the same arity as the resolver in file messages.messages.query.js so might look familiar. We're just using a different async method and passing the message.userId from the resolved message data to get the associated user information.

Lastly we need to add our Message resolver to our export object of resolvers. Open the file api/slack-integration/resolver/messages.resolver.js and add the Message resolver:

File path icon web-api/api/slack-integration/resolver/messages.resolver.js
const messages = require('./messages.messages.query');
const Message = require('./message'); // <-- New import

module.exports = {
  Query: {
    messages
  },

  Message // <-- Add the message model resolver
};

Because we have defined the GraphQL Message model as the model for each item in our returned query for messages (which looks like: messages(max: Int): [Message] as part of our schema), GraphQL will use our new Message resolver for each item in the list of messages. This is done by passing the new resolver the data for each item such as userId to get the associated user information and create the User model.