Building my app foundation: Ionic + React, Amplify, Appsync, Lambda resolver

In this mega post, I will create an Ionic app that will initiate a query from a mobile front end, issue requests over the internet to an AWS GraphQL API, and via AppSync, invoke a Lambda, to process and return dummy data.

Building my app foundation: Ionic + React, Amplify, Appsync, Lambda resolver

In this mega post, I will create an Ionic app that will initiate a query from a mobile front end, issue requests over the internet to an AWS GraphQL API, and via AppSync, invoke a Lambda, to process and return dummy data.

I will send a GET Query and a PUT Mutation. I want to see if I can get a query result out of Appsync. However, I want to connect to an AppSync local resolver, and pull out some dummy data rather than query a database. The AWS Direct Lambda Resolver (DLR) seems to be the best way. This prototype is significant for me, if I prove it, and understand it, I will put a stake in it, and use it as my base application infrastructure.

AppSync is not the only game in town. I also like Hasura, but at this point in time Amplify and DLRs give AppSync the edge.

The plan

  1. Install a base Ionic-React project
  2. Install Amplify integration
  3. Install Amplify UI
  4. Create a DLR (Direct Lambda Resolver)
  5. Create an Appsync GraphQL API
  6. Configure a demo front end
  7. Make a call from the frontend and display dummy results

Prerequisites

My versions from package.json are as follows, behaviour may change as time goes on and versions go up.

    "@aws-amplify/ui-react": "^0.2.14",
    "@capacitor/core": "2.4.0",
    "@ionic/react": "^5.0.7",
    "@ionic/react-router": "^5.0.7",
    "@testing-library/jest-dom": "^4.2.4",
    "@testing-library/react": "^9.4.0",
    "@testing-library/user-event": "^8.0.3",
    "@types/jest": "^24.0.25",
    "@types/node": "^12.12.24",
    "@types/react": "^16.9.17",
    "@types/react-dom": "^16.9.4",
    "@types/react-router": "^5.1.4",
    "@types/react-router-dom": "^5.1.3",
    "aws-amplify": "^3.0.23",
    "ionicons": "^5.0.0",
    "react": "^16.13.0",
    "react-dom": "^16.13.0",
    "react-router": "^5.1.2",
    "react-router-dom": "^5.1.2",
    "react-scripts": "^3.4.2",
    "typescript": "3.8.3"

1: Install a base Ionic-React project

I'm doing this on a Windows dev server. I have had Ionic installed for a while so I'm re-running the Ionic install to pick up any updates, then creating a project.

npm i -g @ionic/cli 

Then change directory to my development root, and build the project.

ionic start ionic-react-amplify blank --type=react
cd .\ionic-react-amplify
npm install
ionic serve

You should be able to find the base welcome screen on a browser pointing at http://localhost:8100/

2: Install Amplify integration

I want my app to be able to talk to the AWS mothership. Enter: Amplify. I am following a variation on the AWS tutorial here. Note however that is Ionic with Angular, my variation replaces Angular with React which is a bit of a refactor of that guide.

Even though I already have the Amplify CLI installed, it was a while ago so I'll pick up any updates.

npm i -g @aws-amplify/cli

I have some defaults here that I hope are obvious and can be modified to suit. From inside the app home directory I just created at ionic-react-amplify:

amplify init
Scanning for plugins...
Plugin scan successful
Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project ionicreactamplify
? Enter a name for the environment dev
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using ionic
? Source Directory Path:  src
? Distribution Directory Path: www
? Build Command:  npm.cmd run-script build
? Start Command: ionic serve
Using default provider  awscloudformation

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html

? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use default
Adding backend environment dev to AWS Amplify Console app: xxxxxxxxxxxx
...

This should now have made available the credentials needed for your dev system to talk to the cloud.

3: Install Amplify UI

This will install some base elements such as UI buttons supporting templated login/logout.

From the main dev directory.

npm install aws-amplify @aws-amplify/ui-react

The default guides suggest adding the Amplify imports to src/index.jsx. That is fine for a default but we will be doing all our work in the default Home file created with routes enabled, more on that later.

In theory, your CLI is now ready to build AWS cloud elements, and your frontend code has the hooks to be able to talk to them. You might wish to plough on through the AWS tutorial linked in section 2. The problem with that is, you'll have to jump back and forth between Ionic - which is based on Angular - and React.

For me however, I want to take the React road, and I want to create a GraphQL API, but have it read from a "local resolver" DLR in AppSync, and have that return a set response, a bit like Postman.

This means connecting Amplify to a pre-existing AppSync resource, rather than creating one on demand. So my next step logically would be to create the AppSync resource, then come back and figure out how to get the amplify add api to do my bidding.

4: Create a DLR (Direct Lambda Resolver)

For this I will make use of the AWS Management Console. First we'll configure a Lambda script.

You'll need a region with AppSync and Lambda, which is most of them. Take yourself to the chosen region Services -> Compute -> Lambda screen.

From the dashboard select "Create Function". Select "Author from scratch". Choose a name such as "ionic-resolver". This demo is Node code, so select Node.js, at version 12.x if it's there. Resolvers can be several other languages. The default permissions are fine for this introspective demo. Hit the Create Function button.

Replace all the default code with my demo code, and save.

exports.handler = (event, context, callback) => {
    console.log( event);
    switch (event.info.fieldName) {
        case 'getHelloWorld':
            const getHelloWorld = {
                node: "Hello world from GET request",
                myParam: "GET parameter: " + event.arguments.myParam,
                myData: "Some query result stuff."
            } ;
        
            callback(null, getHelloWorld);
        case 'putHelloWorld':
            const putHelloWorld = {
                node: "Hello world from PUT request",
                myParam: "PUT parameters: " + event.arguments.myParam + " and: " + event.arguments.myData,
                // Some sort of mutation like adding a DB record
                myDataResult: "Did something interesting with data: " + event.arguments.myData,
            } ;
        
            callback(null, putHelloWorld);
    }
};

It's good to know here that there are some reserved context objects we can lean on. It's worth familiarising yourself. For this example I am using these:

  • event.arguments
  • event.info

There are multiple objects in the context. More here: https://docs.aws.amazon.com/appsync/latest/devguide/resolver-context-reference.html

In particular if you are passing fields that include user input, there is crucial information in there on sanitizing the data.

5: Create an Appsync GraphQL API

With your region selected, take yourself to the console Services -> Mobile -> AWS AppSync management screen.

Create the API

On the main API screen, choose Create API. Choose "Build from Scratch" and Start. Choose a name like "Ionic Resolver", and Create.

Create the Schema

Go to the Schema, and replace the commented text with the demo schema below and save.

type HelloWorld {
	myParam: String!
	node: String
	myData: String
	myDataResult: String
}

type Mutation {
	putHelloWorld(myParam: String, myData: String): HelloWorld
}

type Query {
	getHelloWorld(myParam: String): HelloWorld
}

schema {
	query: Query
	mutation: Mutation
}

Add the Lambda as a data source

Let's add the Lambda we created as a data source. Go to the Data Sources and hit the Create data source button. On the next screen, give the data source a name like "ionic_resolver". The data source type drop down should be AWS Lambda function. The region should be your chosen region.

Find your script in the Function ARN drop list. If it's not there, you can copy paste the ARN from Lambda code pages.

Hit the Create button, and your Lambda should be now available as a data source.

Attach the data source to the Mutation and Query

Go back to the Schema page.

On the right, we need to attach the Lambda script we created to the Mutation and Query entries (only - not resolvers). Choose attach, and on the next screen, choose the script. Do not enable any mapping templates, and hit the Save Resolver button.

Go back to the Schema page and do the same for the other, so both the Mutation and Query are attached.

Test the query and mutation

Go to the "Queries" page. Try each of these and you should see the relevant responses.

First the Query. Paste this into the query window and hit the Play button.

query {
  getHelloWorld (myParam:"get-10-records"){
    node
    myParam
    myData
  }
}

Should give this response:

{
  "data": {
    "getHelloWorld": {
      "node": "Hello world from GET request",
      "myParam": "GET parameter: get-10-records",
      "myData": "Some query result stuff."
    }
  }
}

Next the Mutation. Clear and Paste this into the query window.

mutation putHelloWorld {
  putHelloWorld(
    myParam:"put-stuff"
    myData: "abc123"
    ) {
    node
    myParam
    myDataResult
  }
}

Executing the mutation should return:

{
  "data": {
    "putHelloWorld": {
      "node": "Hello world from PUT request",
      "myParam": "PUT parameters: put-stuff and: abc123",
      "myDataResult": "Did something interesting with data: abc123"
    }
  }
}

If you check the Lambda code, hopefully it's apparent what's happening with the data to-and-fro. Once you can see the core mechanism for passing data back and forth, it's super empowering. I went through this piece of work for my own understanding, and was stoked when I figured it out. I now should be able to build my whole app infrastructure on this foundation.

6. Configure the demo front end

The good news is that due to Amplify, we can now wrap the queries and responses in Ionic/React, and in theory it's the same queries.

Get the code generation string

In the AppSync console, go to the primary API page, in my case selecting "Ionic Resolver" on the left toolbar. In the integrate box, select the Javascript tab. We have already carried out the Amplify initialisation steps. Where you have "Add the codegen category to your project.", copy the add-codegen string under it.

Head back to your development environment, where we built a blank Ionic/React app. We're going to try and join up the the API.

Install the AppSync frontend code

The apiID name you provide here is unique to the API you've created that you copied. Jump for joy - it recognises Ionic and offers typescript.

Change directory into your project root. Stop the dev process if it's running. Run the amplify add codegen you copied from the previous step.

cd .\ionic-react-amplify
amplify add codegen --apiId slwvcjm7onfz7axhabcde12345
√ Getting API details
Successfully added API Ionic Resolver to your Amplify project
? Choose the code generation language target typescript
? Enter the file name pattern of graphql queries, mutations and subscriptions src\graphql\**\*.ts
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
? Enter the file name for the generated code src\API.ts
? Do you want to generate code for your newly created GraphQL API Yes
√ Downloaded the schema
√ Generated GraphQL operations successfully and saved at src\graphql
√ Code generated successfully and saved in file src\API.ts

Should you want to regenerate following, say, a change to schema, the command "amplify codegen" can be run from the same directory. I haven't tried it - so can't vouch for its safety working on a production system. The API can be viewed thus.

amplify status

Current Environment: dev

| Category | Resource name  | Operation | Provider plugin |
| -------- | -------------- | --------- | --------------- |
| Api      | Ionic Resolver | No Change |                 |

GraphQL endpoint: https://xxx123.appsync-api.us-west-2-or-your-region.amazonaws.com/graphql
GraphQL API KEY: xxx

Build the Ionic-React frontend page

The default project creation includes routes. I am going to use the default Home page, just because it's there. If for some reason you don't have the routes with "Home" page, this could also be set up in the App.tsx file with some minor tweaks.

Edit the src/pages/Home.tsx file and replace with the following code. After this I will run the demo.

import {
  IonContent,
  IonHeader,
  IonPage,
  IonTitle,
  IonToolbar,
  IonGrid,
  IonRow,
  IonCol,
  IonCard,
  IonCardContent,
  IonButton,
  IonCardHeader,
  IonCardTitle,
} from "@ionic/react";
import React, { useState } from "react";
import "./Home.css";
import Amplify, { API, graphqlOperation } from "aws-amplify";
import { putHelloWorld } from "../graphql/mutations";
import { getHelloWorld } from "../graphql/queries";
import awsconfig from "../aws-exports";

Amplify.configure(awsconfig);
API.configure(awsconfig);

const Home: React.FC = () => {
  async function getQuery() {
    console.log("Get Query pressed.");
    try {
      // This "any" added to not assess Type for "property data doesn't exist"
      const queryData: any = await API.graphql(
        graphqlOperation(getHelloWorld, { myParam: "get-1" })
      );
      setQueryResultMyParam(queryData.data.getHelloWorld.myParam);
      setQueryResultNode(queryData.data.getHelloWorld.node);
      setQueryResultMyData(queryData.data.getHelloWorld.myData);
    } catch (err) {
      console.log("error fetching Query");
    }
  }

  async function putMutation() {
    console.log("Put Mutation pressed.");
    try {
      // This "any" added to not assess Type for "property data doesn't exist"
      const putResultData: any = await API.graphql(
        graphqlOperation(putHelloWorld, {
          myParam: "put-stuff",
          myData: "abc123",
        })
      );
      setMutationResultMyParam(putResultData.data.putHelloWorld.myParam);
      setMutationResultNode(putResultData.data.putHelloWorld.node);
      setMutationResultMyDataResult(
        putResultData.data.putHelloWorld.myDataResult
      );
    } catch (err) {
      console.log("error putting Mutation");
    }
  }

  const [gotQueryResultMyParam, setQueryResultMyParam] = useState<string>();
  const [gotQueryResultNode, setQueryResultNode] = useState<string>();
  const [gotQueryResultMyData, setQueryResultMyData] = useState<string>();
  const [gotMutationResultMyParam, setMutationResultMyParam] = useState<
    string
  >();
  const [gotMutationResultNode, setMutationResultNode] = useState<string>();
  const [
    gotMutationResultMyDataResult,
    setMutationResultMyDataResult,
  ] = useState<string>();

  return (
    <IonPage>
      <IonHeader>
        <IonToolbar>
          <IonTitle>Appsync local resolver</IonTitle>
        </IonToolbar>
      </IonHeader>
      <IonContent>
        <IonGrid>
          <IonRow>
            <IonCol>
              <IonCard>
                <IonCardContent>
                  <IonGrid>
                    <IonRow>
                      <IonCol size="12" size-md="6" className="ion-text-center">
                        <IonButton expand="block" onClick={getQuery}>
                          Do a test :<b>GET</b>: query
                        </IonButton>
                      </IonCol>
                      <IonCol size="12" size-md="6" className="ion-text-center">
                        <IonButton onClick={putMutation} expand="block">
                          Do a test :<b>PUT</b>: mutation
                        </IonButton>
                      </IonCol>
                    </IonRow>
                  </IonGrid>
                </IonCardContent>
              </IonCard>
              {gotQueryResultMyParam && (
                <IonRow>
                  <IonCol>
                    <IonCard>
                      <IonCardHeader>
                        <IonCardTitle>GET Query result</IonCardTitle>
                      </IonCardHeader>
                      <IonCardContent>
                        <h2>myParam:</h2>
                        {gotQueryResultMyParam}
                        <br />
                        <h2>NodeJS script says:</h2>
                        {gotQueryResultNode}
                        <br />
                        <h2>Dummy data query returns:</h2>
                        {gotQueryResultMyData}
                      </IonCardContent>
                    </IonCard>
                  </IonCol>
                </IonRow>
              )}
              {gotMutationResultMyParam && (
                <IonRow>
                  <IonCol>
                    <IonCard>
                      <IonCardHeader>
                        <IonCardTitle>PUT Mutation result</IonCardTitle>
                      </IonCardHeader>
                      <IonCardContent>
                        <h2>myParam + myData:</h2>
                        {gotMutationResultMyParam}
                        <br />
                        <h2>NodeJS script says:</h2>
                        {gotMutationResultNode}
                        <br />
                        <h2>myDataResult:</h2>
                        {gotMutationResultMyDataResult}
                      </IonCardContent>
                    </IonCard>
                  </IonCol>
                </IonRow>
              )}
            </IonCol>
          </IonRow>
        </IonGrid>
      </IonContent>
    </IonPage>
  );
};

export default Home;

I ground away for a while trying to pass an object with useState which seemed intuitive, but struggled with errors. Then I wandered off down a track that ended with a recommendation to separate out the useState calls, so that's what I've done.

I also encountered Type errors when retreiving the blah.data.blah's, and added the "any" to avoid assessment for Type. Poking around in the Amplify Github I saw acknowledgement of this, specifically property 'data' does not exist on type 'GraphQLResult' and there are open cases being worked on. Using the "any" might not be necessary in future.

7. Make a call from the frontend and display dummy results

Get the dev server running

I run up the local dev server with ionic serve. Because of the default routing, it will take me to http://localhost:8100/home by default.

Similar to Chrome, I use the Firefox developer views to see debugging info and simulate mobile views. My start screen with the console open looks like this. You'll note I have the simulator set to Galaxy S9. If you haven't yet played with this mobile simulator I recommend you get into it. It's not a comprehensive mobile compatibility tester by any means, but it's perfect for rapidly checking screen aspects in dev.

You can also toggle the screen profile button on the top right and see the app in a traditional web browser view. You are likely here because of the Ionic angle - so you already know its power.

Run the test from Ionic and check the results

Tempting though it is to fiddle with the front end until it's perfect, I will simply click the Get, then the Put, and check the output.

I am making use of React "useState" to display the result cards only once there is data.

Wrapping Up

You can see the potential. The parameters can of course be anything, including data gathered at the front end. If you are getting user input, look very hard at sanitising data.

Amplify allows further enhancement such as auth, and deployment to actual cloud hosting.

Next steps for my app are to do a bit of work around the back end, to get that direct Lambda resolver making some calls to a real database - maybe Dgraph.

If you've followed this road I hope it's been useful!

Main photo courtesy of Javier Esteban on Unsplash

You are welcome to comment anonymously, but bear in mind you won't get notified of any replies! Registration details (which are tiny) are stored on my private EC2 server and never shared. You can also use github creds.