AWS AppSync with Amplify: How to ensure a field in a type is unique
Tags: aws, amplify, appsync, graphql
Published: 2021-11-07 10:20:48

I will cut to the chase - you are probably here from a Google search tearing your hairs out trying to figure how to ensure that a field in your graphql schema is unique, and you are using AWS AppSync in conjunction with AWS Amplify. Amplify's documentation leaves too much to be desired. Here's how to do it:

## Things to know before we proceed

Amplify provides a `CustomResources.json` file under the `stacks` folder that you will make use of. In this file, we specify the CloudFormation templates that Amplify needs to create - that will help us out with our goal.

We'll be creating:

1. A global secondary index in the dynamodb table associated with your schema
2. A lambda function that checks for the uniqueness of the field in the schema
3. An IAM role that allows Appsync to invoke this lambda function
4. A lambda data source associated with the previous lambda function
5. An Appsync Function that invokes the lambda function, with its request and response mapping templates
6. Our Mutation Appsync Function, with its request and response mapping templates.
7. Lastly, our pipeline that brings them all together.

This is the graphql schema we'll be using in our example:

```graphql
type User @model {
  id: ID!
  name: String
  email: String
  username: String
}
```

and we'll be attempting to ensure that the `username` field is unique.

**Word of caution: This post will be a lengthy one and so will be the steps you need to carry out to do something as simple as ensuring uniqueness. But the steps are very easy to follow.**

### Step 1: Create a Global Secondary Index (GSI)

This GSI will be created on the dynamodb table associated with our `User` type. This step is straightforward. We'll use the `@key` directive that amplify provides, which will create the GSI for us

```graphql
// Graphql schema

type User
  @model
  @key(
    fields: ["username"],
    name: "UsernameIndex",
    queryField: "getUserByUsername"
) {
  id: ID!
  name: String
  email: String
  username: String
}
```

This is creating an index named `UsernameIndex` in the dynamodb table associated with the User model. There's only 1 field in this index - the `username`. That was it. Run `amplify push` to create this resource in AWS.

### Step 2: Lambda function that checks for uniqueness of the field's value

This one too is straightforward. Using `amplify add function`, create your lambda function which will query the table associated with the `User` schema in dynamodb, along with the index you created in Step 1. In my example, I have named this function as `uniqueusernamecheck` You can write this in any of the languages that lambda functions support. Here's a snippet of how one could write it in Nodejs:

```javascript
// Lambda function - uniqueusernamecheck

exports.handler = async (event) => {
  const { input } = event.arguments

  // Adding this IF condition only to ensure that we run the check if the username was being set
  if (input.username) {
    const params = {
      TableName: process.env.USERTABLE_NAME,
      IndexName: 'UsernameIndex',
      KeyConditionExpression: 'username = :username',
      ExpressionAttributeValues: {
        ':username': input.username
      }
    }

    // We queried the database associated with the `User` model directly, but feel free to use the `getUserByUsername` query too
    const record = await docClient.query(params).promise()

    if (record.Count > 0 && record.Items[0].id !== event.identity.sub) {
      throw new Error(`${input.username} has been taken. Try another username`)
    }
  }

  // All is well
  return {}
};

```

Feel free to change the values in the `params` object to suit your needs.

What we are doing in this function is checking if a user with that username exists, and if yes, we throw an error. Else, we are returning (an empty object).

As before, run `amplify push` to create this function in AWS.

### Step 3 - An IAM role that allows AppSync to invoke the earlier created Lambda function

`CustomResources.json` comes into play now. We'll be using this to create the IAM role. The `Resources` key on the object in this file is where we'll be declaring all our resources starting with the IAM role. This is what our IAM role definition will look like:

```json
// CustomResources.json

"UniqueUsernameCheckLambdaDataSourceRole": {
  "Type": "AWS::IAM::Role",
  "Properties": {
    "RoleName": {
      "Fn::Sub": [
        "UniqueUsernameCheckLambdaDataSourceRole-${env}",
        { "env": { "Ref": "env" } }
      ]
    },
    "AssumeRolePolicyDocument": {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Principal": {
            "Service": "appsync.amazonaws.com"
          },
          "Action": "sts:AssumeRole"
        }
      ]
    },
    "Policies": [
      {
        "PolicyName": "InvokeLambdaFunction",
        "PolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Action": [
                "lambda:invokeFunction"
              ],
              "Resource": [
                {
                  "Fn::Sub": [
                    "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:uniqueusernamecheck-${env}",
                    { "env": { "Ref": "env" } }
                  ]
                }
              ]
            }
          ]
        }
      }
    ]
  }
},
```

Feel free to change the names - remember that in our example, our function was named `uniqueusernamecheck` and that's what we have used.

At this point, we don't really have to, but it would be best to run `amplify push` to create this IAM role. We can also wait until we complete Step 7 and run it once to create all the resources we need.

### Step 4 - Create a Lambda data source

We have created our index, the lambda function that does the uniqueness check for us and the IAM role for Appsync to invoke this function. Moving along, we shall now create a data source for the lambda function in Appsync (in the same `CustomResources.json` file - you can add this below the one you created in Step 3:

```json
// CustomResources.json

"UniqueUsernameCheckLambdaDataSource": {
  "Type": "AWS::AppSync::DataSource",
  "Properties": {
    "ApiId": {
      "Ref": "AppSyncApiId"
    },
    "Name": "UniqueUsernameCheckLambdaDataSource",
    "Type": "AWS_LAMBDA",
    "ServiceRoleArn": {
      "Fn::GetAtt": [
        "UniqueUsernameCheckLambdaDataSourceRole",
        "Arn"
      ]
    },
    "LambdaConfig": {
      "LambdaFunctionArn": {
        "Fn::Sub": [
          "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:uniqueusernamecheck-${env}",
          { "env": { "Ref": "env" } }
        ]
      }
    }
  },
  "DependsOn": "UniqueUsernameCheckLambdaDataSourceRole"
},
```

Note the `DependsOn` attribute at the end - which makes sure that the IAM role to call the lambda function is created BEFORE the data source gets created. We are also ensuring that we call the function we created in Step 2 - with the environment suffix (since amplify adds that automatically if you are working in multiple environments)

Hang in there - we are halfway through. Most of this is just copy and paste, with the values substituted to suit your scenario.

### Step 5 - Create an Appsync Function

Having created the Lambda data source in the previous step, we now create an Appsync function - the function basically invokes the lambda function we created earlier as well as the request mapping template (called BEFORE our lambda function executes) and response mapping template (called AFTER our lambda function executes)

#### Step 5A - Create the request and response mapping template(s)

The `CustomResources.json` file we have been editing is in the `stacks` folder. Look for a folder named `pipelineFunctions`, which should be a sibling of the `stacks` folder. In this folder we'll define the request and response mapping templates:

This is the request mapping template - create a file named `InvokeUniqueUsernameCheckLambdaDataSource.req.vtl` under the `pipelineFunctions` folder:

```vtl
## pipelineFunctions/InvokeUniqueUsernameCheckLambdaDataSource.req.vtl

## [Start] Invoke AWS Lambda data source: UniqueUsernameCheckLambdaDataSource. **
{
  "version": "2018-05-29",
  "operation": "Invoke",
  "payload": {
      "typeName": "$ctx.stash.get("typeName")",
      "fieldName": "$ctx.stash.get("fieldName")",
      "arguments": $util.toJson($ctx.arguments),
      "identity": $util.toJson($ctx.identity),
      "source": $util.toJson($ctx.source),
      "request": $util.toJson($ctx.request),
      "prev": $util.toJson($ctx.prev)
  }
}
## [End] Invoke AWS Lambda data source: UniqueUsernameCheckLambdaDataSource. **

```

This passes on all the info (and more) that the lambda function needs to perform the uniqueness check.

And this below is the response mapping template - create a file named `InvokeUniqueUsernameCheckLambdaDataSource.res.vtl` under the `pipelineFunctions` folder: 

```vtl
## pipelineFunctions/InvokeUniqueUsernameCheckLambdaDataSource.res.vtl

## [Start] Handle error or return result. **
#if( $ctx.error )
  $util.error($ctx.error.message, $ctx.error.type)
#end
$util.toJson($ctx.result)
## [End] Handle error or return result. **
```

...and this returns the error or results of the lambda function execution. In our scenario, an error occurs if the username has been taken. There is no "result" returned from the lambda function - if there's no error, we simply move on to the next resolver in our pipeline (further below).

#### Step 5B - Actually define the Appsync function

Having defined the request and response mapping templates, we now come back to the `CustomResources.json` file and define the appsync function that uses it:

```json
// CustomResources.json

"InvokeUniqueUsernameCheckLambdaDataSource": {
  "Type": "AWS::AppSync::FunctionConfiguration",
  "Properties": {
    "ApiId": {
      "Ref": "AppSyncApiId"
    },
    "Name": "InvokeUniqueUsernameCheckLambdaDataSource",
    "DataSourceName": "UniqueUsernameCheckLambdaDataSource",
    "FunctionVersion": "2018-05-29",
    "RequestMappingTemplateS3Location": {
      "Fn::Sub": [
        "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/pipelineFunctions/${ResolverFileName}",
        {
          "S3DeploymentBucket": {
            "Ref": "S3DeploymentBucket"
          },
          "S3DeploymentRootKey": {
            "Ref": "S3DeploymentRootKey"
          },
          "ResolverFileName": {
            "Fn::Join": [".", ["InvokeUniqueUsernameCheckLambdaDataSource", "req", "vtl"]]
          }
        }
      ]
    },
    "ResponseMappingTemplateS3Location": {
      "Fn::Sub": [
        "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/pipelineFunctions/${ResolverFileName}",
        {
          "S3DeploymentBucket": {
            "Ref": "S3DeploymentBucket"
          },
          "S3DeploymentRootKey": {
            "Ref": "S3DeploymentRootKey"
          },
          "ResolverFileName": {
            "Fn::Join": [".", ["InvokeUniqueUsernameCheckLambdaDataSource", "res", "vtl"]]
          }
        }
      ]
    }
  },
  "DependsOn": "UniqueUsernameCheckLambdaDataSource"
},
```

This one depends on the data source (Step 4) having been created earlier. You can also see that it refers to the request and response mapping templates we created.

### Step 6 - Create a Mutation Appsync Function

In Step 5, we create an appsync function that invokes the lambda function, along with the request and response mapping templates.

These request and response mapping templates are specific to the invoked lambda function only.

We now create another appsync function, which actually updates our user (ONLY IF THE UNIQUENESS CHECK PASSES).

#### Step 6A - First define the request and response mapping templates

You could define these yourselves, or you could have amplify generated these for you. The quickest way to do this is to execute `amplify mock api` - and while the mock api server is running, look into the `resolvers` folder to find a bunch of auto generated files. Since we are dealing with mutations and my mutation is named `updateUser`, I look for `Mutation.updateUser.req.vtl` and `Mutation.updateUser.res.vtl` files. On finding them, I simply open them and make a very minor edit - like adding a space or a newline at the end of the file. Terminate the mock api server - all the generated files disappear except the ones you edited. Voila! You have an auto generated mutation resolver - the best part about this is that it takes into consideration any `@auth` directives you have defined too.

With the request and response mapping templates now ready, create a file named `MutationUpdateUserFunction.req.vtl` and `MutationUpdateUserFunction.res.vtl` in the `pipelineFunctions` folder. Copy the contents of the auto generated files into these:

- Copy the contents of `Mutation.updateUser.req.vtl` into `MutationUpdateUserFunction.req.vtl`
- Copy the contents of `Mutation.updateUser.res.vtl` into `MutationUpdateUserFunction.res.vtl`
- DO NOT erase the files under `resolvers` yet - just clear the contents. We'll be updating these further below.

#### Step 6B - Now define the appsync function

Come back to the `CustomResources.json` file and proceed to define the appsync function:

```json
// CustomResources.json

"MutationUpdateUserFunction": {
  "Type": "AWS::AppSync::FunctionConfiguration",
  "Properties": {
    "ApiId": {
      "Ref": "AppSyncApiId"
    },
    "Name": "MutationUpdateUserFunction",
    "DataSourceName": "UserTable",
    "FunctionVersion": "2018-05-29",
    "RequestMappingTemplateS3Location": {
      "Fn::Sub": [
        "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/pipelineFunctions/${ResolverFileName}",
        {
          "S3DeploymentBucket": {
            "Ref": "S3DeploymentBucket"
          },
          "S3DeploymentRootKey": {
            "Ref": "S3DeploymentRootKey"
          },
          "ResolverFileName": {
            "Fn::Join": [".", ["MutationUpdateUserFunction", "req", "vtl"]]
          }
        }
      ]
    },
    "ResponseMappingTemplateS3Location": {
      "Fn::Sub": [
        "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/pipelineFunctions/${ResolverFileName}",
        {
          "S3DeploymentBucket": {
            "Ref": "S3DeploymentBucket"
          },
          "S3DeploymentRootKey": {
            "Ref": "S3DeploymentRootKey"
          },
          "ResolverFileName": {
            "Fn::Join": [".", ["MutationUpdateUserFunction", "res", "vtl"]]
          }
        }
      ]
    }
  }
},
```

Note that it refers to the request and response mapping

Step 7 - FINALE - Create the appsync pipeline

*Loki - You must be truly desperate to come to me for help* - if you have made it till this step - thank you for sticking with it.

We now bring them all together - a pipeline that will first invoke our uniqueness check and then actually carry out the mutation of the check passes.

We have to first create - you guessed it - a request and response mapping template. These are invoked when the pipeline starts and when the pipeline ends.

In the previous step, we have the `Mutation.updateUser.req.vtl` and `Mutation.updateUser.res.vtl` files under the `resolvers` folder. We are going to update the contents of these files (remember - you had copied the contents of these files to their equivalent in the `pipelineFunctions` folder).

```vtl
## resolvers/Mutation.updateUser.req.vtl

## [Start] Stash resolver specific context.. **
$util.qr($ctx.stash.put("typeName", "Mutation"))
$util.qr($ctx.stash.put("fieldName", "updateUser"))
{}
## [End] Stash resolver specific context.. **
```

```vtl
## resolvers/Mutation.updateUser.res.vtl

$util.toJson($ctx.prev.result)
```

With that out of the way, we now define our pipeline in the `CustomResources.json` file:

```json
// CustomResources.json

"MutationUpdateUserResolver": {
  "Type": "AWS::AppSync::Resolver",
  "Properties": {
    "ApiId": {
      "Ref": "AppSyncApiId"
    },
    "TypeName": "Mutation",
    "FieldName": "updateUser",
    "Kind": "PIPELINE",
    "PipelineConfig": {
      "Functions": [
        {
          "Fn::GetAtt": ["InvokeUniqueUsernameCheckLambdaDataSource", "FunctionId"]
        },
        {
          "Fn::GetAtt": ["MutationUpdateUserFunction", "FunctionId"]
        }
      ]
    },
    "RequestMappingTemplateS3Location": {
      "Fn::Sub": [
        "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}",
        {
          "S3DeploymentBucket": {
            "Ref": "S3DeploymentBucket"
          },
          "S3DeploymentRootKey": {
            "Ref": "S3DeploymentRootKey"
          },
          "ResolverFileName": {
            "Fn::Join": [".", ["Mutation", "updateUser", "req", "vtl"]]
          }
        }
      ]
    },
    "ResponseMappingTemplateS3Location": {
      "Fn::Sub": [
        "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}",
        {
          "S3DeploymentBucket": {
            "Ref": "S3DeploymentBucket"
          },
          "S3DeploymentRootKey": {
            "Ref": "S3DeploymentRootKey"
          },
          "ResolverFileName": {
            "Fn::Join": [".", ["Mutation", "updateUser", "res", "vtl"]]
          }
        }
      ]
    }
  },
  "DependsOn": [
    "MutationUpdateUserFunction",
    "InvokeUniqueUsernameCheckLambdaDataSource",
    "InvokeCalculateProfileCompletionLambdaDataSource"
  ]
}
```

That's about it!

At this point, our resources are only defined - not created. Execute `amplify push` and your resources should get created in AWS. Your `updateUser` mutation would now check for uniqueness of the `username` field.

It's bonkers that for something so simple, amplify makes one sweat it out. I had to google a lot and would like to mention 2 posts that helped me out:

This stackoverflow answer - outdated, but points in the right direction:

{% stackoverflow 54037525 %}

and this article right here on Dev.to - they talk about how to have business logic BEFORE your mutation executes. I don't think I could have figured out the above without their pointers. Cool stuff.

{% link https://dev.to/dagasatvik10/add-pipeline-resolvers-in-aws-amplify-43ae %}