DEV Community

This Dot Media for This Dot

Posted on • Edited on • Originally published at thisdot.co

Hasura, GraphQL Backend in the Cloud-Part 3

The proof of the pudding is in the eating!

In this final installment of Hasura GraphQL Engine, we will build a Vue.js client side app that authenticates and authorises via auth0, connecting to Hasura GraphQL Engine to build a Food Recipes management system.

In Parts One and Two of this series, you learned about Hasura GraphQL Engine, how to deploy it to Heroku, and how to manage its Postgres database. You also had a thorough demonstration on its query and mutation capabilities.

If you haven’t read Parts One or Two and need to get up to speed, I recommend you backtrack and then continue here.

This article assumes a basic knowledge in:

The source code for this article is hosted on a GitHub repo.

Create a Vue.js app

I will be using the Vue CLI 3 to generate a new Vue.js app. Issue the following command to get yourself started with a new fresh Vue.js app.

vue create hasura-crud-app
Enter fullscreen mode Exit fullscreen mode

The command prompts you with a set of questions to help customise the application. For this demonstration, make sure you follow the steps below:

  • Please pick a preset: Select the Manually select features option.

  • Check the features needed for your project: Select Babel, Router, Vuex and the Linter / Formatter option.

  • Use history mode for router? Type Y.

  • Pick a linter / formatter config: I personally prefer the ESLint + Airbnb config. You may choose another if that’s what you want.

  • Pick additional lint features: Select Lint on save option.

  • Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? Select the In dedicated config files option.

  • Finally, you may decide to save the above options as a template to use next time you want to create a new Vue.js app.

The Vue CLI starts creating your application files and takes a few seconds to get the job done. Once finished, you may run your app using the command yarn serve. If all went well, you should be able to see something similar to this:

Check the official Vue CLI 3 docs for a more in-depth look at creating apps.

Create an Auth0 app

To create a new app, visit the Auth0 website and sign in or create a new one.

Once inside the Auth0 Dashboard, click on the NEW APPLICATION button.

Give your app a name and select the application type.

  • App Name: You are free to choose the name you want. In my case, I’ve chosen Hasura Crud App.

  • Application Type: Select the Single Page Web App to compliment the Vue.js app we are building.

Hit CREATE to start creating your new Auth0 app.

Next, you are prompted to select the client side technology you are using. In this case, select Vue.

That’s all! You follow the instructions to add and integrate Auth0 into your Vue.js app.

Integrate Vue.js app with Auth0

I will cover the minimum needed to add and integrate Auth0 into a Vue.js app. However, you are more than welcome to explore the instructions given to you by Auth0 or even have a look at the sample apps provided on Github, the auth0-vue-samples repo.

Navigate to the settings of the new app and provide a URL for the Allowed Callback URLs field. For now, enter the following: http://localhost:8080/callback.

When Auth0 finishes authenticating the user, it sends the authentication information about the user back to Vue.js app — hence the reason it needs a local URL to call and relay all the information.

Next, mark down the values for the following three important pieces of information:

  • Domain

  • Client ID

  • Allowed Callback URLs

We will be using the above when configuring Auth0 Client inside the Vue.js app.

Back in the Vue.js app, add a new AuthService.js file to hold all the boilerplate code needed to communicate with Auth0.

Now, you need to install the Client Side toolkit for Auth0 API npm package onto your app by issuing this command:

npm i auth0-js
Enter fullscreen mode Exit fullscreen mode

The AuthService.js starts creating a new Auth0 Client by providing some fields. Make sure to replace the template placeholders with the proper information collected above.

function handleAuthentication() {
  return new Promise((resolve, reject) => {
    auth0Client.parseHash((err, authResult) => {
      if (authResult && authResult.accessToken && authResult.idToken) {
        setSession(authResult).then(userInfo => {
          resolve(userInfo.sub);
        });
      } else if (err) {
        logout();
        reject(err);
      }
    });
  });
}

function setSession(authResult) {
  return new Promise((resolve, reject) => {
    const userInfo = {
      accessToken: authResult.accessToken,
      idToken: authResult.idToken,
      expiresAt: authResult.expiresIn * 1000 + new Date().getTime(),
      sub: authResult.idTokenPayload.sub
    };
    localStorage.setItem('user_info', JSON.stringify(userInfo));

    resolve(userInfo);
  });
}
Enter fullscreen mode Exit fullscreen mode

The service then defines the main public interface and lists the functions available for the Vue.js app to call:

export const authService = {
  login,
  logout,
  handleAuthentication,
  getUserId 
}
Enter fullscreen mode Exit fullscreen mode

The handleAuthentication() function is called inside the Callback component to handle the response for the authentication challenge with Auth0.

function handleAuthentication() {
  return new Promise((resolve, reject) => {
    auth0Client.parseHash((err, authResult) => {
      if (authResult && authResult.accessToken && authResult.idToken) {
        setSession(authResult).then(userInfo => {
          resolve(userInfo.sub);
        });
      } else if (err) {
        logout();
        reject(err);
      }
    });
  });
}

function setSession(authResult) {
  return new Promise((resolve, reject) => {
    const userInfo = {
      accessToken: authResult.accessToken,
      idToken: authResult.idToken,
      expiresAt: authResult.expiresIn * 1000 + new Date().getTime(),
      sub: authResult.idTokenPayload.sub
    };
    localStorage.setItem('user_info', JSON.stringify(userInfo));

    resolve(userInfo);
  });
}
Enter fullscreen mode Exit fullscreen mode

The function passes the Auth0 response, extracts the information needed and stores them inside LocalStorage via the setSession() private function. The LocalStorage now holds the user_info key containing all the information about the authentication user.

function login() {
  auth0Client.authorize();
}
Enter fullscreen mode Exit fullscreen mode

The login() function initiates the authentication challenge with Auth0 by calling the authorize() function on the Auth0 Client.

The getUserId() function returns the sub claim (User ID) of the currently logged in user.

function getUserId() {
  const userInfo = getUser();
  return userInfo ? userInfo.sub : null;
}
Enter fullscreen mode Exit fullscreen mode

It makes use of a help function to extract the user information from the LocalStorage and validate the information to make sure the authentication token is not yet expired.

const getUser = function() {
  const userInfo = JSON.parse(localStorage.getItem('user_info'));
  return userInfo && new Date().getTime() < userInfo.expiresAt
    ? userInfo
    : null;
};
Enter fullscreen mode Exit fullscreen mode

Finally, the logout() function clears the LocalStorage and eventually logs out the user.

function logout() {
  localStorage.removeItem('user_info');
}
Enter fullscreen mode Exit fullscreen mode

Let’s create the Callback Vue Component. You can build a very creative component to show to your users when receiving the response from Auth0. I will keep it simple and just call the handleAuthentication() on the AuthService to complete the authentication challenge.

<template>
    <div></div>
</template>

<script>

export default {
  name: 'callback',
  mounted() {
    this.$store.dispatch('account/handleAuthenticationResponse');
  }
};
</script>

<style scoped>
</style>

Enter fullscreen mode Exit fullscreen mode

The component registers the Vue.js mounted() lifecycle hook and dispatches an action on the Vuex Store that will ultimately call the handleAuthentication() function and update the state of the application.

Let’s touch on the store setup in this app. I’ve divided the Veux store into modules to better organise the code.

The account state is defined as follows:


const user = authService.getUserId();

const state = user
  ? { status: { loggedIn: true }, user }
  : { status: {}, user: null };
Enter fullscreen mode Exit fullscreen mode

The code feeds in some initialisation information based on whether the user is currently logged in to the app.

The account actions are defined as follows:

const actions = {
  login({ commit }) {
    commit('loginRequest', user);
    authService.login();
  },
  async handleAuthenticationResponse({ dispatch, commit }) {
    try {
      const userInfo = await authService.handleAuthentication();
      commit('loginSuccess', userInfo);
    } catch (e) {
      authService.logout();
      commit('loginFailure', e);
    }
  },
  logout({ commit }) {
    authService.logout();
    commit('logout');
  }
};
Enter fullscreen mode Exit fullscreen mode

There is an action to log in the user, handle the authentication response, and finally log out the user. Each action issues an API call on the AuthService, grabs the resulting response, and commits it to the Veux store.

The account mutations are defined as follows:


const mutations = {
  loginRequest(state, user) {
    state.status = { loggingIn: true };
    state.user = user;
  },
  loginSuccess(state, user) {
    state.status = { loggedIn: true };
    state.user = user;
  },
  loginFailure(state) {
    state.status = {};
    state.user = null;
  },
  logout(state) {
    state.status = {};
    state.user = null;
  }
};

Enter fullscreen mode Exit fullscreen mode

Basic mutations to track the user information and some flags required by the app. Simple Vuex stuff!

The account getters are defined as follows:


const getters = {
  getUser(state) {
    return state.user && authService.getUserId();
  },
  getReturnUrl(state, getters) {
    return getters['getUser'] && authService.getReturnUrl();
  }
};

Enter fullscreen mode Exit fullscreen mode

A getter to return the User ID of the currently signed in user. Another to return the Return URL for the router to navigate the user after a successful authentication challenge.

Finally, the account module is exported as follows:


export default {
  namespaced: true,
  state,
  mutations,
  actions,
  getters
};
Enter fullscreen mode Exit fullscreen mode

The final step to integrate Auth0 to the app is to configure the router to guard the Recipes page (that we will build in the sections below) and issue an authentication challenge if the user accessing the page is not yet authenticated.

The router code starts by injecting the Router component into the Vue.js system:

Vue.use(Router);
Enter fullscreen mode Exit fullscreen mode

Then it defines the routes in the application as follows:

import Home from '@/components/home/Home.vue';
import Callback from '@/components/auth/Callback.vue';
import RecipeList from '@/components/recipes/RecipeList.vue';

export const router = new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    },
    {
      path: '/callback',
      name: 'callback',
      component: Callback
    },
    {
      path: '/recipes',
      name: 'recipes',
      component: RecipeList
    },
    // otherwise redirect to home
    { path: '*', redirect: '/' }
  ]
});
Enter fullscreen mode Exit fullscreen mode

Finally, the router defines a global guard to make sure the user is authenticated before accessing the secured pages.

router.beforeEach((to, from, next) => {
  // redirect to login page if not logged in and trying to access a restricted page

  const publicPages = ['/login', '/', '/home', '/callback'];
  const authRequired = !publicPages.includes(to.path);
  const loggedIn = store.getters['account/getUser'];

  if (authRequired && !loggedIn) {
    authService.setReturnUrl(to.fullPath);
    store.dispatch('account/login');
  }

  next();
});
Enter fullscreen mode Exit fullscreen mode

The beforeEach() is a global guard and fired on a navigation initiation. It takes as input the to parameter representing the page the user is navigating from, the from parameter representing the page the user is coming from, and finally the next() callback, used to keep things moving and navigating. This is the best place to handle such code before actually navigating to the page itself. Check out the Navigation Guards docs for more information.

The code utilises the Whitelisting technique to exclude pages that don’t require authentication.

The store is queried to retrieve the currently signed in user.

The guard dispatches a login action only when:

  • The page the user is navigating to requires authentication

  • The user is not currently signed in

Finally, if the user is already authenticated, the guard calls on next() to proceed with the current navigation request.

Now that the app is fully integrated with Auth0, you can start authenticating your users and move on to the next step.

Integrate Hasura GraphQL Engine with Auth0 Webhook

Part Two has a section dedicated to Advanced Access Control. By now, you know we need to host an Auth0 Webhook and configure Hasura Engine to call on this webhook whenever Hasura wants to authorise a request.

The Hasura team provides a sample Auth0 Webhook that we can deploy right away to Heroku and integrate with the Hasura app.

Let’s visit the GitHub repo above, click on Deploy to Heroku button, grab the app URL, and navigate to our Hasura app on Heroku.

Go to Settings, then click on reveal Config Vars button. Finally, add this new key/value combination:

  • Key: HASURA_GRAPHQL_AUTH_HOOK

  • Value: AUTH0 WEBHOOK APP URL /auth0/webhook

By default, the webhook deployed once it verifies the authentication token it receives from Hasura (that is passed over from the client’s request) authorizes the request and returns the following information:

  • The X-Hasura-User-Id populated by the Auth0 User ID (sub).

  • The X-Hasura-Role populated by a default value of user.

That’s all! Now Hasura would call the configured webhook to authenticate requests.

Configure proper permissions on the Postgres database tables

Now that Hasura will authorise all requests for queries and mutations, let’s configure the select permissions on the Recipe table and allow only authorised users.

Open your Hasura deployed app and navigate to the Recipe table Permissions Tab.

The select permission is now configured to allow users with role user to select rows. You could also be more specific and specify a custom check. In addition, you can pick and choose what columns the user can access and retrieve.

Let’s switch back to our Vue.js app and configure the Apollo Client so that we can start querying Hasura data.

Add Apollo Client for Vue.js

In my article Part Two on GraphQL, I made use of the Apollo Client for Angular. In this article, we will configure the Apollo Client for Vue.js and use it to communicate with Hasura GraphQL Engine.

To start with, issue the command below to install a few npm packages required to use the Apollo Client in our app.

yarn add vue-apollo graphql apollo-client apollo-link apollo-link-http apollo-cache-inmemory graphql-tag
Enter fullscreen mode Exit fullscreen mode

Then let’s configure and create an Apollo Client as follows:

https://gist.github.com/bhaidar/c8b9800c9b5bfba5e26c4c4014f896ec

The code starts by creating a new HttpLink by pointing to the Hasura API URL on Heroku.

Then it creates an authorisation middleware and configures Apollo Client to use it. This middleware simply adds the user’s token into each and every request pointing to the Hasura app.

const authMiddleware = new ApolloLink((operation, forward) => {
  const token = authService.getAccessToken();

  // add the authorization to the headers
  operation.setContext({
    headers: {
      authorization: token ? `Bearer ${token}` : null
    }
  });

  return forward(operation);
});
Enter fullscreen mode Exit fullscreen mode

The code uses another helper function offered by the AuthService to retrieve the user’s access token.


function getAccessToken() {
  const userInfo = getUser();
  return userInfo ? userInfo.accessToken : null;
}
Enter fullscreen mode Exit fullscreen mode

Finally, the code exports a new and configured ApolloClient instance.

const defaultOptions = {
  watchQuery: {
    fetchPolicy: 'network-only',
    errorPolicy: 'ignore'
  },
  query: {
    fetchPolicy: 'network-only',
    errorPolicy: 'all'
  }
};

// Create the apollo client
export default new ApolloClient({
  link: concat(authMiddleware, httpLink),
  cache: new InMemoryCache(),
  defaultOptions: defaultOptions
});
Enter fullscreen mode Exit fullscreen mode

The client is configured without a local cache mechanism.

That’s it! Now you have successfully created and configured the Apollo Client for Vue.js.

Build the Recipe List Component

Let’s shift gears and build the Recipe List component. This will query the Hasura Postgres database via GraphQL and display the recipe list with some details.


<script>
import { mapState } from 'vuex';

export default {
  name: 'RecipeList',
  computed: {
    ...mapState('recipes', { recipes: 'all', isLoading: 'isLoading' })
  },
  mounted() {
    this.$store.dispatch('recipes/findAll');
  },
  methods: {
    goToRecipe($event) {
      this.$store.dispatch("recipes/selectRecipe", +$event);
    }
  }
};
</script>
Enter fullscreen mode Exit fullscreen mode

The component dispatches an action inside mounted() Vue lifecycle hook to fetch all recipe records from the database.

Then it makes use of mapState(), defined by Vuex, to generate computed properties for two fields on the state: Recipe data and isLoading flag.

Finally, it defines the gotoRecipe() function that dispatches an action to navigate to the EditRecipe component.

Let’s have a look at the recipes Vuex module.

The module starts by defining the recipes state to track through the app. For now, the state defines all to hold all recipe data from the Hasura server. Also, it defines the isLoading flag to show/hide some spinners, which are helpful UI indicators.

import { router } from '@/router';
import gqlClient from '@/services/apollo';
import { authService } from '@/services/auth/AuthService';

import {
  RECIPES_QUERY,
} from '@/queries';

let state = {
  all: [],
  isLoading: false
};
Enter fullscreen mode Exit fullscreen mode

A single findAll() action is defined for now to retrieve the recipe data from Hasura.


const actions = {
  async findAll({ commit }) {
    commit('setLoading', true);
    const response = await gqlClient.query({
      query: RECIPES_QUERY
    });
    commit('setRecipeList', response.data.recipe);
  }
};
Enter fullscreen mode Exit fullscreen mode

The action starts by mutating the Vuex state and setting isLoading to true. Then it calls on the query() function defined on the Apollo Client and supply it with the name of the query to execute on the server.

The RECIPES_QUERY is defined as follows:


export const RECIPES_QUERY = gql`
  query {
    recipe(order_by: { id: asc }) {
      id
      name
      description
      instructions
      number_of_servings
      vegetarian
      calories_per_serving
      source
      food_category_id
      food_category {
        id
        name
      }
      created_by
      time_to_prepare
      recipe_ingredients {
        id
        ingredient {
          id
          name
        }
        quantity
        comments
      }
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

The query defines the structure of the GraphQL by following Hasura Query Language Extensions to retrieve a list of recipe records together with their details.

The findAll() action, once the data is received from the Hasura server, commits the data into the Vuex store. The mutation is defined as follows:


const mutations = {
  setRecipeList(state, recipeList) {
    state.all = [...recipeList];
    state.isLoading = false;
  },
  setLoading(state, isLoading) {
    state.isLoading = isLoading;
  }
};
Enter fullscreen mode Exit fullscreen mode

The mutation function receives the recipe list and simply updates the state.all field with the data.

The selectRecipe() action saves the selected Recipe ID in the store and routes the user to the EditRecipe component.


selectRecipe({ commit }, recipeId) {
   commit('setRecipe', recipeId);
   router.push({ name: 'editRecipe', params: { recipeId: recipeId } });
},
Enter fullscreen mode Exit fullscreen mode

The saved Recipe ID is used later by the EditRecipe component to query the Recipe to be edited.

Finally, the module exports the state, mutations, and actions.


const getters = {};

export default {
  namespaced: true,
  state,
  mutations,
  actions,
  getters
};
Enter fullscreen mode Exit fullscreen mode

For brevity, I’ve not included the entire RecipeList component HTML code. However, you can always view it on the GitHub repo.

Build the Edit Recipe screen

The user navigates to the EditRecipe component by clicking a single Recipe on the RecipeList component.

The component presents a simple Recipe editing screen.

A Back to Recipes button to navigate back to the Recipe List.

A Save button to save any changes on the Recipe.

A one-line-form to allow the user to add more recipe ingredients.

Let’s have a look at the source code behind the EditRecipe component.

The component defines a new data object to hold a new Recipe Ingredient record being added onto the Recipe.

export default {
  name: 'EditRecipe',
  data() {
    return {
      recipe_ingredient: {
        ingredient_id: '',
        quantity: 0,
        comments: ''
      }
    };
  },
Enter fullscreen mode Exit fullscreen mode

In order to communicate with the store, the component defines a few computed properties representing sections of the state related to this component.

Basically, this component requires access to:

  • The recipe being edited

  • The list of Food Category records

  • The list of Ingredient records

  • The isLoading flag

Notice the use of mapGetters to call a getter and retrieve the Recipe object being edited?


  computed: {
    ...mapState('recipes', {
      foodCategoryList: 'foodCategoryList',
      ingredientList: 'ingredientList',
      isLoading: 'isLoading'
    })
    ...mapGetters('recipes', { recipe: 'selectedRecipe' })
  },
Enter fullscreen mode Exit fullscreen mode

The state related to this component is defined inside the recipes module store.


let state = {
  foodCategoryList: [],
  ingredientList: [],
  isLoading: false
};
Enter fullscreen mode Exit fullscreen mode

The component dispatches a few actions inside the mounted() function to basically request for data.


  mounted() {
    this.$store.dispatch('recipes/fetchFoodCategoryList');
    this.$store.dispatch('recipes/fetchIngredientList');
  },
Enter fullscreen mode Exit fullscreen mode

Let’s have a look at the store implementation for the above actions.

The selectedRecipe() getter finds and returns a Recipe object within the state.

selectedRecipe(state) {
     return state.all.find(item => item.id == state.one);
}
Enter fullscreen mode Exit fullscreen mode

The fetchFoodCategoryList() function communicates with the Hasura backend API to retrieve a list of available Food Category records using the Apollo Client and executing the FOOD_CATEGORY_RECIPE_QUERY.

  async fetchFoodCategoryList({ commit }) {
    const response = await gqlClient.query({ query: FOOD_CATEGORY_RECIPE });
    commit('setFoodCategoryList', response.data.food_category);
  },
Enter fullscreen mode Exit fullscreen mode

Once the data is retrieved, it commits the data into the store by calling the setFoodCategoryList mutation.


 setFoodCategoryList(state, foodCategoryList) {
    state.foodCategoryList = [...foodCategoryList];
 },
Enter fullscreen mode Exit fullscreen mode

The FOOD_CATEGORY_RECIPE_QUERY is defined as follows:


export const FOOD_CATEGORY_RECIPE_QUERY = gql`
  query {
    food_category(order_by: { id: asc }) {
      id
      name
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

The fetchIngredientList() function communicates with the Hasura backend API to retrieve a list of available Ingredient records using the Apollo Client and executing the INGREDIENTS_QUERY.


export const FOOD_CATEGORY_RECIPE_QUERY = gql`
  query {
    food_category(order_by: { id: asc }) {
      id
      name
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

The INGREDIENTS_QUERY is defined as follows:


export const INGREDIENTS_QUERY = gql`
  query {
    ingredient(order_by: { id: asc }) {
      id
      name
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

Back to the EditRecipe component: it defines two methods that are called by the UI to update the Recipe and add a new recipe ingredients.

The updateRecipe() method prepares the payload and dispatches the updateRecipe action on the store.



    updatRecipe($event) {
      const {
        id,
        name,
        description,
        instructions,
        food_category_id,
        number_of_servings,
        time_to_prepare,
        calories_per_serving,
        source,
        vegetarian
      } = this.recipe;
      this.$store.dispatch('recipes/updateRecipe', {
        id,
        name,
        description,
        instructions,
        food_category_id,
        number_of_servings,
        time_to_prepare,
        calories_per_serving
      });
    },
Enter fullscreen mode Exit fullscreen mode

The addIngredient() method prepares the payload and dispatches the InsertRecipeIngredient action on the store.


    addIngredient($event) {
      const payload = {
        ...this.recipe_ingredient,
        quantity: +this.recipe_ingredient.quantity,
        recipe_id: this.recipe.id
      };
      this.$store.dispatch('recipes/insertRecipeIngredient', payload);
      this.recipe_ingredient = {
        ingredient_id: '',
        quantity: 0,
        comments: ''
      };
    }
  }
Enter fullscreen mode Exit fullscreen mode

Now, let’s have a look at the actions’ implementation in the store.

The insertRecipeIngredient action executes the RECIPE_INGREDIENT_MUTATION and passes the required payload. It then dispatches a new action to refresh the Recipe data in the application by calling dispatch(‘findAll’) action. With that, you will instantly see the added recipe ingredients in the list in front of you.


async insertRecipeIngredient({ dispatch, commit }, recipeIngredient) {
    const response = await gqlClient.mutate({
      mutation: RECIPE_INGREDIENT_MUTATION,
      variables: {
        ...recipeIngredient
      }
    });

    dispatch('findAll');
  },
Enter fullscreen mode Exit fullscreen mode

The updateRecipe action is defined as follows:


async updateRecipe({ dispatch, commit }, recipe) {
    const response = await gqlClient.mutate({
      mutation: RECIPE_UPDATE_MUTATION,
      variables: {
        ...recipe,
        created_by: authService.getUserId()
      }
    });

    window.location.assign('/recipes');
  }
Enter fullscreen mode Exit fullscreen mode

It simply executes a mutation to update the Recipe record and then it changes the window.location to go back to the list of recipes. The code could have used the Vue Router to navigate back to the Recipes page, however doing it this way, clears the local Apollo cached database and retrieves a fresh copy of the data. This is just an alternative to using the Apollo update() function.

To be concise, I haven’t included the entire EditRecipe component HTML code here. However, you can check it out on the GitHub repo.

Conclusion

Now that you have some insight on the Hasura GraphQL Engine, how to deploy Hasura on Heroku, how to manipulate the queries and mutations, and how to build a Vue.js client side app, it’s time to play!

So with this, I now leave you to further your skills and knowledge to strengthen your understanding in Hasura GraphQL. The three-part series was designed to be as simple as possible for even the newest developer to harness. Like they say, practice makes perfect.

This post was written by Bilal Haidar, a mentor with This Dot.

Need JavaScript consulting, mentoring, or training help? Check out our list of services at This Dot Labs.

Top comments (0)