Unread Notifications

Latest Notifications

No Notifications

You're all set! Start a discussion by leaving a comment on a lesson or replying to an existing comment.

Amplify + Nuxt

Implementing Our Private & Public Amplify GraphQL API In Our Nuxt App

13 MIN READ
2 MONTHS AGO

In this lesson we'll be implementing the publicly and privately accessible GraphQL API that we created in the last lesson within our Nuxt project.

Watch on YouTube

Series List

  1. Setup Amplify & Nuxt

  2. Adding authentication (Part 1, Setup)

  3. Adding authentication (Part 2, Implementation)

  4. Adding a public and private API (Part 1, Setup)

  5. Adding a public and private API (Part 2, Implementation) - You are here

  6. Deploy via S3

Repository


Creating Our API Module

To get started let's create an API Vuex module. We'll use this module to do all the communication with our API. So, let's create this module at /store/api.js within our project.

Within this store, we're going to need to import API from the aws-amplify package as well as our queries and mutations Amplify generated for us in the last lesson.

For now, let's also leave the state empty. We'll fill this in when we get to rigging up our posts.

import { API } from 'aws-amplify'
import * as gqlQueries from '~/src/graphql/queries'
import * as gqlMutations from '~/src/graphql/mutations'

export const state = {}

Let's add a single getter called authMode. We'll use this to determine which authorization mode to use for each API call.

export const getters = {
  authMode: (state, getters, rootState) =>
    rootState.auth.isAuthenticated ? 'AMAZON_COGNITO_USER_POOLS' : 'API_KEY'
}

We'll also only have a single mutation. This mutation will set whatever key value pair is passed into it.

export const mutations = {
  set(state, { key, value }) {
    state[key] = value
  }
}

Lastly, we'll have three actions to start with. These will be helper actions to serve as a middleman to help keep things DRY, since repeating this code for each request would be a lot of repeating if our API were to grow.

export const actions = {
  /**
   * API Helpers
   * These api helpers help cut back on repetitive code we'd otherwise need in each action
   */

  async get({ commit, getters }, { key, query, id }) {
    const { data } = await API.graphql({
      query: gqlQueries[query],
      variables: { id },
      authMode: getters.authMode
    })

    const value = data[query]
    if (key) commit('set', { key, value })
    return value
  },

  async query({ commit, getters }, { key, query, filter }) {
    const { data } = await API.graphql({
      query: gqlQueries[query],
      variables: { filter },
      authMode: getters.authMode
    })

    const value = data[query].items
    if (key) commit('set', { key, value })
    return value
  },

  async mutate({ commit, getters }, { key, mutation, input }) {
    const { data } = await API.graphql({
      query: gqlMutations[mutation],
      variables: { input },
      authMode: getters.authMode
    })

    const value = data[mutation]
    if (key) commit('set', { key, value })
    return value
  }
}

Each of our helper actions accepts a key and query or mutation. If a key is provided the action will set whatever the result of our query/mutation is in our state under that name. Query or mutation will be a string of the name of the query or mutation we want to run, for example "listPosts".

All of our results return back a data object, which we'll use object spreading to extract from our response. Inside our data object will be a key of the name of the query or mutation that was run. This is why we reference the query or mutation string off data to extract the actual value.

const value = data[query]

Our get action will be use to get a specific record from our database by an id.

Our query action will be used to query lists of items from our database. We can optionally provide a filter to filter down our results. To get the value of our query results we need to nest one level deeper inside an items key. For example, our value path will be data.listPosts.items.

const data = {
  listPosts: {
    items: []
  }
}

Our mutate action will be used to create, update, and delete records in our database. The input we pass into it will be the fields required for the model. You can find these fields within your API documentation in AppSync.

So, here's the full picture of what our API store module should currently look like.

import { API } from 'aws-amplify'
import * as gqlQueries from '~/src/graphql/queries'
import * as gqlMutations from '~/src/graphql/mutations'

export const state = {
}

export const getters = {
  authMode: (state, getters, rootState) =>
    rootState.auth.isAuthenticated ? 'AMAZON_COGNITO_USER_POOLS' : 'API_KEY'
}

export const mutations = {
  set(state, { key, value }) {
    state[key] = value
  }
}

export const actions = {
  /**
   * API Helpers
   * These api helpers help cut back on repetitive code we'd otherwise need in each action
   */

  async get({ commit, getters }, { key, query, id }) {
    const { data } = await API.graphql({
      query: gqlQueries[query],
      variables: { id },
      authMode: getters.authMode
    })

    const value = data[query]
    if (key) commit('set', { key, value })
    return value
  },

  async query({ commit, getters }, { key, query, input, filter }) {
    const { data } = await API.graphql({
      query: gqlQueries[query],
      variables: { input, filter },
      authMode: getters.authMode
    })

    const value = data[query].items
    if (key) commit('set', { key, value })
    return value
  },

  async mutate({ commit, getters }, { key, mutation, input }) {
    const { data } = await API.graphql({
      query: gqlMutations[mutation],
      variables: { input },
      authMode: getters.authMode
    })

    const value = data[mutation]
    if (key) commit('set', { key, value })
    return value
  }
}

Creating Our User Profile Module

Next let's create a store to manage our user's profile. Let's create a module called user at /store/user.js. You could also use the auth store or name this store profile.

For this module, we won't need to import any dependencies. Our state and mutations will start off pretty typical. We'll have a user state item defaulted to null and a setUser mutation to mutate our user state.

export const state = {
  user: null
}

export const mutations = {
  setUser(state, user) {
    state.user = user
  }
}

We'll be working with three actions; these will be called getUser, createUser, and findOrCreateUser.

export const actions = {
  async getUser({ commit, dispatch }, id) {
    const user = await dispatch(
      'api/get',
      { query: 'getUser', id },
      { root: true }
    )
    commit('setUser', user)
    return user
  },

  async createUser({ commit, dispatch }, input) {
    const user = await dispatch(
      'api/mutate',
      { mutation: 'createUser', input },
      { root: true }
    )
    commit('setUser', user)
    return user
  },

  async findOrCreateUser({ dispatch }, { attributes, username }) {
    let user = await dispatch('getUser', username)
    if (user) return user

    return dispatch('createUser', {
      id: username,
      email: attributes.email,
      createdAt: Date.now()
    })
  }
}

Our getUser action dispatches our to our API module calling api/get to fetch a user profile by the id provided. Note our user profile id and authenticated user id will be one in the same. Also note that the { root: true } at the end of our dispatch tells Vuex to run this dispatch in the root context outside this module.

Our createUser action dispatches to our API module calling api/mutate to create a new user profile with the fields defined inside the input object.

Lastly, our findOrCreateUser action will use our getUser action to attempt to find a user profile. If it does that user profile will be returned, otherwise a new one will be created with the data passed in. The attributes and username parameters will be coming directly from our logged in user data.

So, here's our completed user Vuex module.

export const state = {
  user: null
}

export const mutations = {
  setUser(state, user) {
    state.user = user
  }
}

export const actions = {
  async getUser({ commit, dispatch }, id) {
    const user = await dispatch(
      'api/get',
      { query: 'getUser', id },
      { root: true }
    )
    commit('setUser', user)
    return user
  },

  async createUser({ commit, dispatch }, input) {
    const user = await dispatch(
      'api/mutate',
      { mutation: 'createUser', input },
      { root: true }
    )
    commit('setUser', user)
    return user
  },

  async findOrCreateUser({ dispatch }, { attributes, username }) {
    let { getUser } = await dispatch('getUser', username)
    if (getUser) return getUser

    return dispatch('createUser', {
      id: username,
      email: attributes.email,
      createdAt: Date.now()
    })
  }
}

Creating A User Profile On Login

Now we can use the findOrCreateUser action during login to load our user's profile or create a profile if one can't be found.

To do this let's open our auth Vuex store at /store/auth.js. Below our commit('set', user) let's add a call do findOrCreateUser, passing in the user object we get back from calling Auth.signIn.

// log user in & get back JWT session
async login({ commit, dispatch }, { email, password }) {
  const user = await Auth.signIn(email, password)
  commit('set', user)

  await dispatch('user/findOrCreateUser', user, { root: true })

  return user
},

While we're here, we'll also want to get our user profile on load if there's an authenticated user. We can hook this check into our auth store's load action, again just below the commit('set', user).

// load the currently authenticated user if there is one
async load({ commit, dispatch }) {
  try {
    const user = await Auth.currentAuthenticatedUser()
    commit('set', user)

    if (user) {
      // get and set user's profile
      await dispatch('user/getUser', user.username, { root: true })
    }

    return user
  } catch (error) {
    commit('set', null)
  }
},

At this point we can go ahead and test and make sure everything we have so far is working. We can register a new user, and upon registration the user should have a profile created for them. We can login to an older account and verify that a profile was also created for them. And, we can reload to verify it's loading in okay if the user were to leave and come back.

One Last Auth Change

Before we move on from authentication changes, later on we'll be using our user's id for privilege checking. Let's take a moment here to expand our $auth helper class with an id getter.

get id() {
  if (!this.user) return
  return this.user.username
}

Now that we have the authentication bits out of the way, we can move into setting up and handling our posts.

Adding Our Post API

To start, let's expand upon the API store we created earlier in this lesson. So open up /store/api.js and let's add our post CRUD (create, read, update, delete) actions. We'll be able to make all of these actions short and sweet by using the api helpers we setup when we created this module.

First and foremost, though, let's add in our default state for our post items.

export const state = {
  posts: [],
  post: null
}

We'll use posts to store the results of our list queries and post to store the results of our get queries.

Next, let's move into our actions. We'll have five actions to communicate with our post endpoints.

  • listPosts to get all our posts, you can add filtering to this if you'd like.

  • getPost to get a specific post.

  • createPost to make a new post.

  • updatePost to update a specific post's data.

  • deletePost to delete a specific post.

Each action will call one of our api helper actions. If a key is provided the value we get back from our API will be stored in our Vuex state under that key.

export const actions = {

  async listPosts({ dispatch }) {
    return dispatch('query', { key: 'posts', query: 'listPosts' })
  },

  async getPost({ dispatch }, id) {
    return dispatch('get', { key: 'post', query: 'getPost', id })
  },

  async createPost({ dispatch }, input) {
    return dispatch('mutate', { key: 'post', mutation: 'createPost', input })
  },

  async updatePost({ dispatch }, input) {
    return dispatch('mutate', { key: 'post', mutation: 'updatePost', input })
  },

  async deletePost({ dispatch }, id) {
    return dispatch('mutate', { mutation: 'deletePost', input: { id } })
  },

  // ... api helpers ... //
}

Creating A Post

Now that we have everything in place to communicate with our API, we can start working on our views. To start with let's add a view to create a post at /pages/posts/create.vue

<template>
  <div class="p-6">
    <h1 class="text-3xl font-semibold mb-6">Create New Post</h1>
    <form @submit.prevent="create">
      <label>Title</label>
      <input v-model="form.title" placeholder="Email" class="form-control" />

      <label>Summary</label>
      <textarea v-model="form.summary" placeholder="Email" class="form-control"></textarea>

      <label>Body</label>
      <textarea v-model="form.body" placeholder="Email" class="form-control"></textarea>

      <button type="submit" class="button--green">Create</button>
    </form>
  </div>
</template>

<script>
export default {
  data: () => ({
    form: {
      title: '',
      summary: '',
      body: ''
    }
  }),

  methods: {
    async create() {
      try {
        const post = await this.$store.dispatch(
          'api/createPost',
          this.getPayload()
        )
        this.$router.push('/')
      } catch (error) {
        console.log({ error })
      }
    },

    getPayload() {
      return {
        ...this.form,
        authorId: this.$auth.user.username,
        createdAt: Date.now()
      }
    }
  }
}
</script>

We can test this out and verify all the api calls are resolving okay.

Listing Our Posts

Now we'll need to list these out. We'll do this on our home page at /pages/index.vue below all our authentication checking. While we're here we can also rig up the ability to delete a post.

<template>
  <!-- auth checks are up here -->

  <!-- Posts -->
  <div v-for="post in posts" :key="post.id" class="my-6">
    <h4 class="text-lg font-semibold">
      <nuxt-link :to="`/posts/${post.id}`">{{ post.title }}</nuxt-link>
    </h4>
    <p>{{ post.summary }}</p>
    <small>By: {{ post.author.email }}</small>
    <div v-if="post.author.id === $auth.id" class="flex justify-center text-xs">
      <nuxt-link :to="`/posts/${post.id}/edit`" class="mr-3">Edit</nuxt-link>
      <button @click="deletePost(post.id)" class="text-red-500">Delete</button>
    </div>
  </div>
</template>

<script>
export default {
  async asyncData({ store }) {
    return { 
      posts: await store.dispatch('api/listPosts') 
    }
  },

  methods: {
    async deletePost(id) {
      // delete the post
      await this.$store.dispatch('api/deletePost', id)
      // refresh our post data
      this.posts = await this.$store.dispatch('api/listPosts')
    }
  }
}
</script>

Now we can check and verify the posts we created will actually pull down from the api and list out. We can also verify we can delete successfully.

View A Post

Next up, let's create a page to view a specific post at /pages/posts/_id/index.vue. Note that in Nuxt, a directory beginning with a _ is treated as a route parameter. Meaning _id will be a post's actual id in the url.

<template>
  <div class="flex flex-col p-12">
    <nuxt-link to="/">&lt; Back</nuxt-link>
    <h1 class="text-4xl mb-3">{{ post.title }}</h1>
    <p class="text-lg mb-3 pb-3 border-b border-grey-light">{{ post.summary}}</p>
    <p>{{ post.body }}</p>
  </div>
</template>

<script>
export default {
  async asyncData({ store, params }) {
    return {
      post: await store.dispatch('api/getPost', params.id)
    }
  }
}
</script>

Edit A Post

Then finally, we need to be able to edit our posts. You can alter the create page to allow this, but to keep things separate and easy to read through, I'll be creating a new page at /pages/posts/_id/edit.vue

<template>
  <div class="p-6">
    <h1 class="text-3xl font-semibold mb-6">Update {{ post.title }}</h1>
    <form @submit.prevent="update">
      <label>Title</label>
      <input v-model="form.title" placeholder="Email" class="form-control" />

      <label>Summary</label>
      <textarea v-model="form.summary" placeholder="Email" class="form-control"></textarea>

      <label>Body</label>
      <textarea v-model="form.body" placeholder="Email" class="form-control"></textarea>

      <button type="submit" class="button--green">Update</button>
    </form>
  </div>
</template>

<script>
export default {
  async asyncData({ store, params }) {
    const post = await store.dispatch('api/getPost', params.id)
    return {
      post,
      form: {
        title: post.title,
        summary: post.summary,
        body: post.body
      }
    }
  },

  data: () => ({
    form: {
      title: '',
      summary: '',
      body: ''
    }
  }),

  methods: {
    async update() {
      try {
        const post = await this.$store.dispatch(
          'api/updatePost',
          this.getPayload()
        )
        this.$router.push('/')
      } catch (error) {
        console.log({ error })
      }
    },

    getPayload() {
      return {
        ...this.form,
        id: this.post.id
      }
    }
  }
}
</script>

Testing Access Controls

Finally, before we wrap up let's test our access controls and make sure they're all working. In order to fully do this we'll need to setup an admin group and assign at least one user to that group.

Let's head into the AWS Management Console and traverse to Cognito. Click "Manage User Pools" then click on your project name. Go to user's and group in the left hand menu then click the Groups tab. Click "Create Group" and create a group with the name "admin" and click "Create group," everything else can remain empty.

To assign this group to a user, go back to the Users tab. Click on a user, click "Add to group," select your admin group, then click "Add to group."

Cases To Test

  • A user whose not an admin cannot edit or delete a post they don't own.

  • A user who is an admin can edit or delete any post.

  • A user whose authenticated can view a list of posts and a single post.

  • A user whose unauthenticated can view a list of posts and a single post.

Next Lesson

That does it for this lesson. We skipped over updating and deleting our user's profile, however, the process is nearly identical to what we did for our posts. So, it'll serve as something good for you to walk through on your own, and if you should need help I'll be here.

In the next lesson we'll cover deploying.

Comment

Prepared By

Tom Gobich

Burlington, KY

Owner of Adocasts, JavaScript developer, educator, PlayStation gamer, burrito eater.

Visit Website