AWS Amplify GraphQL is Awesome!!! But Missing One Thing?

AWS Amplify GraphQL is Awesome!!! But Missing One Thing?

AWS Amplify with the built-in GraphQL code generators and convenience functions are awesome!!! Querying my GQL API is smooth like butter. However, none of this convenience is available in AWS Amplify Lambda function. This means that making GraphQL queries to your AppSync API is going to require some extra work.

Generators are Magic (but not for Lambdas)

In an AWS Amplify project, when you update your Schema, Amplify auto-generates convenience code for you, which can be used in your UI/React code. The problem, once again, is that it can only be used in your UI code.

When your run amplify push or amplify codegen Amplify takes your API schema definition and creates a set of fully specified query strings you can easily use to run queries, mutations, and subscriptions.

Super Semantic: a GraphQL Schema

The GraphQL definition in Amplify lives in amplify/backeng/api/schema.graphql. This is where you can define your GraphQL schema, with all kinds of decorators provided by Amplify.

type Knowledge
@model
@searchable
@auth(
    rules: [
        {
            allow: public,
            provider: apiKey,
            operations: [create, update, read, delete]
        }
    ]
) {
    id: ID!
    url: String
    text: String
    length: Int!
    embedding: [Float]!
    score: Float
    topics: [Topic] @hasMany
    organizationId: ID!
}

Amplify Generates Queries For You

When you amplify push or amplify codegen Amplify automatically creates a set of convenience variables for all the available queries, mutations, and subscriptions for you in these files: src/graphql/mutations.js, src/graphql/queries.js, and src/graphql/subscriptions.js.

These convenience variables provides things like this createKnowledge function. AWS Amplify maintains the mutations.js file for you, ensuring your queries match your schema. However, once again, they are only available by default in the UI code... not in Lambdas!

export const createKnowledge = /* GraphQL */ `
  mutation CreateKnowledge(
    $input: CreateKnowledgeInput!
    $condition: ModelKnowledgeConditionInput
  ) {
    createKnowledge(input: $input, condition: $condition) {
      id
      url
      text
      length
      embedding
      score
      topics {
        items {
          id
          tag
          description
          createdAt
          updatedAt
          knowledgeTopicsId
          __typename
        }
        nextToken
        __typename
      }
      organizationId
      createdAt
      updatedAt
      __typename
    }
  }
`;

React: Easily Use Convenience Functions and Queries

This is awesome, because this you can use this query variable throughout your UI/React code, and so you can avoid having to update your query everywhere you use it. It enables conveniently using the query in your React code, with some aws-amplify provided functions for making GraphQL calls to the AppSync endpoint.

Using Convenience Code in React / UI Code:

import {API, graphqlOperation} from "aws-amplify";
import {createKnowledge} from "@/graphql/mutations";

const response = await API.graphql(graphqlOperation(createKnowledge, {
   input: {
     text: "Some knowledge"
   }
  })
);

Lambdas: Out of Luck!!!

Unfortunately, AS I MENTIONED, Amplify does not provide a convenient, auto-magic way to do this inside your AWS Amplify Lambdas... yet. Instead, the code recommended by AWS looks like this:


const axios = require('axios');
const gql = require('graphql-tag');
const graphql = require('graphql');
const {print} = graphql;

const searchTrainingDatapointsQuery = gql`
    query SearchTrainingDatapoints(
        $filter: SearchableTrainingDatapointFilterInput
        $sort: [SearchableTrainingDatapointSortInput]
        $limit: Int
        $nextToken: String
    ) {
        searchTrainingDatapoints(
            filter: $filter
            sort: $sort
            limit: $limit
            nextToken: $nextToken
        ) {
            items {
                id
                manifestURL
                datasetARN
                createdAt
                updatedAt
                trainingDatapointDetectionId
            }
            nextToken
        }
    }
`


const searchTrainingDatapoints = async (nextToken) => {
  return axios({
    url: process.env.XXXX,
    method: 'post',
    headers: {
      'x-api-key': process.env.XXXX
    },
    data: {
      query: print(searchTrainingDatapointsQuery),
      variables: {
        nextToken,
        filter: {
          isApproved: {
            eq: true
          }
        }
      }
    }
  });
}

Yuck, This is Not Ideal

Yuck! You could put this code in every Lambda where you need it, but that approach comes with a lot of issues. Wouldn't it be so nice to have the same convenience, static-linking, and clean code in your Lambdas!

Why Should Lambdas be Second Class?

I found myself repeating this pattern of copying segments of the GQL queries and mutations I needed into custom functions in each of my Lambdas. This led to extra maintenance work whenever I changed the GQL Schema. I needed to find and update all instances of the queries in every Lambda. This borderline violates the DRY principle, because I'm maintaining multiple copies of nearly identical query code across lambdas and between Lambdas and my UI code. Let's fix that.

The Goal: Lambdas are First Class GQL Users

We want a solution that lets us access the convenience of the autogenerated GraphQL queries, and also some nice functions for hiding the boilerplate authentication, request, and marshalling code of AppSync. We want to be able to share and reuse that code between all my lambdas.

It would be nice if it were this easy to call a GraphQL AppSync endpoint in every Lambda:

const {updateData, updateKnowledge} = require('applyx-graphql')

const embeddings = []
const id = "xxxxx-xxxxx-xxxxx-xxxxx"
const updateResult = await updateData(updateKnowledge,
  "updateKnowledge", {
      input: {
        id: id,
        embedding: embeddings
      }
    })

Bridging the Gap: a Solution

Lambda Layers + Amplify Hooks for Automated Query Sharing

The approach is simple: copy the query generated queries to a lambda layer, alongside boilerplate code for making authenticated requests to AppSync more convenient. It's not perfect. It would be better if we could avoid copying the query files and instead share them between front and backend... but alas, the strong separation between the Lambda runtime and the UI/React/NextJS runtimes prevents that.

Lambda Layers for Code Sharing

AWS Lambda supports something called Lambda Layers. AWS Lambda Layers are a feature that allows you to package and manage common dependencies and code libraries separately from your Lambda function, making them reusable across multiple functions. PERFECT!. A Lambda Layer can hold and share common GraphQL code for use across all our lambdas.

Lambda layer architecture GIF

Create a Lambda Layer

First we need to create a new Lambda layer, using amplify add function, and choose to add a Lambda layer.

Lambda layer creation

This lambda layer will become the centralized serverless location for housing and sharing our GraphQL queries, and convenience code.

Add The Layer to Your Function

Now, just add the layer to the Lambda function where you wish to use the convenience queries and functions.

Adding Layer to Function

My example here shows a previous "applyxgraphql" layer. This is just an earlier layer I created using this same technique. You can ignore it. Focus on the "applyxgraphqllayer" layer.

Amplify Hooks: for Automating Queries Sharing

By using Amplify hooks, we can copy an updated version of the src/graphql/mutations.js, src/graphql/queries.js,src/graphql/subscriptions.js files to a Lambda layer prior to running amplify push. By adding amplify/hooks/Pre-Push.js, we can trigger an action before amplify push

This hook file copies a those files every time we run amplify push. It will copy the src/graphql/mutations. js, src/graphql/queries.js,src/graphql/subscriptions.js files to a directory inside this new layer.

/**
 * This is an Amplify hook file, wich runs before an ```amplify push```, and copies the autogenerated queries.js,
mutations.js, and subscriptions.js into an AWS Lambda Layer directory.
 *
 * learn more: https://docs.amplify.aws/cli/usage/command-hooks
 */
const fs = require("fs");

const copyFiles = (src, dest) => {
  if (!fs.existsSync(dest)) {
    fs.mkdirSync(dest);
  }
  const files = fs.readdirSync(src);
  for (const fileName of files) {
    const srcPath = `${src}/${fileName}`;
    const destPath = `${dest}/${fileName}`;
    if (fs.existsSync(destPath)) {
      fs.unlinkSync(destPath);
    }
    // also replace a given regex in the file with another string
    let fileContent = fs.readFileSync(srcPath, 'utf8');
    // Replace the text using regex
    // const newContent = fileContent.replace(/export const (.*) =/g, 'module.exports.$1 =');
    // ^ This is for a non-typescript Lambda, where export is not converted by tsc prior to deploying
    fs.writeFileSync(destPath, fileContent);
  }
}
/**
 * @param data { { amplify: { environment: { envName: string, projectPath: string, defaultEditor: string }, command: string, subCommand: string, argv: string[] } } }
 * @param error { { message: string, stack: string } }
 */
const hookHandler = async (data, error) => {
  const src = "src/graphql";
  const dest = "amplify/backend/function/applyxgraphql/lib/nodejs/src/my_graphql/graphql";
  copyFiles(src, dest);

};

const getParameters = async () => {
  const fs = require("fs");
  return JSON.parse(fs.readFileSync(0, { encoding: "utf8" }));
};

getParameters()
  .then((event) => hookHandler(event.data, event.error))
  .catch((err) => {
    console.error(err);
    process.exitCode = 1;
  });

Lambda Layer Files and Functions (An Overview of the Resulting Structure and Code)

Within this lambda layer amplify/backend/functions/applyxgraphql, we will add a set of helper functions alongside a directory of GraphQL queries, and export it all in an index file. Last, we will add a package.json file to make this "local module" requirable. Then we will import the local module into the layer with the layer's root package.json.

├── applyxgraphql-awscloudformation-template.json
├── layer-configuration.json
├── lib
│   └── nodejs
│       ├── package.json # layer dependencies go here.
│       ├── package-lock.json
│       ├── README.txt
│       └── src
│           └── applyx-graphql
│               ├── graphql # query files copied here
│               │   ├── mutations.ts
│               │   ├── queries.ts
│               │   ├── schema.json
│               │   └── subscriptions.ts
│               ├── index.ts
│               ├── package.json # local graphql dependencies go here
│               ├── package-lock.json
│               ├── storeHelpers.ts # convenience functions here
│               ├── storeHelper.test.ts
│               └── tsconfig.json
├── opt
└── parameters.json

NOTE: The 'applyx' is added by Amplify based on your Amplify stack name. My Amplify stack here is named 'applyx'

AppSync Helper Code

Inside storeHelpers.ts We will add some code for packaging up the query, adding the API_KEY for our AppSync endpoint, and packaging it all up as an axios request. The code below is what I'm currently using.

CAVEAT: It works, but could be improved in a few ways: (most notably by not requiring provision of the queryName.. . for another time...).

import axios from 'axios';

/**
 *  API_XXX_GRAPHQLAPIENDPOINTOUTPUT
 *  API_XXX_GRAPHQLAPIIDOUTPUT
 *  API_XXX_GRAPHQLAPIKEYOUTPUT
 */

interface PackageRequest2Result {
  items: Array<any>;
}

const packageRequest2 = async (query: string, queryName: string, variables: any): Promise<PackageRequest2Result> => {
  // console.log('packageRequest2', queryName, JSON.stringify(variables, null, 2));
  const headers = {
    'x-api-key': process.env.API_XXX_GRAPHQLAPIKEYOUTPUT,
    'Content-Type': 'application/json',
  };
  const response = await axios({
    url: process.env.API_XXX_GRAPHQLAPIENDPOINTOUTPUT as string,
    method: 'post',
    headers: headers,
    data: {
      query,
      variables
    },
  });
  const result = response.data;
  if (result.errors) {
    throw new Error(JSON.stringify(result.errors));
  }
  return result?.data[queryName];
};

const getData = async (query: string, queryName: string, id: string): Promise<any> => {
  return await packageRequest2(query, queryName, {id});
};

const searchData = async (query: string, queryName: string, variables: any): Promise<any> => {
  const result = await packageRequest2(query, queryName, variables);
  return result?.items;
};

const createData = async (query: string, queryName: string, variables: any): Promise<any> => {
  return await packageRequest2(query, queryName, variables);
};

const updateData = async (query: string, queryName: string, variables: any): Promise<any> => {
  return await packageRequest2(query, queryName, variables);
};

const deleteData = async (query: string, queryName: string, id: string): Promise<any> => {
  return await packageRequest2(query, queryName, {input: {id}})
}

export {getData, searchData, createData, updateData, deleteData};

Making the Local npm Module Available

A lambda layer can make any dependencies available to Lambda functions by adding those dependencies to the package.json. In this case, we want to make this local module available. That looks like this.

The amplify/backend/function/applyxgraphql/lib/nodejs/package.json file requires this local package as a dependency, making it available to layer users.

{
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "dependencies": {
    "applyx-graphql": "file:src/applyx-graphql"
  }
}

The Result: Convenience like in Amplify UI/React

As a result of this automation + convenience code, we can now more easily and DRY-ly use GraphQL queries, mutations, and subscriptions in our Lambda functions.

We can do something like this in any Lambda that uses this layer:

const {
  updateData,
  createData,
  updateKnowledge,
  createKnowledge
} = require('applyx-graphql')
const embeddings = []
const id = "xxxxx-xxxxx-xxxxx-xxxxx"
const updateResult = await updateData(
  updateKnowledge,
  "updateKnowledge", {
      input: {
        id: id,
        embedding: embeddings
      }
    })

const createResult = await createData(
  createKnowledge,
  "createKnowledge", {
      input: {
        embedding: embeddings
      }
    })

A Note on Typescript in Lambdas

For this example to work, we must compile Typescript in each Lambda/Layer into javascript. Here's a hint on how to automate that with amplify push as well.

amplify/backend/function/applyxgraphql/lib/nodejs/src/applyx-graphql/tsconfig.json

{
  "compilerOptions": {
    "target": "es2017",
    "noImplicitAny": false,
    "allowJs": true,
    "types": ["node"],
    "module": "NodeNext",
    "moduleResolution": "nodenext"
  },
  "include": ["."],
  "exclude": ["node_modules", "**/*.test.ts"]
}

{projectRoot}/package.json

{
  "scripts": {
   "amplify:applyxgraphql": "cd amplify/backend/function/applyxgraphql/lib/nodejs/src/applyx-graphql && tsc -p ./tsconfig.json && cd -",
  }
}