Serverless mobile with React Native and AWS Amplify

In this post I will go through the process of integrating an open-source React Native app with the Amazon Web Services (AWS) serverless backend infrastructure. I will be using the new AWS Amplify library developed by the AWS Mobile team.

React Native setup

brew install node brew install watchman npm install -g react-native-cli

Make sure you have installed Xcode to get access to the iOS simulator + Android Studio +/- Genymotion for an Android simulator, or alternatively connect your hardware device. For more detailed platform specific instructions check out the link below:

https://facebook.github.io/react-native/docs/getting-started.html

Project setup

The app we are about to clone is a Bars App. It’s a simple app that finds your location and tells you what venues are nearby using the Google Places API. Users can view details about each venues price rating, opening hours, reviews, website, location, and phone number. Users can also get directions to the venue and add each venue to a favourites list. The video capture below taken from an iOS simulator will give you a clearer idea about what we will be re-creating:

https://youtu.be/4I9HTbN8E6o

To get started clone the repo:

Change to the project folder:

cd BarsAppAmplify

Add dependencies:

npm install or yarn

Project structure

Folder organisation is one of personal or team preference. My structure looks like this for this app:

folder structure

features

Amazon

To get started with Amazon first sign up to the AWS Free Tier:

https://aws.amazon.com/free/

AWS Amplify CLI setup

The new AWS Amplify CLI tool was introduced only around August 2018. It supersedes the AWS Mobile CLI.

npm install -g @aws-amplify/cli

amplify configure

The configure command will direct you to create a new IAM user via the AWS console, when prompted enter the accessKeyId and secretAccessKey, store these in a safe place, you can also assign this user an AWS Profile Name:

amplify configure

amplify init (in the project folder)

amplify init

amplify add auth

amplify add auth

amplify add api

amplify add api

The base schema.graphql file looks like this:

type Bar @model {

id: ID!

title: String!

content: String!

price: Int

rating: Float

}

This app will have a many-to-many connection between type Bar and type User. Currently AWS Amplify does not yet support many-to-many connections, hence the @connection directive which is used for specifying relationships between @model object types cannot be used. Update the schema.graphql file to look as follows.

type Bar @model {

id: ID!

createdAt: String

updatedAt: String

name: String!

phone: String

location: String

lat: String

lng: String

url: AWSURL

addedBy: ID!

users(first: Int, after: String): [Bar]

}



type BarMember @model {

id: ID!

createdAt: String

updatedAt: String

userId: ID!

barId: ID!

}



type User @model {

id: ID!

createdAt: String

updatedAt: String

username: String!

bars(first: Int, after: String): [Bar]

}

Important Step

amplify push

This command will update your cloud resources and add an aws-exports.js file to your project root directory. In your App.js file make sure this file is imported from the correct location.

Other directives

Note: AWS Amplify has has the following directives that can be used with AppSync:

@model: Used for storing types in Amazon DynamoDB. @connection: Used to define different authorization strategies. @auth: Used for specifying relationships between @model object types. @searchable: Used for streaming the data of an @model object type to Amazon ElasticSearch Service.

AWS AppSync Schema

Go to the AWS Console and AWS AppSync under Services. Select the API that has been generated API for this app and go to the schema.

aws appsync schema

The schema that has been created needs some modification to allow for the many-to-many relationship between Bars and Users to work. Modify the schema as follows:

type Bar {

id: ID!

createdAt: String

updatedAt: String

name: String!

phone: String

location: String

lat: String

lng: String

url: AWSURL

website: AWSURL

addedBy: ID!

users(first: Int, after: String): BarUsersConnection

}



type BarMember {

id: ID

createdAt: String

updatedAt: String

userId: ID!

barId: ID!

}



type BarUsersConnection {

items: [User]

nextToken: String

}



input CreateBarInput {

id: ID!

name: String!

phone: String

location: String

lat: String

lng: String

url: AWSURL

website: AWSURL

addedBy: ID!

}



input CreateBarMemberInput {

userId: ID!

barId: ID!

}



input CreateUserInput {

id: ID!

username: String!

}



input DeleteBarInput {

id: ID

}



input DeleteBarMemberInput {

id: ID

}



input DeleteUserInput {

id: ID

}



type ModelBarConnection {

items: [Bar]

nextToken: String

}



input ModelBarFilterInput {

id: ModelIDFilterInput

createdAt: ModelStringFilterInput

name: ModelStringFilterInput

phone: ModelStringFilterInput

location: ModelStringFilterInput

lat: ModelStringFilterInput

lng: ModelStringFilterInput

url: ModelStringFilterInput

website: ModelStringFilterInput

addedBy: ModelIDFilterInput

and: [ModelBarFilterInput]

or: [ModelBarFilterInput]

not: ModelBarFilterInput

}



type ModelBarMemberConnection {

items: [BarMember]

nextToken: String

}



input ModelBarMemberFilterInput {

id: ModelIDFilterInput

createdAt: ModelStringFilterInput

userId: ModelIDFilterInput

barId: ModelIDFilterInput

and: [ModelBarMemberFilterInput]

or: [ModelBarMemberFilterInput]

not: ModelBarMemberFilterInput

}



input ModelBooleanFilterInput {

ne: Boolean

eq: Boolean

}



input ModelFloatFilterInput {

ne: Float

eq: Float

le: Float

lt: Float

ge: Float

gt: Float

contains: Float

notContains: Float

between: [Float]

}



input ModelIDFilterInput {

ne: ID

eq: ID

le: ID

lt: ID

ge: ID

gt: ID

contains: ID

notContains: ID

between: [ID]

beginsWith: ID

}



input ModelIntFilterInput {

ne: Int

eq: Int

le: Int

lt: Int

ge: Int

gt: Int

contains: Int

notContains: Int

between: [Int]

}



enum ModelSortDirection {

ASC

DESC

}



input ModelStringFilterInput {

ne: String

eq: String

le: String

lt: String

ge: String

gt: String

contains: String

notContains: String

between: [String]

beginsWith: String

}



type ModelUserConnection {

items: [User]

nextToken: String

}



input ModelUserFilterInput {

id: ModelIDFilterInput

createdAt: ModelStringFilterInput

username: ModelStringFilterInput

and: [ModelUserFilterInput]

or: [ModelUserFilterInput]

not: ModelUserFilterInput

}



type Mutation {

createBar(input: CreateBarInput!): Bar

updateBar(input: UpdateBarInput!): Bar

deleteBar(input: DeleteBarInput!): Bar

createBarMember(input: CreateBarMemberInput!): BarMember

updateBarMember(input: UpdateBarMemberInput!): BarMember

deleteBarMember(input: DeleteBarMemberInput!): BarMember

createUser(input: CreateUserInput!): User

updateUser(input: UpdateUserInput!): User

deleteUser(input: DeleteUserInput!): User

}



type Query {

getBar(id: ID!): Bar

listBars(filter: ModelBarFilterInput, limit: Int, nextToken: String): ModelBarConnection

getBarMember(userId: ID!, barId: ID!): BarMember

listBarMembers(filter: ModelBarMemberFilterInput, limit: Int, nextToken: String): ModelBarMemberConnection

getUser(id: ID!): User

listUsers(filter: ModelUserFilterInput, limit: Int, nextToken: String): ModelUserConnection

}



type Subscription {

onCreateBar: Bar

@aws_subscribe(mutations: ["createBar"])

onUpdateBar: Bar

@aws_subscribe(mutations: ["updateBar"])

onDeleteBar: Bar

@aws_subscribe(mutations: ["deleteBar"])

onCreateBarMember: BarMember

@aws_subscribe(mutations: ["createBarMember"])

onUpdateBarMember: BarMember

@aws_subscribe(mutations: ["updateBarMember"])

onDeleteBarMember: BarMember

@aws_subscribe(mutations: ["deleteBarMember"])

onCreateUser: User

@aws_subscribe(mutations: ["createUser"])

onUpdateUser: User

@aws_subscribe(mutations: ["updateUser"])

onDeleteUser: User

@aws_subscribe(mutations: ["deleteUser"])

}



input UpdateBarInput {

id: ID!

name: String

phone: String

location: String

lat: String

lng: String

url: AWSURL

website: AWSURL

}



input UpdateBarMemberInput {

id: ID!

userId: ID

barId: ID

}



input UpdateUserInput {

id: ID!

username: String

}



type User {

id: ID!

createdAt: String

updatedAt: String

username: String!

bars(first: Int, after: String): UserBarsConnection

}



type UserBarsConnection {

items: [Bar]

nextToken: String

}

AWS AppSync Resolvers

Again in the AppSync schema page there is a resolvers section on the right side. Go through an update the following resolvers:

Resolver for Bar.users: BarMemberTable

## Request {

"version" : "2017-02-28",

"operation" : "Query",

"query" : {

"expression": "barId = :id",

"expressionValues" : {

":id" : {

"S" : "${ctx.source.id}"

}

}

},

"index": "barId-index",

"limit": $util.defaultIfNull(${ctx.args.first}, 20),

"nextToken": $util.toJson($util.defaultIfNullOrBlank($ctx.args.after, null))

} ## Response {

"items": $util.toJson($ctx.result.items),

"nextToken": $util.toJson($util.defaultIfNullOrBlank($context.result.nextToken, null))

}

Resolver for BarUsersConnection.items: UserTable

## Request

## Please remember to replace the hyphenated table name below with the one that was created for your app #set($ids = [])

#foreach($user in ${ctx.source.items})

#set($map = {})

$util.qr($map.put("id", $util.dynamodb.toString($user.get("userId"))))

$util.qr($ids.add($map))

#end {

"version" : "2018-05-29",

"operation" : "BatchGetItem",

"tables" : {

"User-rndmxxybyjfv5lvzou3767zbte": {

"keys": $util.toJson($ids),

"consistentRead": true

}

}

} ## Response

## Please remember to replace the hyphenated table name below with the one that was created for your app #if( ! ${ctx.result.data} )

$util.toJson([])

#else

$util.toJson($ctx.result.data.User-uq7n63nywrc4tku2tzgx4mx75u)

#end

Resolver for User.bars: BarMemberTable

## Request {

"version" : "2017-02-28",

"operation" : "Query",

"query" : {

"expression": "userId = :id",

"expressionValues" : {

":id" : {

"S" : "${ctx.source.id}"

}

}

},

"index": "userId-index",

"limit": $util.defaultIfNull(${ctx.args.first}, 20),

"nextToken": $util.toJson($util.defaultIfNullOrBlank($ctx.args.after, null))

} ## Response {

"items": $util.toJson($ctx.result.items),

"nextToken": $util.toJson($util.defaultIfNullOrBlank($context.result.nextToken, null))

}

Resolver for UserBarsConnection.items: BarTable

## Request

## Please remember to replace the hyphenated table name below with the one that was created for your app

#set($ids = [])

#foreach($bar in ${ctx.source.items})

#set($map = {})

$util.qr($map.put("id", $util.dynamodb.toString($bar.get("barId"))))

$util.qr($ids.add($map))

#end {

"version" : "2018-05-29",

"operation" : "BatchGetItem",

"tables" : {

"Bar-rndmxxybyjfv5lvzou3767zbte": {

"keys": $util.toJson($ids),

"consistentRead": true

}

}

} ## Response

## Please remember to replace the hyphenated table name below with the one that was created for your app #if( ! ${ctx.result.data} )

$util.toJson([])

#else

$util.toJson($ctx.result.data.Bar-uq7n63nywrc4tku2tzgx4mx75u)

#end

Resolver for Query.getBarMember: BarMember

## Request {

"version" : "2017-02-28",

"operation" : "Query",

"index" : "userId-index",

"query" : {

## Provide a query expression. **

"expression": "userId = :userId",

"expressionValues" : {

":userId" : $util.dynamodb.toDynamoDBJson($ctx.args.userId)

}

},

"filter" : {

"expression" : "barId = :barId",

"expressionValues" : {

":barId" : $util.dynamodb.toDynamoDBJson($ctx.args.barId)

}

},

} ## Response #if($ctx.result.items.size() > 0)

$util.toJson($ctx.result.items[0])

#else

null

#end

Resolver for Mutation.createBar: BarTable

Update the key only, leave the rest as it is.

"key": {

"id": $util.dynamodb.toDynamoDBJson($ctx.args.input.id),

},

Resolver for Mutation.createUser: UserTable

Update the key only, leave the rest as it is.

"key": {

"id": $util.dynamodb.toDynamoDBJson($ctx.args.input.id),

},

DynamoDB

From the AWS AppSync console select Data Sources and find the BarMember table. Create 2 indexes for this table, barId-index, and userId-index, with no sort keys and default settings. See example below:

dynamodb

Google Places API

Sign up to Google Places and get an API key.

google-places

Mapbox API

Sign up to Mapbox and get an API key.

mapbox

Add API keys

This project uses react-native-config to store API keys in an environment file. Creata a .env file in the project root directory, then add your Google Places API and Mapbox API keys here.

GOOGLE_PLACES_API_KEY=YOUR_KEY_GOES_HERE

MAPBOX_ACCESS_TOKEN=YOUR_KEY_GOES_HERE

Launch

Although this app will work on a simulator, connecting a hardware device for this will allow you to more easily access the geolocation, maps, directions, and features. There are some issues on android that have not been ironed out yet, so please refer to the Github repo.

Run on ios device

react-native run-ios — device "iPhone X"

Run on android device:

adb devices

react-native run-android — deviceId "myDeviceId"

Run on ios:

react-native run-ios

Run on android:

react-native run-android

If you are getting build errors try the following:

delete app from simulator or device and rebuild

erase all content and settings from simulator and rebuild

clean build folder in xcode and rebuild

Flow

I am in the process of migrating from PropTypes to using Flow. So you will see parts of the app using one or the other. To check for Flow errors:

yarn run flow start

yarn run flow status

Testing with Jest and Enzyme

There are some really basic snaphsot tests that have been created for some parts of the app using Jest and Enzyme. To check the current tests are working:

yarn run test

Additional information

React Apollo

In this app I have chosen to primarily use React Apollo’s graphql higher order component to connect queries, mutations, and subscriptions to the app. With React Apollo 2.1 you can use the new Query, Mutation, and Subscription components instead.

AWS Amplify API

In the Auth section of this app I have used AWS Amplify’s API and graphqlOperation helper. This API is effectively an alternative GraphQL client for working with queries, mutations, and subscriptions. It is great to use when you do not need offline support and the more advanced features of React Apollo.

AWS Appsync

With AWS AppSync you can combine React Apollo’s graphql higher order component with the graphqlMutation (offline support) and buildSubscription helpers. These take away some of the boilerplate code normally required to implement mutations and subscriptions. I have used the buildSubscription helper in this app.

Me:

I have been learning React Native for the last 12 months. Please check out my github for other open-source apps that I have been working on.

Bars App: https://github.com/pjay79/BarsAppAmplify

Github: https://www.github.com/pjay79

Twitter: @praveenj1979