DEV Community

loading...
Cover image for Modern Full-Stack Serverless, Part III
AWS Community Builders

Modern Full-Stack Serverless, Part III

Salah Elhossiny
ML engineer || AWS Certified MLS || AWS Community Builders member || Fullstack developer
・13 min read

In this part, you will be creating a GraphQL API that interacts with a DynamoDB NoSQL database to perform CRUD+L (create, read, update, delete, and list) operations. You’ll learn what GraphQL is, why developers are adopting it, and how it works.

We will be building a notes app that will allow users to create, update, and delete notes. It will also have GraphQL subscriptions enabled in order to see updates in real time. If another user is interacting with the app and they create a new note, our app will update with the new values in real time.

Introduction to GraphQL

GraphQL is an API implementation that is an alternative to REST. Let’s have a look at what GraphQL is, what a GraphQL API consists of, and how GraphQL works.

What Is GraphQL?

GraphQL is an API specification. It is a query language for APIs and a runtime for fulfilling those queries with your data. It is, and can be used as, a replacement for REST and has some similarities to REST. GraphQL was introduced by Facebook in 2015, though it had been used internally since 2012. GraphQL allows clients to define the structure of the data that is required from an API call so that they can know exactly what data structure is going to be returned from the server. Requesting data in this way enables a much more efficient way for client-side applications to interact with backend APIs and services, reducing the amount of under-fetching of data, preventing the over-fetching of data, and preventing type errors.

What Makes Up a GraphQL API?

A GraphQL API consists of three main parts: schema, resolvers, and data sources.

Alt Text

The schema, written in GraphQL Schema Definition Language (SDL), defines the data model (types) and operations that can beexecuted against the API. The schema consists of base types (data models) and GraphQL operations like queries for fetching data; mutations for creating, updating, and deleting data; and subscriptions for subscribing to changes in data in real time. Here is an example of a GraphQL schema:

 # base type
  type Todo {
  id: ID
  name: String
  completed: Boolean
  }

  # Query definitions
  type Query {
  getTodo(id: ID): Todo
  listTodos: [Todo]
  }

  # Mutation definitions
  type Mutation {
  createTodo(input: Todo): Todo
  }

  # Subscription definitions
  type Subscription {
  onCreateTodo: Todo
  }
Enter fullscreen mode Exit fullscreen mode

Once the schema has been created, you can begin writing resolvers for the GraphQL operations defined in the schema (query, mutation, subscription). GraphQL resolvers tell the GraphQL operations what to do when being executed and will typically interact with some data source or another API

Alt Text

GraphQL Operations

GraphQL operations are how you interact with the API data sources. GraphQL operations can be similarly mapped to HTTP methods for RESTful APIs:

  GET -> Query
  PUT -> Mutation
  POST -> Mutation
  DELETE -> Mutation
  PATCH -> Mutation
Enter fullscreen mode Exit fullscreen mode

A GraphQL request operation looks similar to a JavaScript object with only the keys and no values. The keys and values are returned in the GraphQL operation response. Here’s an example of a typical GraphQL query fetching an array of items:

  query {
   listTodos {
     id
     name
     completed
    }
   }
Enter fullscreen mode Exit fullscreen mode

This request would return the following response:

{
"data": {
"listTodos": [
   { "id": "0", "name": "buy groceries", "completed": false },
   { "id": "1", "name": "exercise", "completed": true }
  ]
 }
}
Enter fullscreen mode Exit fullscreen mode

You can also pass arguments into a GraphQL operation. The following operation is a query for a Todo, passing in the ID of the Todo we’d like to fetch:

query {
   getTodo(id: "0") {
     name
     completed
   }
}
Enter fullscreen mode Exit fullscreen mode

This request would return the following response

{
"data": {
"getTodo": {
   "name": "buy groceries"
   "completed": false
  }
 }
}
Enter fullscreen mode Exit fullscreen mode

Though there are many ways to implement a GraphQL server, in this part we will be using AWS AppSync. AppSync is a managed service that allows us to deploy a GraphQL API, resolvers, and data sources quickly and easily using the Amplify CLI.

Creating the GraphQL API

Now that you have a basic understanding of what GraphQL is, let’s go ahead and start using it to build the Notes app.

The first thing you need to do is create a new React application and install the necessary dependencies. This app will be using the AWS Amplify library to interact with the API, uuid for creating unique ids, and the Ant Design library for styling:

  ~ npx create-react-app notesapp
  ~ cd notesapp
  ~ npm install aws-amplify antd uuid
Enter fullscreen mode Exit fullscreen mode

Now, within the root of the new app, you can create the Amplify project:

~ amplify init
With the Amplify project initialized, we can then add the GraphQL API:

~ amplify add api

? Please select from one of the below mentioned services:
GraphQL
? Provide API name: notesapi
? Choose the default authorization type for the API: API Key
? Enter a description for the API key: public (or some
description)
? After how many days from now the API key should expire:
365 (or your
preferred expiration)
? Do you want to configure advanced settings for the GraphQL
API: N
? Do you have an annotated GraphQL schema? N
? Do you want a guided schema creation? Y
? What best describes your project: Single object with
fields
? Do you want to edit the schema now? Y
Enter fullscreen mode Exit fullscreen mode

Next, open the base GraphQL schema (generated by the CLI), located at notesapp/amplify/backend/api/notesapi/schema.graphql, in your text editor. Update the schema to the following, and save it:

type Note @model {
   id: ID!
   clientId: ID
   name: String!
   description: String
   completed: Boolean
}
Enter fullscreen mode Exit fullscreen mode

This schema has a main Note type containing five fields. A field can be either nullable (not required) or non-nullable (required). A non- nullable field is specified with a ! character.

The Note type in this schema is annotated with an @model directive. This directive is not part of the GraphQL SDL; instead, it is part of the AWS Amplify GraphQL Transform library.

The GraphQL Transform library allows you to annotate a GraphQL schema with different directives like @model, @connection, @auth, and others.

The @model directive we used in this schema will transform the base Note type into an expanded AWS AppSync GraphQL API complete with:

  1. Additional schema definitions for queries and mutations (Create, Read, Update, Delete, and List operations)

  2. Additional schema definitions for GraphQL subscriptions

  3. DynamoDB database

  4. Resolver code for all GraphQL operations mapped to DynamoDB database To deploy the API, you can run the push command:

~ amplify push 
Enter fullscreen mode Exit fullscreen mode

Once the deployment has completed, the API and database have successfully been created in your account. Next, let’s open the newly created AppSync API in the AWS Console and test out a few GraphQL operations.

Viewing and Interacting with the GraphQL API To open the API in the AWS Console at any time, you can use the following command:

 ~ amplify console api
  > Choose GraphQL
Enter fullscreen mode Exit fullscreen mode

Once you’ve opened the AppSync console, click Queries in the
lefthand menu to open the query editor. Here, you can test out
GraphQL queries, mutations, and subscriptions using your API.
The first operation we’ll try out is a mutation to create a new note. In
the query editor, execute the following mutation:

mutation createNote {
  createNote(input: {
     name: "Book flight"
     description: "Flying to Paris on June 1 returning June
     10"completed: false
     }) {
     id name description completed
  }
}
Enter fullscreen mode Exit fullscreen mode

Now that you’ve created an item, you can try querying for it. Let’s try
to query for all of the notes in the app:


query listNotes {
    listNotes {
       items {
         id
         name
         description
         completed
    }
   }
}

Enter fullscreen mode Exit fullscreen mode

You can also try querying for a single note using the ID of one of the
notes:


query getNote {
  getNote(id: "<NOTE_ID>") {
     id
     name
     description
     completed
  }
}
Enter fullscreen mode Exit fullscreen mode

Building the React Application

The first thing you will need to do is configure the React application
to recognize the Amplify resources located at src/aws-exports.js. To
do so, open src/index.js and add the following below the last import:


import Amplify from 'aws-amplify'
import config from './aws-exports'
Amplify.configure(config)

Enter fullscreen mode Exit fullscreen mode

Listing Notes (GraphQL Query)

Now that the application has been configured, you can begin making
calls against the GraphQL API. The first operation we will be
implementing will be a query to list all of the notes.

The query will return an array and we will map over all of the items
in the array, showing the note name, description, and whether or not it
is completed.

In src/App.js, first import the following at the top of the file:


import React, {useEffect, useReducer} from 'react'
import { API } from 'aws-amplify'
import { List } from 'antd'
import 'antd/dist/antd.css'
import { listNotes } from './graphql/queries'

Enter fullscreen mode Exit fullscreen mode

Next, we will need to create a variable to hold our initial application
state. Because our application will be holding and working with
multiple state variables, we will use the useReducer hook from
React to manage state.
useReducer has the following API:

  const [state, dispatch] = useReducer(reducer <function>, initialState <any>); 

Enter fullscreen mode Exit fullscreen mode

useReducer accepts a reducer function of type (state, action) => newState and initialState as arguments:


/* Example of some basic state */

  const initialState = { notes: [] }

  /* Example of a basic reducer */

  function reducer(state, action) {
     switch(action.type) {
     case 'SET_NOTES':
     return { ...state, notes: action.notes }
     default:
     return state
     }
  }

  /* Implementing useReducer */

  const [state, dispatch] = useReducer(reducer: <function>, initialState: <any>);

  /* Sending an update to the reducer */

  const notes = [{ name: 'Hello World' }]

  dispatch({ type: 'SET_NOTES', notes: notes })

  /* Using the state in your app */

  {
    state.notes.map(note => <p>{note.name}</p>)
  }
Enter fullscreen mode Exit fullscreen mode

When invoked, the useReducer hook returns an array containing
two items:

  1. The application state
    A dispatch function (this function allows you to update
    the application state)

  2. The initial state of our Notes application will hold an array for the
    notes, form values, error, and loading state.

In src/App.js, add the following initialState object after the last import:

 const initialState = {
   notes: [],
   loading: true,
   error: false,
   form: { name: '', description: '' }
 }
Enter fullscreen mode Exit fullscreen mode

Then create the reducer. For now, the reducer will only have cases to
either set the notes array or set an error state:

function reducer(state, action) {
   switch(action.type) {
   case 'SET_NOTES':
   return { ...state, notes: action.notes, loading: false
   }
   case 'ERROR':
   return { ...state, loading: false, error: true }
   default:
   return state
   }
}
Enter fullscreen mode Exit fullscreen mode

Next, update the main App function to create the state and dispatch
variables by calling useReducer and passing in the reducer and initialState:

 export default function App() {
    const [state, dispatch] = useReducer(reducer, initialState); 
 }
Enter fullscreen mode Exit fullscreen mode

To fetch the notes, create a fetchNotes function (in the main App function) that will call the AppSync API and set the notes array once the API call is successful:

async function fetchNotes() {
   try {
     const notesData = await API.graphql({
     query: listNotes
   })

   dispatch({ type: 'SET_NOTES', notes: notesData.data.listNotes.items }); 

   } catch (err) {
     console.log('error: ', err)
     dispatch({ type: 'ERROR' })
   }
}
Enter fullscreen mode Exit fullscreen mode

Now, invoke the fetchNotes function by implementing the
useEffect hook (in the main App function):

useEffect(() => {
  fetchNotes()
}, [])

Enter fullscreen mode Exit fullscreen mode

The next thing you need to do is return the main UI for the
component. In the main App function, add the following:

   return (
      <div style={styles.container}>
      <List
      loading={state.loading}
      dataSource={state.notes}
      renderItem={renderItem}
      />
      </div>
   )

Enter fullscreen mode Exit fullscreen mode

Here we are using the List component from Ant Design. This
component will map over an array (dataSource) and return an
item for each item in the array by calling the renderItem function.

Next, define renderItem (in the main App function):

function renderItem(item) {
 return (
   <List.Item style={styles.item}>
   <List.Item.Meta
   title={item.name}
   description={item.description}
   />
   </List.Item>
 )
}
Enter fullscreen mode Exit fullscreen mode

Finally, create the styles for the components we will be using for this app:

   const styles = {
      container: {padding: 20},
      input: {marginBottom: 10},
      item: { textAlign: 'left' },
      p: { color: '#1890ff' }
   }

Enter fullscreen mode Exit fullscreen mode

Now we are ready to run the app! In the terminal, run the start command:

~ npm start

Enter fullscreen mode Exit fullscreen mode

Creating Notes (GraphQL Mutation)

Now that you know how to query for a list of notes, let’s take a look
at how to create a new note. To do so, you’ll need the following:

  1. A form to create a new note
  2. A function to update the state as the user types into the form
  3. A function to add the new note to the UI and send an API call to create a new note

First, import the UUID library so you can create a unique identifier for the client.

We do this now so that later on when we implement subscriptions we can identify the client that created the note.

We will also import the Input and Button components from Ant Design:

import { v4 as uuid } from 'uuid'
import { List, Input, Button } from 'antd'
Enter fullscreen mode Exit fullscreen mode

Next, you will need to import the createNote mutation definition:

import { createNote } from './graphql/mutations'
Enter fullscreen mode Exit fullscreen mode

Then, create a new CLIENT_ID variable below the last import:

const CLIENT_ID = uuid()
Enter fullscreen mode Exit fullscreen mode

Update the switch statement in the reducer to add three new cases.
We will need a new case for the following three actions:

  1. Adding a new note to the local state
  2. Resetting the form state to clear out the form
  3. Updating the form state when the user types

 case 'ADD_NOTE':
   return { ...state, notes: [action.note, ...state.notes]}
 case 'RESET_FORM':
   return { ...state, form: initialState.form }
 case 'SET_INPUT':
   return { ...state, form: { ...state.form, [action.name] : action.value } }
Enter fullscreen mode Exit fullscreen mode

Next, create the createNote function in the main App

function:async function createNote() {
   const { form } = state
   if (!form.name || !form.description) {
      return alert('please enter a name and description')
   }
   const note = { ...form, clientId: CLIENT_ID, completed: false, id: uuid() }

   dispatch({ type: 'ADD_NOTE', note })
   dispatch({ type: 'RESET_FORM' })

   try {
     await API.graphql({
       query: CreateNote,
       variables: { input: note }
     })
     console.log('successfully created note!')
   } catch (err) {
     console.log("error: ", err)
   }
}
Enter fullscreen mode Exit fullscreen mode

In this function, we are updating the local state before the API call is successful. This is known as an optimistic response.

It is done because we want the UI to be fast and to update as soon as the user
adds a new note.

If the API call fails, you can then implement some functionality in the catch block to notify the user of the error if you would like.

Now, create an onChange handler in the main App function to update the form state when the user interacts with an input:

 function onChange(e) {
   dispatch({ type: 'SET_INPUT', name: e.target.name, value: e.target.value })
 } 
Enter fullscreen mode Exit fullscreen mode

Finally, we will update the UI to add the form components.

Before the List component, add the following two inputs and button:


<Input
 onChange={onChange}
 value={state.form.name}
 placeholder="Note Name"
 name='name'
 style={styles.input}
/>

<Input
 onChange={onChange}
 value={state.form.description}
 placeholder="Note description"
 name='description'
 style={styles.input}
/>

<Button
 onClick={createNote}
 type="primary"
>
 Create Note
</Button>

Enter fullscreen mode Exit fullscreen mode

Deleting Notes (GraphQL Mutation)

Next, let’s take a look at how to delete a note. To do so, we’ll need
the following:

  1. A deleteNote function to delete the note both from the
    UI and from the GraphQL API

  2. A button in each note to invoke the deleteNote function

First, import the deleteNote mutation:

  import {
    createNote,
    deleteNote
  } from './graphql/mutations'; 
Enter fullscreen mode Exit fullscreen mode

Then, create a deleteNote function in the main App function:

async function deleteNote({ id }) {
   const index = state.notes.findIndex(n => n.id === id)
   const notes = [
     ...state.notes.slice(0, index),
     ...state.notes.slice(index + 1)];
   dispatch({ type: 'SET_NOTES', notes })
   try {
      await API.graphql({
        query: DeleteNote,
        variables: { input: { id } }
      }); 
      console.log('successfully deleted note!'); 
   } catch (err) {
   console.log({ err }); 
   }
}

Enter fullscreen mode Exit fullscreen mode

In this function, we are finding the index of the note and creating a
new notes array without the deleted note. We then dispatch the
SET_NOTES action passing in the new notes array to update the local
state and show an optimistic response. Next, we call the GraphQL
API to delete the note in the AppSync API.
Now, update the List.Item component in the renderItem
function to add a delete button to the actions prop that will call the
deleteNote function, passing in the item:

<List.Item
 style={styles.item}
 actions={[
  <p style={styles.p} onClick={() =>
     deleteNote(item)}>Delete</p>
   ]}
  >
 <List.Item.Meta
   title={item.name}
   description={item.description}
 />
</List.Item>

Enter fullscreen mode Exit fullscreen mode

The next piece of functionality we want to add is the ability to update
a note to be completed. To do so, you’ll need the following:

  1. An updateNote function to update the note in the UI and in the GraphQL API
  2. A button in each note to invoke the updateNote function

First, import the updateNote mutation:


import {
  updateNote,
  createNote, 
  deleteNote
} from './graphql/mutations';

Enter fullscreen mode Exit fullscreen mode

Next, create an updateNote function in the main App function:


async function updateNote(note) {
   const index = state.notes.findIndex(n => n.id === note.id)
   const notes = [...state.notes]
   notes[index].completed = !note.completed
   dispatch({ type: 'SET_NOTES', notes})
   try {
     await API.graphql({
       query: UpdateNote,
       variables: { input: { id: note.id, completed: notes[index].completed } }
     })
     console.log('note successfully updated!')
   } catch (err) {
     console.log('error: ', err)
   }
}

Enter fullscreen mode Exit fullscreen mode

In this function, we are first finding the index of the selected note,
then creating a copy of the notes array. We then update the completed
value of the selected note to be the opposite of what it currently is.
We then update the notes array with the new version of the note, setthe notes array in the local state, and call the GraphQL API, passing
in the note that needs to be updated in the API.

Finally, update the List.Item component to add an update button
that will call the updateNote function, passing in the item. This
component will render either completed or mark complete
depending on the value of the completed Boolean of the item
(based on whether completed is true or false):

<List.Item
 style={styles.item}
 actions={[
   <p style={styles.p} onClick={() =>
     deleteNote(item)}>Delete</p>,
   <p style={styles.p} onClick={() => updateNote(item)}>
    {item.completed ? 'completed' : 'mark completed'}
   </p>
]}

Enter fullscreen mode Exit fullscreen mode

Real-Time Data (GraphQL Subscriptions)

The last piece of functionality we will implement is the ability to
subscribe to updates in real time. The update that we’d like to
subscribe to is when a new note has been added. When this happens,
the functionality we’d like to implement is to have our application
receive that new note, update the notes array with the new note, and
render the updated notes array to our screen.

To do this, you will be implementing a GraphQL subscription. With
GraphQL subscriptions, you can subscribe to different events. These
events are usually some type of mutation (on create, on update, on
delete). When one of these events happens, the data from the event
gets sent to the client that initialized the subscription. It is then up to
you to handle the data that comes in on the client.

To make this work, you’ll only need to initialize the subscription in
the useEffect hook and dispatch the ADD_NOTE type along with
the note data when a subscription is fired.

First, import the onCreateNote subscription:

 import { onCreateNote } from './graphql/subscriptions'
Enter fullscreen mode Exit fullscreen mode

Next, update the useEffect hook with the following code:

useEffect(() => {
 fetchNotes(); 
 const subscription = API.graphql({
  query: onCreateNote})
 .subscribe({
   next: noteData => {
    const note = noteData.value.data.onCreateNote; 

    if (CLIENT_ID === note.clientId) return; 

    dispatch({ type: 'ADD_NOTE', note }); 
   }
  })
  return () => subscription.unsubscribe()
}, [])

Enter fullscreen mode Exit fullscreen mode

In this subscription, we are subscribing to the onCreateNote
event. When a new note is created, this event gets triggered and the
next function is invoked, passing in the note data as the parameter.
We take the note data and check to see if our client is the application
that created the note. If our client created the note, we return without
going any further. If we are not the client that created the note, we
dispatch the ADD_NOTE action, passing in the note data from the
subscription.

Conclusion

Congratulations, you’ve deployed your first serverless GraphQL
application!

Here are a few things to keep in mind from this chapter:

  1. The useEffect hook is similar to componentDidMount from the React lifecycle methods in that it runs after the component first renders.

  2. The useReducer hook allows you to manage application state and is preferable to useState when having morecomplex application logic.

  3. GraphQL queries are used for fetching data in a GraphQL API.

  4. GraphQL mutations are used for creating, updating, or deleting data in a GraphQL API.

  5. You can subscribe to API real-time events in a GraphQL API by using GraphQL subscriptions.

Resources:

Book: Full Stack Serverless: Modern Application Development with React, AWS, and GraphQL

By: Nader Dabit

Part 1: https://dev.to/salah856/modern-full-stack-serverless-part-i-34cb

Part 2: https://dev.to/aws-builders/modern-full-stack-serverless-part-ii-94i

Discussion (1)

Collapse
eissorcercode99 profile image
The EisSorcer

Thank you