In this guide I am going to show how you can apply advanced GEO search in React Native in combination with AWS Amplify. More and more apps are using the GPS of a device or location of a user to show relevant content. This tutorial and in specific the AWS amplify implementation is even applicable for React or any other framework.
I will show how you can achieve GEO Polygon Search within your React Native App. With Polygon you can find a certain location in an area of coordinates. The other GEO option which I will show is GEO line Search. This is comparing two coordinates within a line distance.
Scenario 1
We are going to create an running event. You can only participate if you are living in that area where the event is taking place, otherwise you will not be able to find the event. With this I will cover Polygon search.
Scenario 2
In this scenario you can find other runners that live in a certain distance from you. You can connect to them to invite them to train together. You will enter the coordinates where you are living, set a distance and find all runners living in that distance.
What will not be covered?
You can use the GPS of your phone to show only the runners in a distance from your active GPS position, but that will not be covered in the tutorial.
Maybe you want to expose the data to each guest user or maybe only to logged in users. You can use AWS Amplify Auth to set up your corresponding authentication, but this will not be covered here. I will use Cognito to authenicate the API. If you want to know how to set up authenication in your app please follow this extensive guide from @dabit3:
The Complete React Native Guide to User Authentication with the Amplify Framework: Click here
With approval from @dabit3 I also borrowed some steps about how to initiate AWS Amplify and React Native. Thx!
Getting Started
Set up React Native
First, we'll create the React Native application we'll be working with.
If using Expo
$ npx expo init geosearch
> Choose a template: blank
$ cd geosearch
$ npm install aws-amplify aws-amplify-react-native
If using React Native CLI
$ npx react-native init geosearch
$ cd geosearch
$ npm install aws-amplify aws-amplify-react-native
Set up AWS Amplify
We first need to have the AWS Amplify CLI installed. The Amplify CLI is a command line tool that allows you to create & deploy various AWS services.
To install the CLI, we'll run the following command:
$ npm install -g @aws-amplify/cli
Next, we'll configure the CLI with a user from our AWS account:
$ amplify configure
For a video walkthrough of the process of configuring the CLI, click
Now we can now initialize a new Amplify project from within the root of our React Native application:
$ amplify init
Here we'll be guided through a series of steps:
- Enter a name for the project: amplifygeo (or your preferred project name)
- Enter a name for the environment: local (or your preferred environment name)
- Choose your default editor: Visual Studio Code (or your text editor)
- Choose the type of app that you're building: javascript
- What javascript framework are you using: react-native
- Source Directory Path: /
- Distribution Directory Path: build
- Build Command: npm run-script build
- Start Command: npm run-script start
- Do you want to use an AWS profile? Y
- Please choose the profile you want to use: YOUR_USER_PROFILE
- Now, our Amplify project has been created & we can move on to the next steps.
Add Graphql to your project
Your React Native App is up and running and AWS Amplify is configured. Amplify comes with different services which you can use to enrich your app. We are focussing mostly on the API service. So let’s add an API.
Amplify add api
These steps will take place:
- Select Graphql
- Enter a name for the API: geoAPI (your preferred API name)
- Select an authorisation type for the API: Amazon Cognito User Pool ( Because we are using this app with authenticated users only, but you can choose other options)
- Select at do you want to use the default authentication and security configuration: Default configuration
- How do you want users to be able to sign in? Username (with this also the AWS Amplify Auth module will be enabled)
- Do you want to configure advanced settings? *No, I am done. *
- Do you have an annotated GraphQL schema? n
- Do you want a guided schema creation?: n
- Provide a custom type name: event
You API and your schema definition have been created now. You can find it in you project directory:
Amplify > backend > api > name of your api
Open your schema.graphql and remove all the code and replace it with this code:
type Event @model @searchable {
date: String!
description: String!
maxParticipants: Int!
polygon: Location!
}
type Runner @model @searchable {
name: String!
age: Int!
coordinates: Coordinates!
}
type Coordinates {
lat: Float!
lon: Float!
}
type Location {
type: String!
coordinates: [[[Float]]]!
}
type Query {
nearbyEvent(location: LocationEventInput!): EventConnection
searchByDistance(location: LocationRunnerInput!, km: Int): RunnerConnection
}
input LocationEventInput {
type: String!
coordinates: [Float]!
}
input LocationRunnerInput {
lat: Float!
lon: Float!
}
type EventConnection {
items: [Event]
total: Int
nextToken: String
}
type RunnerConnection {
items: [Runner]
total: Int
nextToken: String
}
The @model will create a DynamoDB for you and the @searchable will create an ElasticSearch cluster for you. Each time you add, update or delete records in DynamoDB, these changes will be pushed to ElasticSearch. There are more directives possible, for the full set look at the AWS Amplify docs. With this schema you also add two queries:
1) to search events with coordinates that are positioned in the event area. With a input of type and coordinates
2) a query to search other runners by distance from your geo point. An input of lat, lon and kilometer.
Save your schema.graphql and close the file.
Now in you API directory open the resolvers directory and create these 4 files:
Query.nearbyEvent.req.vtl
## Query.nearbyEvent.req.vtl
## Objects of type Event will be stored in the /event index
#set( $indexPath = "/event/doc/_search" )
{
"version": "2017-02-28",
"operation": "GET",
"path": "$indexPath.toLowerCase()",
"params": {
"body": {
"query": {
"geo_shape": {
"polygon": {
"relation": "intersects",
"shape": {
"type": "${ctx.args.location.type}",
"coordinates": $ctx.args.location.coordinates
}
}
}
}
}
}
}
Query.nearbyEvent.res.vtl
## Query.nearbyEvent.res.vtl
#set( $items = [] )
#foreach( $entry in $context.result.hits.hits )
#if( !$foreach.hasNext )
#set( $nextToken = "$entry.sort.get(0)" )
#end
$util.qr($items.add($entry.get("_source")))
#end
$util.toJson({
"items": $items,
"total": $ctx.result.hits.total,
"nextToken": $nextToken
})
Query.searchByDistance.req.vtl
## Query.searchByDistance.req.vtl
## Objects of type Runner will be stored in the /runner index
#set( $indexPath = "/runner/doc/_search" )
#set( $distance = $util.defaultIfNull($ctx.args.km, 200) )
{
"version": "2017-02-28",
"operation": "GET",
"path": "$indexPath.toLowerCase()",
"params": {
"body": {
"query": {
"bool" : {
"filter" : {
"geo_distance" : {
"distance" : "${distance}km",
"coordinates" : $util.toJson($ctx.args.location)
},
}
}
}
}
}
}
Query.searchByDistance.res.vtl
## Query.searchByDistance.res.vtl
#set( $items = [] )
#foreach( $entry in $context.result.hits.hits )
#if( !$foreach.hasNext )
#set( $nextToken = "$entry.sort.get(0)" )
#end
$util.qr($items.add($entry.get("_source")))
#end
$util.toJson({
"items": $items,
"total": $ctx.result.hits.total,
"nextToken": $nextToken
})
You have now successfully created your resolvers which will act as a glue between GraphQL and ElasticSearch.
You need to make your application aware of these resolvers and provide the S3 path where you will deploy them. Therefor you go to your API directory and open the stacks directory. Open the file *CustomerResources.json. Find these lines:
"Resources": {
"EmptyResource": {
"Type": "Custom::EmptyResource",
"Condition": "AlwaysFalse"
}
Add a comma and paste this code:
"QuerynearbyEvent": {
"Type": "AWS::AppSync::Resolver",
"Properties": {
"ApiId": {
"Ref": "AppSyncApiId"
},
"DataSourceName": "ElasticSearchDomain",
"TypeName": "Query",
"FieldName": "nearbyEvent",
"RequestMappingTemplateS3Location": {
"Fn::Sub": [
"s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/Query.nearbyEvent.req.vtl",
{
"S3DeploymentBucket": {
"Ref": "S3DeploymentBucket"
},
"S3DeploymentRootKey": {
"Ref": "S3DeploymentRootKey"
}
}
]
},
"ResponseMappingTemplateS3Location": {
"Fn::Sub": [
"s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/Query.nearbyEvent.res.vtl",
{
"S3DeploymentBucket": {
"Ref": "S3DeploymentBucket"
},
"S3DeploymentRootKey": {
"Ref": "S3DeploymentRootKey"
}
}
]
}
}
},
"searchByDistance": {
"Type": "AWS::AppSync::Resolver",
"Properties": {
"ApiId": {
"Ref": "AppSyncApiId"
},
"DataSourceName": "ElasticSearchDomain",
"TypeName": "Query",
"FieldName": "searchByDistance",
"RequestMappingTemplateS3Location": {
"Fn::Sub": [
"s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/Query.searchByDistance.req.vtl",
{
"S3DeploymentBucket": {
"Ref": "S3DeploymentBucket"
},
"S3DeploymentRootKey": {
"Ref": "S3DeploymentRootKey"
}
}
]
},
"ResponseMappingTemplateS3Location": {
"Fn::Sub": [
"s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/Query.searchByDistance.res.vtl",
{
"S3DeploymentBucket": {
"Ref": "S3DeploymentBucket"
},
"S3DeploymentRootKey": {
"Ref": "S3DeploymentRootKey"
}
}
]
}
}
}
Wow! You have created your whole backend as code. It’s now time to deploy. Go to the root of your project directory and deploy with:
Amplify push
- Do you want to generate code for your newly created GraphQL API (Y/n): Y
- Choose the code generation language target: javascript
- Enter the file name pattern of graphql queries, mutations and subscriptions: enter (the default path)
- Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions: Y
- Enter maximum statement depth [increase from default if your schema is deeply nested]: enter (the default value of 2)
Configure ElasticSearch
After you pushed your backend to AWS you need to do some additional task in the console. You need to map the polygon field of your Event type in ElasticSearch to a GEO shape. I have not figured out a way to do this as code, so tips are welcome. This means that each time you deploy your backend to a new environment you need to do this action manually once.
- Log into the AWS console
- Search for ElasticSearch Service
- Click on the domain name of your cluster
- Copy the Domain ARN
- Click on the tab Modify access policy and add this code:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "*"
},
"Action": "es:*",
"Resource": "<YOUR DOMAIN ARN>/*"
}
]
}
This will open Kibana access to the internet without a password. This means everybody van query and mutate your data. Make sure you change this later if you don't want to end up in the situation like Adobe did:
Adobe left 7.5 million Creative Cloud user records exposed online click here to read the article
- Go back to the dashboard of the ElasticSearch Service
- Click again on the domain
- Click on the Kibana link
- Click on Dev Tools and paste this code below in the console by replacing the other content:
PUT /event
{
"mappings": {
"doc": {
"properties": {
"polygon": {
"type": "geo_shape"
}
}
}
}
}
PUT /runner
{
"mappings": {
"doc": {
"properties": {
"coordinates": {
"type": "geo_point"
}
}
}
}
}
You have now created the mappings! As soon as you create a running event via your app or Appsync console it will be added to dynamoDB, streamed to ElasticSearch and the polygon field will be mapped to a GEO shape. Good Job!
Add some data via AppSync
Let’s add some data which you can use in your app. Go to the AppSync service in the console.
- Go to AWS AppSync via the console.
- Open your project
- Click on Queries
- Log in with a Cognito user by clicking on the button 'Login via Cognito User Pools' (You can create a user via Cognito in the console or via your App)
- Add the following code an run the code:
mutation PutEvent {
createEvent( input: {
date: "12-12-2019",
description: "Run event for kids",
maxParticipants: 140,
polygon:{
type: "Polygon",
coordinates:[[[52.357156, 4.888768],[52.354334, 4.888238],[52.353505, 4.900297],[52.356831, 4.902110],[52.357156, 4.888768]]]
}
}
){
date
description
maxParticipants
polygon { type, coordinates }
}
}
now make another event in the same area:
mutation PutEvent {
createEvent( input: {
date: "08-12-2019",
description: "The Amsterdam running Event of the year",
maxParticipants: 50,
polygon:{
type: "Polygon",
coordinates:[[[52.357156, 4.888768],[52.354334, 4.888238],[52.353505, 4.900297],[52.356831, 4.902110],[52.357156, 4.888768]]]
}
}
){
date
description
maxParticipants
polygon { type, coordinates }
}
}
Let's create some awesome Runners:
mutation PutRunners {
createRunner(input:{
name: "Ramon"
age: 34
coordinates:{
lat:52.355986,
lon:4.892747
}
}){
name
age
coordinates {lat,lon}
}
}
And another one:
mutation PutRunners {
createRunner(input:{
name: "Niels"
age: 40
coordinates:{
lat:52.370216,
lon:4.895168
}
}){
name
age
coordinates {lat,lon}
}
}
You will find now events and runners in DynamoDB as well in ElasticSearch. Your GraphQL, DynamoDB and ElasticSearch are working good!
Let's see the data in action
We are going to query the data via the the AWS Appsync query console.
Let's first query with the coordinates of Ramon's location:
query GetEventsNearBY {
nearbyEvent(location:{
type: "point"
coordinates:[52.355986,4.892747]
})
{
items {
date
description
maxParticipants
}
}
}
Cool, you see the two events in the area where Ramon is living.
Now let's change the coordinates and let's see if Niels will not see these events, because he is living outside the area:
query GetEventsNearBY {
nearbyEvent(location:{
type: "point"
coordinates:[52.370216,4.895168]
})
{
items {
date
description
maxParticipants
}
}
}
And it's working, there are no results
Now let's test scenario 2. Where we want to find runner is a certain distance from my location (coordinates):
query GetRunnersByDistance {
searchByDistance(location:{lat:52.355986, lon: 4.892747}, km: 50)
{
items {
name
age
}
}
}
You will get two results.
{
"data": {
"searchByDistance": {
"items": [
{
"name": "Niels",
"age": 40
},
{
"name": "Ramon",
"age": 34
}
]
}
}
}
Now let's make the distance smaller to 1 km:
query GetRunnersByDistance {
searchByDistance(location:{lat:52.355986, lon: 4.892747}, km: 1)
{
items {
name
age
}
}
}
and the expected result is that you will only see Ramon:
{
"data": {
"searchByDistance": {
"items": [
{
"name": "Ramon",
"age": 34
}
]
}
}
}
You whole backend is up and running, tested and you are now ready to built your front end application. This can be any framework like: IOS, Android, React Native, VueJS, React, Angular or Ionic.
Build the React Native app
We are going to build a very simple react native application, so that you at least can see it working in your app. Be prepared: this will not be a great user experienced design.
Go to the root of your project and open App.js and replace it with this code:
import React from "react";
import { withAuthenticator } from "aws-amplify-react-native";
import Amplify from "aws-amplify";
// Get the aws resources configuration parameters
import awsconfig from "./aws-exports"; // if you are using Amplify CLI
import Main from "./src/Main";
Amplify.configure(awsconfig);
class App extends React.Component {
render() {
return <Main />;
}
}
export default withAuthenticator(App);
This will import everything you need an wraps your app with a HOC withAuthenticator. This creates login and signup functionality for your app.
Now create a file in the src folder with this name: Main.js and paste the following code:
import React from "react";
import { Text, View, FlatList, TextInput } from "react-native";
import * as queries from "./graphql/queries.js";
import { API, graphqlOperation } from "aws-amplify";
class Main extends React.Component {
state = { events: [], runners: [] };
async componentDidMount() {
await this.loadNearByEvents();
}
async loadNearByEvents() {
const input = {
type: "point",
coordinates: [52.355986, 4.892747] // normally you set these coordinates dynamically from a profile page or active GPS location.
};
const result = await API.graphql(
graphqlOperation(queries.nearbyEvent, { location: input })
)
.then(result => {
return result.data.nearbyEvent.items;
})
.catch(err => console.log(err));
this.setState({
events: result
});
}
async loadRunnersByDistance(kmInput) {
// normally you set these coordinates dynamically from a profile page or active GPS location.
const input = {
lat: 52.355986,
lon: 4.892747
};
const result = await API.graphql(
graphqlOperation(queries.searchByDistance, {
location: input,
km: kmInput
})
)
.then(result => {
return result.data.searchByDistance.items;
})
.catch(err => console.log(err));
this.setState({
runners: result
});
}
render() {
return (
<View style={{ marginTop: 80, marginLeft: 10, marginRight: 10 }}>
<Text style={{ fontSize: 20, marginBottom: 5 }}>Events</Text>
<FlatList
data={this.state.events}
renderItem={({ item }) => (
<View style={{ marginBottom: 20 }}>
<Text>Date: {item.date}</Text>
<Text>Description: {item.description}</Text>
<Text>Max Participants: {item.maxParticipants}</Text>
</View>
)}
/>
<Text style={{ fontSize: 20, marginBottom: 5 }}>
Runners in the area
</Text>
<Text>Distance (KM)</Text>
<TextInput
style={{
borderRadius: 4,
borderWidth: 0.5,
borderColor: "#d6d7da",
width: 200,
padding: 10
}}
onChangeText={text => this.loadRunnersByDistance(text)}
/>
<FlatList
data={this.state.runners}
renderItem={({ item }) => (
<View style={{ marginBottom: 20 }}>
<Text>Name: {item.name}</Text>
<Text>Age: {item.age}</Text>
</View>
)}
/>
</View>
);
}
}
export default Main;
You app is ready and you can start it from your root project with:
expo start
When you change to distance from 1 to 2 you will see it shows only the runners in that distance
See github for the actual code: https://github.com/rpostulart/geoSearch
Conclusion
In this guide you have seen how easily it is to set up a backend and front end for your App. You have now the skills to play around with GEO Searches while we discovered point distance and polygon, but there is more out there. You added static coordinates, so you can also continue to make them more dynamic.
I really love AWS Amplify. It gives you the power as a developer to fully focus on delivering business value and innovation to your customers and not spending time on configuration.
In this guide we are using AWS ElasticSearch Service. Be aware that this is an expensive service. I really hope that the AWS Amplify, AWS DynamoDB or AWS ElasticSearch team comes up with a less expensive solution like for example serverless ElasticSearch.
Let me know what you think about this guide.
Top comments (10)
The best way to add the ES mapping is to use a cloudformation custom resource lambda, you can write an inline lambda function that makes the right API calls when the template is first deployed, if you make the ES domain a dependency of the custom resource Lambda then it will always run after the ES domain has been setup.
Ok nice, I was not aware of that. Do you have some more info / referencence documentation about this?
there are some gists floating around to adapt, most look like this block of JSON for the cloudformation custom resources file. You likely want to lock down the permission a bit more.
Thx, will look into it and update this post accordingly
Hey rpostulart, love this detailed article. Thanks for putting it together. Wondering if you might have a moment to try out our new official Amplify Geo category? It's currently in developer preview. There are some things you did here which become much easier.
docs.amplify.aws/lib/geo/getting-s...
Yes I have. It is a great replacement instead of using Elasticsearch.
I am having a discussion with one of your team member about how to have a realtime api response instead that it is streamed to eventbridge and then you need to put a lambda in between to send it to appsync (subscription)
Do you have a good example of how to push data to the backend from React when using something like a nested Coordinate object? I struggle with using the normal setFormData pattern when custom objects are in play.
Great article and super examples, thank you! Something that made me think is why the two dimensional array of the polygon is done with 3dim array in the schema def:
coordinates: [[[Float]]]!
Also the react counterpart is a 3dim array?
Any thoughts on that?
Thanks, Is there a way to create a subscription based on this query? Lets say I want to get updated coordinates of the runners filtered by distance? I knos that subscriptions are based on mutations, how can I do that if posible?
Nice! I would love to know the way to create events from the same ReactNative app, different screen?