Skip to content

Building a Social Network with Amplify Flutter (Part 2)

Published:

11 min read

AWS Amplify and Flutter

To see the completed project, check out the Github page.

In the last part, we looked at the User type and how to hook into the signup flow. In this part, we’ll look at the rest of the GraphQL API, including the vote mutation which leverages custom resolvers to achieve fair, atomic voting on the platform with a real-time stream.

API

To start, a look at the API for Habits, one of the two remaining model types in our schema.

type Habit 
    # Creates a model with all query, mutation, and subscription fields.
    @model

    # Allows searching and sorting habits by category.
    @key(
        name: "habitsByCategory", 
        fields: ["category", "ups"], 
        queryField: "habitsByCategory"
    )

    # Allows searching and sorting habits by their poster.
    @key(name: "habitsByUser", fields: ["owner"])

    # Defines the authorization rules for model access
    @auth(rules: [
        # Unrestricted access to members of the "admin" user pool group
        { allow: groups, groups: ["admin"] }

        # Unrestricted access to API key (used in Lambdas)
        { allow: public }

        # Read access for all authenticated users
        { allow: private, operations: [read] }

        # Read, create, and delete access for owners 
        # (habits cannot be updated once created)
        { allow: owner, operations: [create, delete] }
    ])

    # Enable full-text search on all fields
    @searchable {

    # The habit's unique ID.
    id: ID!

    # A short summary of the habit.
    tagline: String!

    # The category to which the habit belongs.
    category: Category!

    # A long-form description of the habit and the user's steps
    # to achieve it.
    details: String

    # The number of upvotes this habit received, used for calculating 
    # score.
    ups: Int
        # Restrict mutations to admin + API key
        @auth(rules: [
            { allow: public }
            { allow: private, operations: [read] }
            { allow: groups, groups: ["admin"] }
            { allow: owner, operations: [create] }
        ])

    # The number of downvotes this habit received, used for calculating 
    # score.
    downs: Int
        # Restrict mutations to admin + API key
        @auth(rules: [
            { allow: public }
            { allow: private, operations: [read] }
            { allow: groups, groups: ["admin"] }
            { allow: owner, operations: [create] }
        ])

    # The username of the user who created the habit.
    owner: String

    # The user who created the habit.
    author: User
        @connection(fields: ["owner"])

    # The list of comments associated with this habit.
    comments: [Comment!]
        @connection(keyName: "commentsByHabit", fields: ["id"])
}

# The available categories for habits.
enum Category { Health, Finance, Productivity, Relationships }

Habits are created by users on the platform—i.e. they have an “owner”—and that owner is allowed to create and delete them as they wish. Note: Habitrs cannot update their habits after posting. These permissions are expressed with a single @auth rule:

{ allow: owner, operations: [create, delete] }

However, owners should not be allowed to modify the vote totals associated with a habit (imagine the mayhem!) To restrict the privilege around the ups and downs fields, we set an additional set of @auth restrictions:

# The number of upvotes this habit received, used for calculating 
# score.
ups: Int
    # Restrict mutations to admin + API key
    @auth(rules: [
        { allow: public }
        { allow: private, operations: [read] }
        { allow: groups, groups: ["admin"] }
        { allow: owner, operations: [create] }
    ])

# The number of downvotes this habit received, used for calculating 
# score.
downs: Int
    # Restrict mutations to admin + API key
    @auth(rules: [
        { allow: public }
        { allow: private, operations: [read] }
        { allow: groups, groups: ["admin"] }
        { allow: owner, operations: [create] }
    ])

Vote

For one part of the schema, I knew I would need some custom functionality: the voting mutation. To ensure a fair and accurate voting system means that users should be bound to casting a single vote and that each vote is correctly tallied in the database. By default, this kind of requirement is not supported by either AppSync or Amplify. However, DynamoDB does have atomic add/subtract operations which means that, in theory, we should be able to achieve the results we’re after. And in practice, this turns out to work very well.

Defining a custom GraphQL operation is as simple as adding it to the root operation in the schema and creating the accompanying resolver files in the “resolvers” folder.

type Mutation {
    vote(habitId: ID!, type: VoteType!): VoteResult
}

# The different ways to cast a vote.
enum VoteType { upvote, downvote, removeUpvote, removeDownvote }

# The result of casting a vote using the `vote` mutation.
type VoteResult {
    habit: Habit
    user: User
}

Each resolver has a request/response template that maps the inputs in the GraphQL schema to DynamoDB operations, and then ultimately to a JSON response object or some kind of error. Resolver templates are specifically named as OperationType.name.(req/res).vtl.

When you use Amplify, these resolver files are already generated for you and used to implement things like authorization logic and DynamoDB operations. In our case, we’ll create the following files:

But to write the voting resolver, we will need to combine multiple DynamoDB operations, since we need to access/modify information from multiple tables. And since each resolver can only perform one operation, we need a separate resolver for each step:

Note: While this information could be sent through the GraphQL API, we do not ever want to trust what the user sends - our DynamoDB tables should be the source of truth, and we can protect those sources through authorization logic and input validation.

When creating a single GraphQL operation backed by a series of resolvers, it’s called a pipeline function and requires a few extra steps to configure over a single resolver. The main request and response template serve the same purpose, but we must create a new request/response duo for each intermediate step and wire them together using the CustomResources.json file which can be found in every Amplify backend folder.

Resolver flow

Altogether, we end up with the following structure:

amplify/
  backend/
    api/
      habitr/
        pipelineFunctions/
          VoteHabitFunction.req.vtl
          VoteHabitFunction.res.vtl
          VoteUserGetFunction.req.vtl
          VoteUserGetFunction.res.vtl
          VoteUserPutFunction.req.vtl
          VoteUserPutFunction.res.vtl
        resolvers/
          Mutation.vote.req.vtl
          Mutation.vote.res.vtl
        stacks/
          CustomResources.json

DynamoDB resolvers are written in the Apache Velocity Template (VTL) language. It should be (hopefully) easy enough to follow along as we walk through the steps, but in case you have questions or want to learn more, there is a great reference for the many supported operations available here.

Mutation.vote.req.vtl

This is the entry point to our vote mutation. By this point, the GraphQL request has been parsed by AppSync and translated to a VTL representation which we can access and manipulate.

## Allow only Cognito User Pool-authenticated users

## 1
#set($username = $ctx.identity.username)
#if( $util.isNull($username) )
    $util.unauthorized()
#end

## 2
$util.qr($ctx.stash.put("username", $username))

## 3
$util.qr($ctx.stash.put("habitId", $ctx.arguments.habitId))

## 4
{}

DynamoDB includes several useful functions and utilities for building Velocity templates.

1. On the first line, we see that we have access to the username of the user performing the action, which it has parsed from their JWT token. It may be null, in which case we immediately deny the request using the unauthorized utility function.

2. We store the username in our “stash”, which is an empty map where we can store anything we’d like to keep in between resolvers. In this case, we will need the username later when retrieving data from the User’s table.

3. We also have access to the arguments to the GraphQL request. Keeping in mind our mutation signature, we have access to habitId and type on $ctx.arguments:

vote(habitId: ID!, type: VoteType!): VoteResult

4. Remember, this is a templating language, and when this template is resolved, it expects a JSON document. The previous steps have not rendered anything since everything inside #set, #if, and other built-in directives is not printed. Likewise, we make use of the $util.qr function to silence the output of commands and prevent their injection into the template.

So, in the end, we render an empty JSON object. We could have passed the username through this object as well since it will be available in the $ctx.result object of the next resolver. But whereas the result of a template is only available in the resolvers which immediately follow, the stash persists through the whole pipeline.

VoteUserGetFunction.req.vtl

## 1
#set($username = $ctx.stash.username)

## 2
{
    "version": "2018-05-29",
    "operation": "GetItem",
    "key": {
        "username": $util.dynamodb.toDynamoDBJson($username)
    }
}

Here, we construct our first DynamoDB request.

1. First, we retrieve the username from the stash.

2. Then, we use that to build a GetItem operation, using the username as the key, since we had previously set the username field as the primary key of our User model.

VoteUserGetFunction.res.vtl

## 1
#if ($ctx.error)
    $util.error($ctx.error.message, $ctx.error.type)
#else

#set($user = $ctx.result)
#set($habitId = $ctx.stash.habitId)

## 2
## Determine if the habit was previously upvoted or downvoted
#set($wasUpvoted = false)
#set($wasDownvoted = false)
#foreach($upvotedHabit in $user.upvotedHabits)
    #if($upvotedHabit == $habitId)
    #set($wasUpvoted = true)
    #end
#end

#foreach($downvotedHabit in $user.downvotedHabits)
    #if($downvotedHabit == $habitId)
    #set($wasDownvoted = true)
    #end
#end

#if($wasUpvoted && $wasDownvoted)                               
    $util.error("Inconsistent state.")
#end

## 3
$util.qr($ctx.stash.put("wasUpvoted", $wasUpvoted))
$util.qr($ctx.stash.put("wasDownvoted", $wasDownvoted))

## 4
$util.toJson($ctx.result)
#end

1. At this point, the previous DynamoDB operation could have either succeeded or failed. If it failed, we use the error utility to cancel the request and populate a GraphQL errors entry. If it succeeded, we can use the user data to build our input to the next resolver.

2. Before we do, though, we want to gather some information for later and verify its state. We check whether the user has upvoted or downvoted the habit already, and sanity check the case where the user has both upvoted and downvoted the habit.

3. Then, we save these values for later use, so we don’t have to re-compute them.

4. In this case, we just pass along the user information. The toJson function renders a JSON object into the resolved template. This will become the $ctx.result object of the next template.

VoteHabitFunction.req.vtl

## Updates the Habit by atomically incrementing or decrementing 
## the votes.

## 1
#set($username = $ctx.stash.username)
#set($habitId = $ctx.stash.habitId)
#set($wasUpvoted = $ctx.stash.wasUpvoted)
#set($wasDownvoted = $ctx.stash.wasDownvoted)

#set($type = $ctx.arguments.type)
#set($upvote = $type == "upvote")
#set($downvote = $type == "downvote")
#set($removeUpvote = 
    $type == "removeUpvote" || ($downvote && $wasUpvoted)
)
#set($removeDownvote = 
    $type == "removeDownvote" || ($upvote && $wasDownvoted)
)

#if($wasUpvoted && $upvote)
#return
#elseif($wasDownvoted && $downvote)
#return
#end

$util.qr($ctx.stash.put("upvote", $upvote))
$util.qr($ctx.stash.put("downvote", $downvote))
$util.qr($ctx.stash.put("removeUpvote", $removeUpvote))
$util.qr($ctx.stash.put("removeDownvote", $removeDownvote))

## 2
{
    "version": "2018-05-29",
    "operation": "UpdateItem",
    "key": {
        "id": $util.dynamodb.toDynamoDBJson($habitId)
    },

    ## 3
    #set($expression = "")
    #set($expressionValues = {})
    #if($upvote)
        #set($expression = "ADD ups :plusOne")
    #elseif($downvote)
        #set($expression = "ADD downs :plusOne")
    #end

    #if($removeUpvote)
        #if($util.isNullOrEmpty($expression))
            #set($expression = "ADD")
        #else
            #set($expression = "$expression,")
        #end
        #set($expression = "$expression ups :minusOne")
    #elseif($removeDownvote)
        #if($util.isNullOrEmpty($expression))
            #set($expression = "ADD")
        #else
            #set($expression = "$expression,")
        #end
        #set($expression = "$expression downs :minusOne")
    #end

    ## 4
    "update": {
        "expression": "$expression",

        ## DynamoDB complains if there are unused values here.
        #if($upvote || $downvote)
            $util.qr($expressionValues.put(":plusOne", { "N": 1 }))
        #end
        #if($removeUpvote || $removeDownvote)
            $util.qr($expressionValues.put(":minusOne", { "N": -1 }))
        #end
        "expressionValues": $util.toJson($expressionValues)
    }
}

Next, we want to update the habit itself so that we can alter its upvote and downvote totals. And here, we have everything we need to determine what is allowed and how to change the totals.

1. We gather the information from the previous steps and determine which operations we need to perform. Since upvotes and downvotes are split between two fields, sometimes we must perform two actions. For example, in the case where the user has previously upvoted the post and clicks the downvote button, we need to both decrement the number of upvotes and increment the number of downvotes. We stash this information for later as well.

2. We build a DynamoDB UpdateItem operation. We can update multiple fields on a single item at once, but can only update one item with this operation.

3. We need to create expressions of the form:

ADD <field> <value>(, <field> <value>)

where <field> is the name of the field and <value> is a reference to a DynamoDB value, specified in the "expressionValues" key of the "update" dictionary.

4. We populate the "update" dictionary with the built expression and our standard values (+1/-1).

For example, if the user had previously upvoted a habit, and then clicked downvote, this template would render to:

{
    "version": "2018-05-29",
    "operation": "UpdateItem",
    "key": {
        "id": "<habitId>"
    },
    "update": {
        "expression": "ADD downs :plusOne, ups :minusOne",
        "expressionValues": {
            ":plusOne": {
                "N": 1
            },
            ":minusOne": {
                "N": -1
            }
        }
    }
}

VoteHabitFunction.res.vtl

#if ($ctx.error)
    $util.error($ctx.error.message, $ctx.error.type)
#else
$util.toJson($ctx.result)
#end

We’re in the home stretch now. Here we simply forward the result of the DynamoDB UpdateItem operation, which is the updated item, to the next step.

VoteUserPutFunction.req.vtl

## Updates the user by adding/removing the habit from their tracking, 
## as appropriate.

## Return if the last function did not execute. Otherwise, we can 
## assume it succeeded and make the necessary updates to the user.
#if($util.isNull($ctx.prev.result))
#return
#end

#set($username = $ctx.stash.username)
#set($habitId = $ctx.stash.habitId)
#set($upvote = $ctx.stash.upvote)
#set($downvote = $ctx.stash.downvote)
#set($removeUpvote = $ctx.stash.removeUpvote)
#set($removeDownvote = $ctx.stash.removeDownvote)
{
    "version": "2018-05-29",
    "operation": "UpdateItem",
    "key": {
        "username": $util.dynamodb.toDynamoDBJson($username)
    },

    ## 1
    #if($upvote)
        #set($expression = "ADD upvotedHabits :habitId DELETE downvotedHabits :habitId")
    #elseif($downvote)
        #set($expression = "ADD downvotedHabits :habitId DELETE upvotedHabits :habitId")
    #elseif($removeUpvote)
        #set($expression = "DELETE upvotedHabits :habitId")
    #elseif($removeDownvote)
        #set($expression = "DELETE downvotedHabits :habitId")
    #end

    "update": {
        "expression": "${expression}",

        ## 2
        "expressionValues": {
            ":habitId" : { "SS": [ "${habitId}" ] }
        }
    }
}

Most of this should look familiar now. Our final step is to update the user. We’ve been tracking which habits they’ve upvoted and downvoted in lists of habit IDs in the schema. Our DynamoDB backend uses string sets behind the scenes so that only unique IDs are stored in these lists.

1. We can use the ADD and DELETE DynamoDB operations to insert or remove values from the string sets.

2. We can only add or delete string sets from other string sets, though, so we create one with the habit ID.

VoteUserPutFunction.res.vtl

#if ($ctx.error)
    $util.error($ctx.error.message, $ctx.error.type)
#else
$util.toJson({
    "habit": $ctx.prev.result,
    "user": $ctx.result
})
#end

Now we get to build our response to the user. We can do this here or in the next step, but since we have the updated user available in the $ctx.result object, we do it here.

Mutation.vote.res.vtl

#if ($ctx.error)
    $util.error($ctx.error.message, $ctx.error.type)
#else
$util.toJson($ctx.prev.result)
#end

Last, but not least, we render this result so that AppSync can transform it into a GraphQL response.

Creating the Pipeline

To activate this pipeline, we need to use the CustomResources.json file to tie them all together. A snippet is pasted below (to see the full file, click here).

// ...

  "VoteMutation": {
    "Type": "AWS::AppSync::Resolver",
    "Properties": {
      "ApiId": {
        "Ref": "AppSyncApiId"
      },
      "TypeName": "Mutation",
      "FieldName": "vote",
      "Kind": "PIPELINE",
      "PipelineConfig": {
        "Functions": [
          {
            "Fn::GetAtt": [
              "VoteUserGetFunction",
              "FunctionId"
            ]
          },
          {
            "Fn::GetAtt": [
              "VoteHabitFunction",
              "FunctionId"
            ]
          },
          {
            "Fn::GetAtt": [
              "VoteUserPutFunction",
              "FunctionId"
            ]
          }
        ]
      }
    }
  }

// ...

Vote Subscription

Luckily, once a custom resolver or pipeline function is written, it’s very simple to subscribe to changes! With this subscription, the app can update real-time when votes change in the backend.

type Subscription {
    subscribeToVotes: VoteResult
        @aws_subscribe(mutations: ["vote"])
        @aws_api_key
        @aws_cognito_user_pools
}

Phew! That was a lot to cover, but we’re not quite done. How can we know that our resolvers are working as intended? …that users can truly only send one upvote or one downvote? And that multiple users voting at once will not create conflicts in the database?

Stay tuned, because that’s exactly what we’ll look at in the next part!