Building a Social Network with Amplify Flutter (Part 1)

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

When I first joined the Amplify Flutter team, I wanted to immerse myself in the framework and really push it to its limits. I had a bit of experience building social networks with Firebase, and I knew this would be a good way to touch on all the categories offered by Amplify. Moreover, I knew there would be good opportunities to use the real-time GraphQL subscriptions which I was excited to give a whirl.

The idea for this network would be pretty simple: communal habit tracking. Typical methods of habit tracking are focused on the individual and rely on self-documentation of actions throughout the day. Instead, what if there was a way to share the ways we’ve achieved reaching certain goals in our lives and rank the patterns which got us there? This was the idea behind Habitr.

Sign In

Sign In Screen

Home Screen

Home Screen

Home Drawer

Home Drawer

Sign In

Profile Screen

Users would have a feed of habits, split into categories and ranked by their peers, each with an individual discussion thread and voting mechanism.

Amplify would be employed in the following way:

We’ll go through each of the categories to see how it fits into the app and the lessons I learned building out the front end.

Another popular choice for storing and manipulating data is the DataStore category. However, due to the custom nature of my GraphQL API, it didn’t end up being a viable option for me.

Auth

The Auth category, backed by AWS Cognito, provides a host of different options for authenticating and authorizing users. Briefly, you can create a user pool and/or identity pool to track user accounts and provide authenticated users with scoped-down AWS IAM credentials, respectively. For the user pool, users can sign up by email, username, or phone number, or through a federated identity provider like Google, Facebook, or Apple. MFA can be optionally enabled for some or all users, and Lambda functions can be triggered on certain auth events like pre- and post-signup. Altogether there are many options, which can be a bit dizzying at first, that ultimately provide a lot of choice and flexibility when designing your authentication system.

For Habitr, I chose to have the default user pool/identity pool combo with federation, which would allow users to sign up with a username/password combination or through a federated provider. The accompanying identity pool would provide them access to protected AWS resources, like an S3 bucket path for uploading photos, by issuing temporary, limited IAM credentials.

API

Amplify’s GraphQL API is a managed AppSync service that allows connecting to a DynamoDB backend through a GraphQL API. Typically, when working with just AppSync, all queries, mutations, subscriptions, and input types must be written by hand. Amplify simplifies this process by transforming simple, declarative GraphQL APIs you write. Authorization, indexing, and search functionality are all controlled through the use of directives in the GraphQL schema. The primary directive used, though, is the @model directive which will automatically generate queries, mutations, and subscriptions for a type.

The middle layer consists of “resolvers” which map each query, mutation, and subscription to a set of DynamoDB operations. Typically when working with Amplify, you will not need to interact with these - they are generated as well from the schema you write. However, as I discovered later, you can write “custom resolvers” when additional logic or functionality is needed.

Function

Amplify supports deploying Lambda functions to a REST API interface via the Functions category. Habitr uses three such Lambdas to manage users and provide extended functionality to the other Amplify categories.

Case Study: User Sign-Up / Sign-In

To see the Auth, API, and Function categories in action together, we look at the user sign-up/sign-in flow.

User

As mentioned, Amplify supports several directives out-of-the-box which provide functionality such as full-text search, indexing, and authorization through a declarative means.

Check out the Amplify docs to learn more about each directive.

@model

The model directive, as mentioned before, transforms a simple GraphQL type into a set of standard queries, mutations, and subscriptions. The Todo type above, for example, generates the following operations automatically, and all supporting input types:

type Query {
    getTodo(id: ID!): Todo
    listTodos(filter: ModelTodoFilterInput, limit: Int, nextToken: String): ModelTodoConnection
}

type Mutation {
    createTodo(input: CreateTodoInput!): Todo
    updateTodo(input: UpdateTodoInput!): Todo
    deleteTodo(input: DeleteTodoInput!): Todo
}

type Subscription {
    onCreateTodo: Todo @aws_subscribe(mutations: ["createTodo"])
    onUpdateTodo: Todo @aws_subscribe(mutations: ["updateTodo"])
    onDeleteTodo: Todo @aws_subscribe(mutations: ["deleteTodo"])
}

Without much work at all, you’re able to generate powerful GraphQL schemas which provide a lot of functionality like filtering, pagination, and listening to mutations. Directives like @key, @connection, and @searchable can be used to further shape a GraphQL schema to your needs.

@auth

Authorization rules can be scoped to the model and/or to individual fields, with options such as owner authorization, public/private, user groups, or any combination of these. Coupled with Amplify’s deny-by-default policy in GraphQL, these authorization rules can stack to provide very fine-grained detail over the types of operations allowed for different groups of people. In Habitr, for example, the User model is protected by 4 different @auth rules, which combine to express the following:

Habitr’s default authorization mode is Cognito User Pools, with an additional API key mode, used by backend Functions.

{ allow: groups, groups: ["admin"] }
{ allow: public }
{ allow: private, operations: [read] }
{ allow: owner, ownerField: "username", operations: [update] }

Notice they cannot create or delete their own models - that is left to the admins/server so that necessary clean-up work can be performed.

Below is the full, annotated User type from the GraphQL API.

Check out the Amplify docs to learn more about each directive.

type User
    # Creates a model type with all generated queries and mutations
    # but no subscriptions. This is so that we can create a custom
    # subscription which listens to changes for only one user instead 
    # of for all users (the default).
    @model(subscriptions: null)

    # Set the primary key/index to "username"
    @key(fields: ["username"])

    # 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 Cognito users
        { allow: private, operations: [read] }

        # Read and update access for owners (only admin + API key can create)
        { allow: owner, ownerField: "username", operations: [update] }
    ])

    # Enable full-text search on all fields
    @searchable 
{
    # The user's Cognito username.
    username: String!

    # The user's preferred display name, if different from their
    # auto-generated Cognito username, e.g. with social sign-in.
    displayUsername: String

    # The user's first and last name.
    name: String

    # A reference to the user's profile image, uploaded to S3.
    avatar: S3Object

    # A list of comments the user has posted.
    comments: [Comment!]
        # Creates a has-many relationship to the user's comments
        @connection(keyName: "commentsByUser", fields: ["username"])

    # A list of habits the user has created.
    habits: [Habit!]
        # Creates a has-many relationship to the user's posted habits
        @connection(keyName: "habitsByUser", fields: ["username"])

    # The habits this user has upvoted, used to keep voting fair.
    upvotedHabits: [ID!]
        # Restrict viewing to owners and updates to admins
        @auth(rules: [
            { allow: groups, groups: ["admin"] }
            { allow: public }
            { allow: owner, ownerField: "username", operations: [read] }
        ])

    # The habits this user has downvoted, used to keep voting fair.
    downvotedHabits: [ID!]
        # Restrict viewing to owners and updates to admins
        @auth(rules: [
            { allow: groups, groups: ["admin"] }
            { allow: public }
            { allow: owner, ownerField: "username", operations: [read] }
        ])
}

type Subscription {
    # Custom subscription for listening to a single user's updates.
    # Authorization is deferred to the model type, so the user returned
    # from this subscription has the same protections as one returned
    # from a query or mutation.
    #
    # The auto-generated "onUpdate" subscription for all users:
    # type Subscription {
    #     onUpdateUser: User
    #         @aws_subscribe(mutations: ["updateUser"])
    #         @aws_api_key
    #         @aws_cognito_user_pools
    # }
    subscribeToUser(username: String!): User
        @aws_subscribe(mutations: ["updateUser"])
        @aws_api_key
        @aws_cognito_user_pools
}

Note: Custom Subscriptions

This use case is outside of the Amplify GraphQL transformer, which you can tell by the use of aws-prefixed directives. When doing custom subscriptions, it can be helpful to fall back to the AppSync docs, which have great explanations for the underlying schema behaviors, such as those for subscriptions.


Sign Up

When a user signs up, we want to start building a profile for them, gathering their name, preferred username, and profile picture. But first, we need a way to create that slot for them in our database. Luckily with Cognito triggers, we can do exactly that.

By creating a Function and setting its trigger to post-confirmation, every time a user is created and confirmed in Cognito, our Lambda will fire with the user’s details. We use this information to perform a createUser mutation on our AppSync backend.

Sign In

During sign-in, we use another Function (habitrUserExists) to determine whether a chosen username is available, before the user submits their sign up form, by performing a searchUsers query (generated via the @searchable directive):

query SearchUsers($username: String!) {
    searchUsers(filter: { 
        or: [
            { username: { eq: $username } }
            { displayUsername: { eq: $username } }
        ]
    }) {
        total
    }
}

In the next part, we’ll look at the API for habits and comments, including how to create and test a custom mutation using DynamoDB and the Velocity templating language.