Creating an API with AWS: Part 6: Nested CloudFormation Templates

In my post Creating an API with AWS: Part 5: DELETE methods, I added DELETE methods to each endpoint, to give us full CRUD (Create, Read, Update, Delete) functionality. In this post, I’ll show you how to break up the (rather cumbersome now) CloudFormation template into several nested CloudFormation templates, to make them easier to manage.

Prerequisites

Before you start, you’ll need the following things:

  1. An AWS account
  2. An Administrator IAM User with which to use the AWS CLI
  3. AWS CLI set up on your local machine

Note that AWS will charge you for creating and hosting this API if you don’t have Free Tier on your account.

Step 1: Create an S3 Bucket

To be able to use nested CloudFormation templates, it is necessary to create an s3 bucket with AWS to store the nested templates. Choose a unique bucket name and replace ‘<YOUR_UNIQUE_BUCKET_NAME>’ below, before running:

$ export $BUCKET=<YOUR_UNIQUE_BUCKET_NAME>
$ aws s3api create-bucket --bucket $BUCKET

Step 2: Create a New CloudFormation Parent Template

Create a new CloudFormation template called nested.yml. This will be the parent CloudFormation template, that will launch, in turn, the child CloudFormation templates. The contents should be as below:

AWSTemplateFormatVersion: 2010-09-09
Description: Creates a simple API with three endpoints that can be called to POST/GET/DELETE links and tags and tag links, with a DynamoDB database
Parameters:
  BucketName:
    Description: Name of the s3 bucket
    Type: String
Resources:
  DatabaseStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: !Join
        - ''
        - - 'https://'
          - !Ref BucketName
          - '.s3.amazonaws.com'
          - '/database.yml'
          
  CognitoStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: !Join
        - ''
        - - 'https://'
          - !Ref BucketName
          - '.s3.amazonaws.com'
          - '/cognito.yml'
      
  LambdaStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: !Join
        - ''
        - - 'https://'
          - !Ref BucketName
          - '.s3.amazonaws.com'
          - '/lambda.yml'
      
  APIStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: !Join
        - ''
        - - 'https://'
          - !Ref BucketName
          - '.s3.amazonaws.com'
          - '/api.yml'
      Parameters:
        LambdaARN: !GetAtt LambdaStack.Outputs.LambdaARN
        UserPoolARN: !GetAtt CognitoStack.Outputs.UserPoolARN
    DependsOn:
      - CognitoStack
      - LambdaStack
    
Outputs:
  APIBaseUrl:
    Description: Application API Gateway base URL
    Value: !GetAtt APIStack.Outputs.APIBaseUrl

  APILinkUrl:
    Description: Application API Gateway link endpoint URL
    Value: !GetAtt APIStack.Outputs.APILinkUrl

  APITagUrl:
    Description: Application API Gateway tag endpoint URL
    Value: !GetAtt APIStack.Outputs.APITagUrl

  APITagLinkUrl:
    Description: Application API Gateway taglink endpoint URL
    Value: !GetAtt APIStack.Outputs.APITagLinkUrl

  UserPoolID:
    Description: The Cognito user pool ID
    Value: !GetAtt CognitoStack.Outputs.UserPoolID

  APIClientID:
    Description: The API user pool client ID
    Value: !GetAtt CognitoStack.Outputs.APIClientID

Note that instead of creating individual resources such as a Lambda, APIGateway etc, this template creates other CloudFormation stacks as resources, and they will create the individual resources for each stack. This means we can organise and separate different groups of resources into different CloudFormation templates, making them a bit easier to manage. The outputs for the parent stack are derived from the outputs of each nested stack.

Step 3: Create the Database CloudFormation Template

Create a CFT file called database.yml which contains the database resources as below:

AWSTemplateFormatVersion: "2010-09-09"
Description: Creates three DynamoDB database tables - linkTable, tagTable, tagLinkTable
Resources:
    linkTable:
        Type: AWS::DynamoDB::Table
        Properties:
          AttributeDefinitions:
            -
              AttributeName: "link_id"
              AttributeType: "S"
            -
              AttributeName: "link"
              AttributeType: "S"
          KeySchema:
            -
              AttributeName: "link_id"
              KeyType: "HASH"
            -
              AttributeName: "link"
              KeyType: "RANGE"
          ProvisionedThroughput:
            ReadCapacityUnits: "5"
            WriteCapacityUnits: "5"
          TableName: "linkTable"
          GlobalSecondaryIndexes:
            -
              IndexName: "link_index"
              KeySchema:
                -
                  AttributeName: "link"
                  KeyType: "HASH"
                -
                  AttributeName: "link_id"
                  KeyType: "RANGE"
              Projection:
                ProjectionType: "KEYS_ONLY"
              ProvisionedThroughput:
                ReadCapacityUnits: "5"
                WriteCapacityUnits: "5"
          
    tagTable:
        Type: AWS::DynamoDB::Table
        Properties:
          AttributeDefinitions:
            -
              AttributeName: "tag_id"
              AttributeType: "S"
            -
              AttributeName: "tag"
              AttributeType: "S"
          KeySchema:
            -
              AttributeName: "tag_id"
              KeyType: "HASH"
            -
              AttributeName: "tag"
              KeyType: "RANGE"
          ProvisionedThroughput:
            ReadCapacityUnits: "5"
            WriteCapacityUnits: "5"
          TableName: "tagTable"
          GlobalSecondaryIndexes:
            -
              IndexName: "tag_index"
              KeySchema:
                -
                  AttributeName: "tag"
                  KeyType: "HASH"
                -
                  AttributeName: "tag_id"
                  KeyType: "RANGE"
              Projection:
                ProjectionType: "KEYS_ONLY"
              ProvisionedThroughput:
                ReadCapacityUnits: "5"
                WriteCapacityUnits: "5"

          
    tagLinkTable:
        Type: AWS::DynamoDB::Table
        Properties:
          AttributeDefinitions:
            -
              AttributeName: "tag_id"
              AttributeType: "S"
            -
              AttributeName: "link_id"
              AttributeType: "S"
          KeySchema:
            -
              AttributeName: "tag_id"
              KeyType: "HASH"
            -
              AttributeName: "link_id"
              KeyType: "RANGE"
          ProvisionedThroughput:
            ReadCapacityUnits: "5"
            WriteCapacityUnits: "5"
          TableName: "tagLinkTable"
          GlobalSecondaryIndexes:
            -
              IndexName: "link_id_index"
              KeySchema:
                -
                  AttributeName: "link_id"
                  KeyType: "HASH"
                -
                  AttributeName: "tag_id"
                  KeyType: "RANGE"
              Projection:
                ProjectionType: "KEYS_ONLY"
              ProvisionedThroughput:
                ReadCapacityUnits: "5"
                WriteCapacityUnits: "5"

Step 4: Create the Cognito CloudFormation Template

Create a CFT file called cognito.yml which contains the Cognito resources as below:

AWSTemplateFormatVersion: "2010-09-09"
Description: Creates a Cognito User Pool and a Cognito User Pool Client
Resources:
    apiuserpool:
      Type: AWS::Cognito::UserPool
      Properties:
        AdminCreateUserConfig:
          AllowAdminCreateUserOnly: true
        AutoVerifiedAttributes:
          - email
        UsernameAttributes:
          - email
        UsernameConfiguration:
          CaseSensitive: false
        UserPoolName: apiuserpool

    applicationAPIUserPoolClient:
      Type: AWS::Cognito::UserPoolClient
      Properties:
        AccessTokenValidity: 1
        ClientName: applicationapiclient
        EnableTokenRevocation: true
        ExplicitAuthFlows:
          - ALLOW_USER_PASSWORD_AUTH
          - ALLOW_REFRESH_TOKEN_AUTH
          - ALLOW_ADMIN_USER_PASSWORD_AUTH
        GenerateSecret: true
        IdTokenValidity: 1
        PreventUserExistenceErrors: ENABLED
        RefreshTokenValidity: 1
        SupportedIdentityProviders:
          - COGNITO
        UserPoolId: !Ref apiuserpool
      DependsOn:
            - apiuserpool
            
Outputs:
    UserPoolID:
        Description: The Cognito user pool ID
        Value: !Ref apiuserpool
        
    UserPoolARN:
        Description: The Cognito user pool ARN
        Value: !GetAtt apiuserpool.Arn
      
    APIClientID:
        Description: The API user pool client ID
        Value: !Ref applicationAPIUserPoolClient

Step 5: Create the Lambda CloudFormation Template

Create a CFT file called lambda.yml which contains the Lambda function resources as below:

AWSTemplateFormatVersion: "2010-09-09"
Description: Creates a Lambda Function and Lambda Execution Role
Resources:
    requestHandlerLambdaExecutionRole:
        Type: AWS::IAM::Role
        Properties: 
          AssumeRolePolicyDocument:
            Version: "2012-10-17"
            Statement:
                - 
                    Effect: Allow
                    Principal:
                        Service:
                            - "lambda.amazonaws.com"
                    Action:
                        - "sts:AssumeRole"
          Path: "/"
          Policies:
            - 
                PolicyName: "root"
                PolicyDocument:
                    Version: "2012-10-17"
                    Statement:
                        - 
                            Effect: "Allow"
                            Action:
                                - logs:CreateLogGroup
                                - logs:PutLogEvents
                                - logs:CreateLogStream
                            Resource: "*"
            -
                PolicyName: "db_access"
                PolicyDocument:
                    Version: "2012-10-17"
                    Statement:
                        -
                            Effect: "Allow"
                            Action:
                                - dynamodb:DeleteItem
                                - dynamodb:GetItem
                                - dynamodb:PutItem
                                - dynamodb:Scan
                                - dynamodb:UpdateItem
                                - dynamodb:Query
                            Resource: !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/*"
          RoleName: requestHandlerLambdaExecutionRole


    requestHandler:
        Type: "AWS::Lambda::Function"
        Properties: 
          Code:
            ZipFile: !Sub |
                import json
                import boto3
                import uuid

                from botocore.exceptions import ClientError

                # Establish credentials
                session_var = boto3.session.Session()
                credentials = session_var.get_credentials()

                """ --- Parameters --- """
                # DynamoDB tables, read lambda function environment variables or initialize with defaults if they not exist.
                DYNAMODB_LINK_TABLE = 'linkTable'
                DYNAMODB_TAG_TABLE = 'tagTable'
                DYNAMODB_TAG_LINK_TABLE = 'tagLinkTable'

                # Initialize DynamoDB Client
                dynamodb = boto3.client('dynamodb')


                def store_link(link):
                    link_id = str(uuid.uuid4())
                    dynamodb.put_item(
                        TableName=DYNAMODB_LINK_TABLE,
                        Item={
                            'link_id': {
                                'S': str(link_id)
                            },
                            'link': {
                                'S': str(link)
                            }
                        }
                    )

                    return link_id


                def store_tag(tag):
                    tag_id = str(uuid.uuid4())
                    dynamodb.put_item(
                        TableName=DYNAMODB_TAG_TABLE,
                        Item={
                            'tag_id': {
                                'S': str(tag_id)
                            },
                            'tag': {
                                'S': str(tag)
                            }
                        }
                    )

                    return tag_id


                def store_taglink(tag_id, link_id):
                    dynamodb.put_item(
                        TableName=DYNAMODB_TAG_LINK_TABLE,
                        Item={
                            'tag_id': {
                                'S': str(tag_id)
                            },
                            'link_id': {
                                'S': str(link_id)
                            }
                        }
                    )


                def get_link_id(link):
                    result = dynamodb.query(
                        TableName=DYNAMODB_LINK_TABLE,
                        IndexName='link_index',
                        KeyConditionExpression='link = :link',
                        ExpressionAttributeValues={
                            ':link': {
                                "S": link
                            }
                        }
                    )

                    if result['Count'] > 0:
                        link_id = result['Items'][0]['link_id']['S']
                    else:
                        link_id = store_link(link)

                    return link_id


                def get_tag_id(tag):
                    result = dynamodb.query(
                        TableName=DYNAMODB_TAG_TABLE,
                        IndexName='tag_index',
                        KeyConditionExpression='tag = :tag',
                        ExpressionAttributeValues={
                            ':tag': {
                                "S": tag
                            }
                        }
                    )

                    if result['Count'] > 0:
                        tag_id = result['Items'][0]['tag_id']['S']
                    else:
                        tag_id = store_tag(tag)

                    return tag_id


                def get_tags(tag_id=None):
                    tags = []

                    if tag_id is None:
                        result = dynamodb.scan(
                            TableName=DYNAMODB_TAG_TABLE,
                            Select="SPECIFIC_ATTRIBUTES",
                            ProjectionExpression="tag_id,tag"
                        )
                    else:
                        # Run a query with a filter on tag_id on the tagTable

                        # Get IDs for each keyword
                        result = dynamodb.query(
                            TableName=DYNAMODB_TAG_TABLE,
                            KeyConditionExpression='tag_id = :tag_id',
                            ExpressionAttributeValues={
                                ':tag_id': {
                                    "S": tag_id
                                }
                            }
                        )

                    if result['Count'] > 0:
                        for x in range(result['Count']):
                            tag = {}
                            tag['tag_id'] = result['Items'][x]['tag_id']['S']
                            tag['tag'] = result['Items'][x]['tag']['S']
                            tags.append(tag)

                    return tags


                def extract_link_result(result):
                    links = []
                    if result['Count'] > 0:
                        for x in range(result['Count']):
                            link = {}
                            link['link_id'] = result['Items'][x]['link_id']['S']
                            link['link'] = result['Items'][x]['link']['S']
                            links.append(link)

                    return links


                def get_links(tag_id=None, link_id=None):
                    links = []
                    link_id_list = []

                    if tag_id is None and link_id is None:
                        result = dynamodb.scan(
                            TableName=DYNAMODB_LINK_TABLE,
                            Select="SPECIFIC_ATTRIBUTES",
                            ProjectionExpression="link_id,link"
                        )

                        links = extract_link_result(result)

                    elif link_id is not None:
                        # Run a query with a filter on link_id on the linkTable
                        result = dynamodb.query(
                            TableName=DYNAMODB_LINK_TABLE,
                            KeyConditionExpression='link_id = :link_id',
                            ExpressionAttributeValues={
                                ':link_id': {
                                    "S": link_id
                                }
                            }
                        )

                        links = extract_link_result(result)
                    else:
                        # Run a query with a filter on tag_id on the tagLinkTable
                        result = dynamodb.query(
                            TableName=DYNAMODB_TAG_LINK_TABLE,
                            KeyConditionExpression='tag_id = :tag_id',
                            ExpressionAttributeValues={
                                ':tag_id': {
                                    "S": tag_id
                                }
                            }
                        )

                        if result['Count'] > 0:
                            for x in range(result['Count']):
                                link_id_list.append(result['Items'][x]['link_id']['S'])

                        # From the retrieved link_ids, retrieve the links from the link table
                        if len(link_id_list) > 0:
                            for link_id in link_id_list:
                                result = dynamodb.query(
                                    TableName=DYNAMODB_LINK_TABLE,
                                    KeyConditionExpression='link_id = :link_id',
                                    ExpressionAttributeValues={
                                        ':link_id': {
                                            "S": link_id
                                        }
                                    }
                                )
                                if result['Count'] > 0:
                                    link = {}
                                    link['link_id'] = link_id
                                    # There should only be one result per link_id
                                    link['link'] = result['Items'][0]['link']['S']
                                    links.append(link)

                    return links


                def get_taglinks(tag_id=None, link_id=None):
                    taglinks = []

                    if tag_id is not None and link_id is None:
                        # Run a query with a filter on tag_id on the tagLinkTable
                        result = dynamodb.query(
                            TableName=DYNAMODB_TAG_LINK_TABLE,
                            KeyConditionExpression='tag_id = :tag_id',
                            ExpressionAttributeValues={
                                ':tag_id': {
                                    "S": tag_id
                                }
                            }
                        )

                        if result['Count'] > 0:
                            for x in range(result['Count']):
                                taglink = {}
                                taglink['tag_id'] = tag_id
                                taglink['link_id'] = result['Items'][x]['link_id']['S']
                                taglinks.append(taglink)

                    elif link_id is not None and tag_id is None:
                        # Run a query with a filter on link_id on the tagLinkTable
                        result = dynamodb.query(
                            TableName=DYNAMODB_TAG_LINK_TABLE,
                            IndexName='link_id_index',
                            KeyConditionExpression='link_id = :link_id',
                            ExpressionAttributeValues={
                                ':link_id': {
                                    "S": link_id
                                }
                            }
                        )

                        if result['Count'] > 0:
                            for x in range(result['Count']):
                                taglink = {}
                                taglink['link_id'] = link_id
                                taglink['tag_id'] = result['Items'][x]['tag_id']['S']
                                taglinks.append(taglink)
                    elif link_id is not None and tag_id is not None:
                        result = dynamodb.get_item(
                            TableName=DYNAMODB_TAG_LINK_TABLE,
                            Key={
                                'link_id': {
                                    "S": link_id
                                },
                                'tag_id': {
                                    "S": tag_id
                                }
                            }
                        )

                        if result:
                            taglink = {}
                            taglink['link_id'] = result['Item']['link_id']['S']
                            taglink['tag_id'] = result['Item']['tag_id']['S']
                            taglinks.append(taglink)

                    return taglinks


                def delete_link(link_id):
                    links = get_links(tag_id=None, link_id=link_id)

                    for link in links:
                        dynamodb.delete_item(
                            TableName=DYNAMODB_LINK_TABLE,
                            Key={
                                'link_id': {
                                    "S": link['link_id']
                                },
                                'link': {
                                    "S": link['link']
                                }
                            }
                        )

                    return


                def delete_tag(tag_id):
                    tags = get_tags(tag_id=tag_id)

                    for tag in tags:
                        dynamodb.delete_item(
                            TableName=DYNAMODB_TAG_TABLE,
                            Key={
                                'tag_id': {
                                    "S": tag['tag_id'],
                                },
                                'tag': {
                                    "S": tag['tag']
                                }
                            }
                        )

                    return


                def delete_taglinks(tag_id=None, link_id=None):
                    taglinks = get_taglinks(tag_id=tag_id, link_id=link_id)

                    for taglink in taglinks:
                        dynamodb.delete_item(
                            TableName=DYNAMODB_TAG_LINK_TABLE,
                            Key={
                                'tag_id': {
                                    "S": taglink['tag_id'],
                                },
                                'link_id': {
                                    "S": taglink['link_id']
                                },
                            }
                        )

                    return


                def error_response(error_message, aws_request_id):
                    return {
                        "statusCode": 500,
                        "isBase64Encoded": False,
                        "body": json.dumps({
                            "Error": error_message,
                            "Reference": aws_request_id,
                        }),
                        "headers": {
                            'Access-Control-Allow-Origin': '*',
                        },
                    }


                def bad_request_response(error_message, aws_request_id):
                    return {
                        "statusCode": 400,
                        "isBase64Encoded": False,
                        "body": json.dumps({
                            "Error": error_message,
                            "Reference": aws_request_id,
                        }),
                        "headers": {
                            'Access-Control-Allow-Origin': '*',
                        },
                    }


                def lambda_handler(event, context):
                    try:
                        print(event)
                        print(context)
                        return_body = ""
                        if event['resource'] == '/link':
                            if event['httpMethod'] == "POST":
                                post_body = json.loads(event['body'])
                                link = post_body['link']
                                tag = post_body.get('tag')
                                tag_id = post_body.get('tag_id')
                                if tag_id is None:
                                    tag_id = get_tag_id(tag)
                                else:
                                    tags = get_tags(tag_id)
                                    if not tags:
                                        return bad_request_response('Unknown tag_id ' + tag_id, context.aws_request_id)
                                link_id = get_link_id(link)
                                store_taglink(tag_id, link_id)
                                return_body = json.dumps({"link_id": link_id})
                            elif event['httpMethod'] == "GET":
                                tag_id = None
                                link_id = None
                                query_params = event['queryStringParameters']
                                if query_params is not None:
                                    tag_id = query_params.get('tag_id', None)
                                    tag = query_params.get('tag', None)
                                    link_id = query_params.get('link_id', None)
                                    if tag_id is None and tag is not None:
                                        tag_id = get_tag_id(tag)
                                links = get_links(tag_id, link_id)
                                return_body = json.dumps(links)
                            elif event['httpMethod'] == "DELETE":
                                query_params = event['queryStringParameters']
                                link_id = query_params.get('link_id', None)
                                if not link_id:
                                    return bad_request_response('link_id is required', context.aws_request_id)
                                delete_link(link_id)
                                delete_taglinks(tag_id=None, link_id=link_id)
                                return_body = json.dumps({"link_id": link_id})

                        elif event['resource'] == '/tag':
                            if event['httpMethod'] == "POST":
                                post_body = json.loads(event['body'])
                                tag = post_body.get('tag')
                                tag_id = get_tag_id(tag)
                                return_body = json.dumps({"tag_id": tag_id})
                            elif event['httpMethod'] == "GET":
                                tag_id = None
                                query_params = event['queryStringParameters']
                                if query_params is not None:
                                    tag_id = query_params.get('tag_id', None)
                                    tag = query_params.get('tag', None)
                                    if tag_id is None and tag is not None:
                                        tag_id = get_tag_id(tag)
                                tags = get_tags(tag_id)
                                return_body = json.dumps(tags)
                            elif event['httpMethod'] == "DELETE":
                                query_params = event['queryStringParameters']
                                tag_id = query_params.get('tag_id', None)
                                if not tag_id:
                                    return bad_request_response('tag_id is required', context.aws_request_id)
                                delete_tag(tag_id)
                                delete_taglinks(tag_id=tag_id, link_id=None)
                                return_body = json.dumps({"tag_id": tag_id})
                        elif event['resource'] == '/taglink':
                            if event['httpMethod'] == "POST":
                                post_body = json.loads(event['body'])
                                tag_id = post_body.get('tag_id')
                                link_id = post_body.get('link_id')
                                tags = get_tags(tag_id)
                                links = get_links(tag_id=None, link_id=link_id)
                                if not tags:
                                    return bad_request_response('Unknown tag_id ' + tag_id, context.aws_request_id)
                                if not links:
                                    return bad_request_response('Unknown link_id ' + link_id, context.aws_request_id)
                                store_taglink(tag_id, link_id)
                                return_body = json.dumps({"tag_id": tag_id, "link_id": link_id})
                            elif event['httpMethod'] == "DELETE":
                                query_params = event['queryStringParameters']
                                tag_id = query_params.get('tag_id', None)
                                link_id = query_params.get('link_id', None)
                                if not tag_id:
                                    return bad_request_response('tag_id is required', context.aws_request_id)
                                if not link_id:
                                    return bad_request_response('link_id is required', context.aws_request_id)
                                delete_taglinks(tag_id, link_id)
                                return_body = json.dumps({"tag_id": tag_id, "link_id": link_id})
                        # Because this Lambda function is called by an API Gateway proxy integration
                        # the result object must use the following structure.
                        return {
                            "statusCode": 200,
                            "isBase64Encoded": False,
                            "body": return_body,
                            "headers": {
                                'Access-Control-Allow-Origin': '*',
                            },
                        }
                    except Exception as err:
                        print(str(err))

                        # If there is an error during processing, catch it and return
                        # from the Lambda function successfully. Specify a 500 HTTP status
                        # code and provide an error message in the body. This will provide a
                        # more meaningful error response to the end client.
                        return error_response(str(err), context.aws_request_id)


          Description: Lambda function for processing api endpoint requests
          FunctionName: requestHandler
          Handler: index.lambda_handler
          Role: 
            Fn::GetAtt: 
            - "requestHandlerLambdaExecutionRole"
            - "Arn"
          Runtime: python3.8
        DependsOn:
            - requestHandlerLambdaExecutionRole
            
Outputs:
    LambdaARN:
        Description: The ARN of the lambda function
        Value: !GetAtt requestHandler.Arn

Step 6: Create the API CloudFormation Template

Create a CFT file called api.yml which contains the API Gateway and endpoint resources as below:

AWSTemplateFormatVersion: "2010-09-09"
Description: Creates an API Gateway resource with three endpoints (link, tag, taglink) and methods GET, POST, DELETE and OPTIONS
Parameters:
  LambdaARN:
    Description: ARN of the Lambda Function
    Type: String
  UserPoolARN:
    Description: ARN of the Cognito User Pool
    Type: String
    
Resources:
    ApplicationAPI:
        Type: AWS::ApiGateway::RestApi
        Properties:
            Name: ApplicationAPI
            Description: Simple API for an application
            
    LambdaPermission:
        Type: AWS::Lambda::Permission
        Properties:
            FunctionName: !Ref LambdaARN
            Action: 'lambda:InvokeFunction'
            Principal: apigateway.amazonaws.com
            SourceArn: !Join ["", [!Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:", !Ref ApplicationAPI, "/*"]]
        DependsOn:
            - ApplicationAPI
            
    link:
        Type: AWS::ApiGateway::Resource
        Properties:
            RestApiId: !Ref ApplicationAPI
            ParentId: !GetAtt [ApplicationAPI, RootResourceId]
            PathPart: 'link'
        DependsOn: 
            - ApplicationAPI

    tag:
        Type: AWS::ApiGateway::Resource
        Properties:
            RestApiId: !Ref ApplicationAPI
            ParentId: !GetAtt [ApplicationAPI, RootResourceId]
            PathPart: 'tag'
        DependsOn:
            - ApplicationAPI

    taglink:
        Type: AWS::ApiGateway::Resource
        Properties:
            RestApiId: !Ref ApplicationAPI
            ParentId: !GetAtt [ApplicationAPI, RootResourceId]
            PathPart: 'taglink'
        DependsOn:
            - ApplicationAPI

    apiauth:
      Type: AWS::ApiGateway::Authorizer
      Properties:
        IdentitySource: method.request.header.Authorization
        Name: apiauth
        ProviderARNs:
          - !Ref UserPoolARN
        RestApiId: !Ref ApplicationAPI
        Type: COGNITO_USER_POOLS
    
    ProxyResourceLinkGET:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref link
            HttpMethod: GET     
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaARN}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                - 
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn: 
            - ApplicationAPI
            - apiauth
            - link
            
    ProxyResourceLinkPOST:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref link
            HttpMethod: POST   
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaARN}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                - 
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn: 
            - ProxyResourceLinkGET
            - apiauth

    ProxyResourceLinkDELETE:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref link
            HttpMethod: DELETE
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaARN}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn:
            - ProxyResourceLinkPOST
            - apiauth
              
    OptionsMethodLink:
        Type: AWS::ApiGateway::Method
        Properties:
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref link
            HttpMethod: OPTIONS
            Integration:
                IntegrationResponses:
                    - 
                        StatusCode: 200
                        ResponseParameters:
                            method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
                            method.response.header.Access-Control-Allow-Methods: "'GET,POST,OPTIONS'"
                            method.response.header.Access-Control-Allow-Origin: "'*'"
                        ResponseTemplates:
                            application/json: ''
                PassthroughBehavior: NEVER
                RequestTemplates:
                    application/json: '{"statusCode": 200}'
                Type: MOCK
            MethodResponses:
                - 
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
                    ResponseParameters:
                        method.response.header.Access-Control-Allow-Headers: true
                        method.response.header.Access-Control-Allow-Methods: true
                        method.response.header.Access-Control-Allow-Origin: true
        DependsOn: 
            - ProxyResourceLinkDELETE
            - apiauth

    ProxyResourceTagGET:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref tag
            HttpMethod: GET
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaARN}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn:
            - ProxyResourceLinkPOST
            - apiauth

    ProxyResourceTagPOST:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref tag
            HttpMethod: POST
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaARN}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn:
            - ProxyResourceTagGET
            - apiauth

    ProxyResourceTagDELETE:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref tag
            HttpMethod: DELETE
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaARN}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn:
            - ProxyResourceTagPOST
            - apiauth

    OptionsMethodTag:
        Type: AWS::ApiGateway::Method
        Properties:
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref tag
            HttpMethod: OPTIONS
            Integration:
                IntegrationResponses:
                    -
                        StatusCode: 200
                        ResponseParameters:
                            method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
                            method.response.header.Access-Control-Allow-Methods: "'GET,POST,OPTIONS'"
                            method.response.header.Access-Control-Allow-Origin: "'*'"
                        ResponseTemplates:
                            application/json: ''
                PassthroughBehavior: NEVER
                RequestTemplates:
                    application/json: '{"statusCode": 200}'
                Type: MOCK
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
                    ResponseParameters:
                        method.response.header.Access-Control-Allow-Headers: true
                        method.response.header.Access-Control-Allow-Methods: true
                        method.response.header.Access-Control-Allow-Origin: true
        DependsOn:
            - ProxyResourceTagDELETE
            - apiauth

    ProxyResourceTagLinkGET:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref taglink
            HttpMethod: GET
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaARN}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn:
            - ProxyResourceTagPOST
            - apiauth

    ProxyResourceTagLinkPOST:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref taglink
            HttpMethod: POST
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaARN}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn:
            - ProxyResourceTagLinkGET
            - apiauth

    ProxyResourceTagLinkDELETE:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref taglink
            HttpMethod: DELETE
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaARN}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn:
            - ProxyResourceTagLinkPOST
            - apiauth

    OptionsMethodTagLink:
        Type: AWS::ApiGateway::Method
        Properties:
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref taglink
            HttpMethod: OPTIONS
            Integration:
                IntegrationResponses:
                    -
                        StatusCode: 200
                        ResponseParameters:
                            method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
                            method.response.header.Access-Control-Allow-Methods: "'GET,POST,OPTIONS'"
                            method.response.header.Access-Control-Allow-Origin: "'*'"
                        ResponseTemplates:
                            application/json: ''
                PassthroughBehavior: NEVER
                RequestTemplates:
                    application/json: '{"statusCode": 200}'
                Type: MOCK
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
                    ResponseParameters:
                        method.response.header.Access-Control-Allow-Headers: true
                        method.response.header.Access-Control-Allow-Methods: true
                        method.response.header.Access-Control-Allow-Origin: true
        DependsOn:
            - ProxyResourceTagLinkDELETE
            - apiauth
                
    Deployment: 
      DependsOn: "OptionsMethodTagLink"
      Type: AWS::ApiGateway::Deployment
      Properties: 
        RestApiId: 
          Ref: "ApplicationAPI"
        Description: "Application API deployment"
        StageName: "prod"
        
Outputs:
  APIBaseUrl:
    Description: Application API Gateway base URL
    Value: !Join
        - ''
        - - 'https://'
          - !Ref ApplicationAPI
          - '.execute-api.'
          - !Ref AWS::Region
          - '.amazonaws.com/prod'

  APILinkUrl:
    Description: Application API Gateway link endpoint URL
    Value: !Join
        - ''
        - - 'https://'
          - !Ref ApplicationAPI
          - '.execute-api.'
          - !Ref AWS::Region
          - '.amazonaws.com/prod/link'

  APITagUrl:
    Description: Application API Gateway tag endpoint URL
    Value: !Join
        - ''
        - - 'https://'
          - !Ref ApplicationAPI
          - '.execute-api.'
          - !Ref AWS::Region
          - '.amazonaws.com/prod/tag'

  APITagLinkUrl:
    Description: Application API Gateway taglink endpoint URL
    Value: !Join
        - ''
        - - 'https://'
          - !Ref ApplicationAPI
          - '.execute-api.'
          - !Ref AWS::Region
          - '.amazonaws.com/prod/taglink'

Step 7: Upload the Child CloudFormation Templates to the S3 Bucket

Upload the child CFTs to the s3 bucket with the following commands:

$ aws s3 cp api.yml s3://$BUCKET
$ aws s3 cp cognito.yml s3://$BUCKET
$ aws s3 cp database.yml s3://$BUCKET
$ aws s3 cp lambda.yml s3://$BUCKET

Step 8: Create the test.sh script

If you haven’t created the test.sh script (it should be the same as in Creating an API with AWS: Part 5: DELETE methods), create it with the following code:

BASEURL=$1

echo "=========================="
echo "Test POST /link link=https://www.allthecoding.com, tag=blog"
echo $HEADERS
resp=$(curl -X "POST" -H "Content-Type: application/json" -H "Authorization: $TOKEN" -d "{\"link\": \"https://www.allthecoding.com\", \"tag\": \"blog\"}" $BASEURL/link)
echo $resp
link_id1=$(echo $resp | jq -r '.link_id')
echo $link_id1

echo "=========================="
echo "Test GET /tag tag=blog"
resp=$(curl -X "GET" -H "Content-Type: application/json" -H "Authorization: $TOKEN" $BASEURL/tag?tag=blog)
echo $resp
tag_id1=$(echo $resp | jq -r '.[0].tag_id')
echo $tag_id1

echo "=========================="
echo "Test POST /tag tag=stuff"
resp=$(curl -X "POST" -H "Content-Type: application/json" -H "Authorization: $TOKEN" -d "{\"tag\": \"stuff\"}" $BASEURL/tag)
echo $resp
tag_id2=$(echo $resp | jq -r '.tag_id')
echo $tag_id2

echo "=========================="
echo "Test POST /link link=https://www.google.com, tag=stuff"
resp=$(curl -X "POST" -H "Content-Type: application/json" -H "Authorization: $TOKEN" -d "{\"link\": \"https://www.google.com\", \"tag\": \"stuff\"}" $BASEURL/link)
echo $resp
link_id2=$(echo $resp | jq -r '.link_id')
echo $link_id2

echo "=========================="
echo "Test POST /link link=https://www.google.com, tag_id=$tag_id1"
resp=$(curl -X "POST" -H "Content-Type: application/json" -H "Authorization: $TOKEN" -d "{\"link\": \"https://www.google.com\", \"tag_id\": \"$tag_id1\"}" $BASEURL/link)
echo $resp
link_id2=$(echo $resp | jq -r '.link_id')
echo $link_id2

echo "=========================="
echo "Test POST /link link=https://www.google.com, tag_id=invalid". Expect 400 response: Unknown tag_id invalid
resp=$(curl -X "POST" -H "Content-Type: application/json" -H "Authorization: $TOKEN" -d "{\"link\": \"https://www.google.com\", \"tag_id\": \"invalid\"}" $BASEURL/link)
echo $resp

echo "=========================="
echo "Test GET /link"
resp=$(curl -X "GET" -H "Content-Type: application/json" -H "Authorization: $TOKEN" $BASEURL/link)
echo $resp

echo "=========================="
echo "Test GET /link tag_id=$tag_id1"
resp=$(curl -X "GET" -H "Content-Type: application/json" -H "Authorization: $TOKEN" $BASEURL/link?tag_id=$tag_id1)
echo $resp

echo "=========================="
echo "Test GET /link tag=blog"
resp=$(curl -X "GET" -H "Content-Type: application/json" -H "Authorization: $TOKEN" $BASEURL/link?tag=blog)
echo $resp

echo "=========================="
echo "Test GET /link link_id=$link_id1"
resp=$(curl -X "GET" -H "Content-Type: application/json" -H "Authorization: $TOKEN" $BASEURL/link?link_id=$link_id1)
echo $resp

echo "=========================="
echo "Test GET /tag tag_id=$tag_id1"
resp=$(curl -X "GET" -H "Content-Type: application/json" -H "Authorization: $TOKEN" $BASEURL/tag?tag_id=$tag_id1)
echo $resp

echo "=========================="
echo "Test GET /tag"
resp=$(curl -X "GET" -H "Content-Type: application/json" -H "Authorization: $TOKEN" $BASEURL/tag)
echo $resp

echo "=========================="
echo "Test POST /taglink tag_id=$tag_id1, link_id=$link_id1"
resp=$(curl -X "POST" -H "Content-Type: application/json" -H "Authorization: $TOKEN" -d "{\"link_id\": \"$link_id1\", \"tag_id\": \"$tag_id1\"}" $BASEURL/taglink)
echo $resp

echo "=========================="
echo "Test POST /taglink tag_id=invalid, link_id=$link_id1. Expect 400 response: Unknown tag_id invalid"
resp=$(curl -X "POST" -H "Content-Type: application/json" -H "Authorization: $TOKEN" -d "{\"link_id\": \"$link_id1\", \"tag_id\": \"invalid\"}" $BASEURL/taglink)
echo $resp

echo "=========================="
echo "Test POST /taglink tag_id=$tag_id1, link_id=invalid. Expect 400 response: Unknown link_id invalid"
resp=$(curl -X "POST" -H "Content-Type: application/json" -H "Authorization: $TOKEN" -d "{\"link_id\": \"invalid\", \"tag_id\": \"$tag_id1\"}" $BASEURL/taglink)
echo $resp

echo "=========================="
echo "Test DELETE /link link_id=$link_id1"
resp=$(curl -X "DELETE" -H "Content-Type: application/json" -H "Authorization: $TOKEN" $BASEURL/link?link_id=$link_id1)
echo $resp

echo "=========================="
echo "Test DELETE /tag tag_id=$tag_id1"
resp=$(curl -X "DELETE" -H "Content-Type: application/json" -H "Authorization: $TOKEN" $BASEURL/tag?tag_id=$tag_id1)
echo $resp

echo "=========================="
echo "Test DELETE /taglink tag_id=$tag_id2, link_id=$link_id2"
resp=$(curl -X "DELETE" -H "Content-Type: application/json" -H "Authorization: $TOKEN" "$BASEURL/taglink?tag_id=$tag_id2&link_id=$link_id2")
echo $resp

Step 9: Create an authentication.sh script

If you haven’t created the authentication.sh script (it should be the same as in Creating an API with AWS: Part 5: DELETE methods), create it with the code below:

EMAIL=$1
PASSWORD=$2

aws cognito-idp admin-create-user --user-pool-id $POOL_ID --username $EMAIL
aws cognito-idp admin-set-user-password --user-pool-id $POOL_ID --username $EMAIL --password $PASSWORD --permanent

resp=$(aws cognito-idp describe-user-pool-client --client-id $CLIENT_ID --user-pool-id $POOL_ID)

CLIENT_SECRET=$(echo $resp | jq -r '.UserPoolClient.ClientSecret')

SECRET_HASH=$(python get_client_secret.py $EMAIL $CLIENT_ID $CLIENT_SECRET | awk -F: '{print $2}' | xargs)

resp=$(aws cognito-idp initiate-auth --region us-east-1 --auth-flow USER_PASSWORD_AUTH --client-id $CLIENT_ID --auth-parameters USERNAME=$EMAIL,PASSWORD=$PASSWORD,SECRET_HASH=$SECRET_HASH)

TOKEN=$(echo $resp | jq -r '.AuthenticationResult.IdToken')

echo $TOKEN
export TOKEN=$TOKEN

Create a python script called get_client_secret.py (if you haven’t already) and add the following code:

import sys
import hmac, hashlib, base64

username = sys.argv[1]
app_client_id = sys.argv[2]
key = sys.argv[3]
message = bytes(sys.argv[1]+sys.argv[2], 'utf-8')
key = bytes(sys.argv[3], 'utf-8')
secret_hash = base64.b64encode(hmac.new(key, message, digestmod=hashlib.sha256).digest()).decode()

print("SECRET HASH:", secret_hash)

Step 10: Launch the CloudFormation Template

Launch the nested CFT with this command:

$ aws cloudformation create-stack --stack-name nested --template-body file://nested.yml --parameters ParameterKey=BucketName,ParameterValue=$BUCKET --capabilities CAPABILITY_NAMED_IAM

Poll for its progress with:

$ aws cloudformation describe-stacks --stack-name nested

When complete, make a note of the APIBaseUrl, UserPoolID and APIClientID values. Export them to environment variables with these commands. Be sure the replace the values in angle brackets with the real values:

$ export BASEURL=<APIBaseUrl>
$ export POOL_ID=<UserPoolID>
$ export CLIENT_ID=<APIClientID>

Step 11: Run the authentication.sh script

Run the authentication.sh script (from Step 9) to generate an auth token and export it to the environment. Replace ‘EMAIL’ and ‘PASSWORD’ with an email address for the user and and a secure password:

$ source ./authentication.sh EMAIL PASSWORD

Step 12: Run the test.sh script

Run the test.sh script (from Step 8) to check that the API is working correctly:

$ ./test.sh $BASEURL

If all goes well, the script should run with no errors other than those expected (as detailed in the output).

Step 9: Clean Up

If you don’t want to keep your API (note that AWS will charge you for it if you don’t have Free Tier on your account), delete the CFT stack (all resources created) by running:

$ aws cloudformation delete-stack --stack-name nested

Empty and delete your S3 bucket. To do this, run:

$ aws s3 rm s3://$BUCKET/ --recursive
$ aws s3api delete-bucket --bucket $BUCKET

You will then need to remove any logs that were generated by the Lambda function. To do this:

  1. Log into the AWS Console (your AWS account) using your Administrator IAM user, and navigate to CloudWatch.
  2. On the left-hand side, select Logs -> Log Groups.
  3. Select ‘aws/lambda/requestHandler’.
  4. Under Actions, select ‘Delete log groups’ and confirm.

Conclusion

You should now be able to create a simple API on AWS with a nested CloudFormation template, with a separate CloudFormation template for each group of resources.

Thanks for reading!