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 Screen
Home Screen
Home Drawer
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:
- Auth: The auth category would be used to create user accounts and manage access to the backend resources.
- Storage: Users could upload profile pictures for their accounts. Auth controls would allow users to modify their own content while being limited to viewing the photos of other users.
- API (GraphQL): The meat of the app, with all the user data, habits, and a custom mutation for live voting.
- Function/API (REST): Helper functions that may or may not need authentication (e.g. checking username availability).
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.
- By default, all access is denied, meaning users not signed into Cognito or without an API key do not have any access.
{ allow: groups, groups: ["admin"] }
- Logged-in users who are part of the “admin” User Pool Group have full access (by default, all operations are permitted if unspecified).
{ allow: public }
- API Key holders (Lambda functions) have full access. This allows the server-side Functions to perform admin tasks such as creating and deleting users.
{ allow: private, operations: [read] }
- Any logged-in user is permitted to read the profile of another user. This could potentially be too much access if, for example, there were sensitive fields in the model. But Amplify allows you to further restrict fields by applying additional auth rules over the model’s.
{ allow: owner, ownerField: "username", operations: [update] }
- The “owner” of the model, defined by the
username
field has theread
permission from the last rule plus theupdate
permission. This owner field is pulled from the JWT token issued by Cognito when the user interacts with the API and compared against theusername
field of the model to determine their privilege.
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.