Skip to content

Building a Social Network with Amplify Flutter (Part 3)

Published:

6 min read

AWS Amplify and Flutter

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

In the last part, we wrote a custom AppSync resolver to perform atomic voting operations that keep our social network, Habitr, fair. But how do we know it works? In this part, we’ll answer that question.

Custom Resolver

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
}

As a refresher, to vote on a particular habit we developed a custom mutation that is backed by an AppSync pipeline function, or a series of custom resolvers. These resolvers start by getting the current user, then perform an atomic update of the habit, and finally, update the user.

Resolver flow

Validation

To validate our code, we’re going to perform a simple test:

  1. Create 1000 test accounts in Cognito
  2. Get the vote totals for a particular habit
  3. Have each of the test accounts simultaneously cast a vote on that habit
  4. Keep track of how they vote and the expected end totals
  5. Verify the end totals match what’s expected

The language I chose to accomplish this task was Go, because it was the language I was most familiar with of the languages which have an AWS SDK. It also happens to have very nice concurrency primitives which make steps 3 and 4 particularly easy. If you’re unfamiliar with Go, it has a very simple syntax and the snippets below should be familiar to anyone who has used a C-like language.

Creating the Test Accounts

To create the test accounts, we use the Cognito SDK from the AWS SDK and call the AdminCreateUser function. We also manually add them to our AppSync backend with a GraphQL mutation to createUser.

for i := 0; i < cfg.NumWorkers; i++ {
  username := fmt.Sprintf("test%d", i)

  log.Printf("Creating user %d...\n", i)

  _, err := cognitoClient.AdminCreateUser(
    ctx, 
    &cognitoidentityprovider.AdminCreateUserInput{
      UserPoolId:        &cfg.UserPoolId,
      Username:          &username,
      TemporaryPassword: aws.String(DefaultPassword),
      UserAttributes:    []types.AttributeType{
        {
          Name:  aws.String("email"),
          Value: aws.String("test@example.com"),
        },
      },
    },
  )
  if err != nil {
    return err
  }

  var mutation struct {
    CreateUser struct {
      Username graphql.String
    } `graphql:"createUser(input: {username: $username})"`
  }
  err = graphqlClient.Mutate(
    ctx, 
    &mutation, 
    map[string]interface{}{
      "username": graphql.String(username),
    },
  )
  if err != nil {
    return err
  }
}

Logging In the Test Accounts

After the test accounts have been created, we need to get Cognito credentials for each to be able to call our vote mutation. For this we use the AdminInitiateAuth and, if necessary, AdminRespondToAuthChallenge, functions from the Cognito SDK. The AdminRespondToAuthChallenge call is needed when Cognito requires the user to change their password before logging in.

We use the AccessToken and IdToken values returned from the endpoint to create User objects which we’ll use in the next step.

initAuthOut, err := cognitoClient.AdminInitiateAuth(
  ctx, 
  &cognitoidentityprovider.AdminInitiateAuthInput{
    AuthFlow:       types.AuthFlowTypeAdminUserPasswordAuth,
    ClientId:       &cfg.AppClientId,
    UserPoolId:     &cfg.UserPoolId,
    AuthParameters: map[string]string{
      "USERNAME": username,
      "PASSWORD": DefaultPassword,
    },
  },
)
if err != nil {
  return err
}

if initAuthOut.ChallengeName != "" {
  authChallengeOut, err := cognitoClient.AdminRespondToAuthChallenge(
    ctx, 
    &cognitoidentityprovider.AdminRespondToAuthChallengeInput{
      ChallengeName:      initAuthOut.ChallengeName,
      ClientId:           &cfg.AppClientId,
      UserPoolId:         &cfg.UserPoolId,
      Session:            initAuthOut.Session,
      ChallengeResponses: map[string]string{
        "USERNAME":   username,
        "NEW_PASSWORD": DefaultPassword,
      },
    },
  )
  if err != nil {
    return err
  }

  user := &User{
    Username:    username,
    AccessToken: *authChallengeOut.AuthenticationResult.AccessToken,
    IdToken:     *authChallengeOut.AuthenticationResult.IdToken,
  }

  resCh <- user
  return nil
}

user := &User{
  Username:    username,
  AccessToken: *initAuthOut.AuthenticationResult.AccessToken,
  IdToken:     *initAuthOut.AuthenticationResult.IdToken,
}
resCh <- user
return nil

Concurrently Voting

To perform the voting is fairly straightforward in Go. We will spin up a “goroutine” for each of the voters, lightweight threads which can run concurrently with respect to each other. The actual vote is performed using a GraphQL client, and should look very familiar:

func vote(cfg common.Config, client *graphql.Client, upvote bool) error {
  var mutation struct {
    Vote struct {
      Habit struct {
        ID       graphql.ID
        Tagline  graphql.String
        Category graphql.String
        Ups      graphql.Int
        Downs    graphql.Int
      }
      User struct {
        Username        graphql.String
        UpvotedHabits   []graphql.String
        DownvotedHabits []graphql.String
      }
    } `graphql:"vote(habitId: $habitId, type: $type)"`
  }
  var voteType string
  if upvote {
    voteType = "upvote"
  } else {
    voteType = "downvote"
  }
  err := client.Mutate(context.Background(), &mutation, map[string]interface{}{
    "habitId": graphql.ID(cfg.HabitId),
    "type":  VoteType(voteType),
  })

  return err
}

We keep track of the vote totals locally using two atomic integers, representing the number of upvotes and downvotes cast. For each of the voters, we randomly choose whether they will upvote or downvote. Then, we fire off a goroutine with the choice and schedule the respective counter to be incremented on completion.

func spawnVoters(ctx context.Context, cfg common.Config, clients []*graphql.Client) error {
  eg := new(errgroup.Group)

  for _, client := range clients {
    client := client
    eg.Go(func() error {
      upvote := rand.Intn(2)
      if upvote == 1 {
        defer atomic.AddInt32(&upsCounter, 1)
        return vote(cfg, client, true)
      } else {
        defer atomic.AddInt32(&downsCounter, 1)
        return vote(cfg, client, false)
      }
    })
  }

  return eg.Wait()
}

Checking the Results

Finally, we simply perform a query on the AppSync API to get the number of upvotes and downvotes and compare those to the totals we calculated.

var after getHabit
err = graphqlClient.Query(ctx, &after, map[string]interface{}{
  "habitId": graphql.ID(cfg.HabitId),
})
if err != nil {
  common.ExitError(err)
}

expectedUps := int32(before.GetHabit.Ups) + upsCounter
expectedDowns := int32(before.GetHabit.Downs) + downsCounter

gotUps := after.GetHabit.Ups
gotDowns := after.GetHabit.Downs

log.Printf("Votes After: (Ups %d Downs %d)\n", gotUps, gotDowns)
log.Printf("Expected: (Ups %d Downs %d)\n", expectedUps, expectedDowns)
if expectedUps == int32(gotUps) && expectedDowns == int32(gotDowns) {
  log.Println("Success!")
} else {
  fmt.Fprintf(os.Stderr, "Error! Votes do not match.")
}

Putting it All Together

To see it all work, check out this video where 1000 voters concurrently cast their votes for the first habit on the list.

If it’s hard to see, the following logs are printed:

2021/06/09 08:46:45.964598 Logging in users...
2021/06/09 08:46:55.677857 Successfully logged in users...
2021/06/09 08:46:55.960918 Votes Before: (Ups 0 Downs 0)
2021/06/09 08:47:01.442812 Voting completed in 5480 milliseconds. 1000 votes cast.
2021/06/09 08:47:01.521798 Votes After: (Ups 497 Downs 503)
2021/06/09 08:47:01.521817 Expected: (Ups 497 Downs 503)
2021/06/09 08:47:01.521825 Success!

As it turns out, our custom resolver is working as intended. Even when multiple people cast their vote simultaneously, we can rest assured that our backend vote totals will accurately reflect their votes — in real-time, no less! To me, this really solidified how incredibly powerful AppSync and DynamoDB can be and it makes me excited to find other ways to put them to use!


And so ends our journey with Habitr. I think at this point, I have covered all of the juicy details which made this project enjoyable and exciting to work on, and I hope they have left an equally sizeable impression on you. While I haven’t covered all aspects of the project, I leave it as an exercise for those curious, and you’ll find the complete source code on the Github page, including the full code for the tests described above.

Thanks for reading, and I’ll see you in the next one.