In my post Creating an API with AWS: Part 2: DynamoDB, I updated the simple AWS API endpoint with a DynamoDB database table for data storage. In this post, I’ll add two additional endpoints, with two additional database tables. One of the tables will be a linking table between the two other tables. This allows data stored in one table to be linked to data stored in another. In this example, we’ll add a new endpoint called tag, which we can use to manage tags, and a third endpoint called taglink, which we can use to assign tags to links.
Prerequisites
Before you start, you’ll need the following things:
- An AWS account
- An Administrator IAM User with which to use the AWS CLI
- AWS CLI set up on your local machine
- The CloudFormation template from my previous post, Creating an API with AWS: Part 2: DynamoDB.
Note that AWS will charge you for creating and hosting this API if you don’t have Free Tier on your account.
Step 1: Add/Update DynamoDB tables in the CloudFormation Template
Taking the CloudFormation template (CFT) from my previous post, Creating an API with AWS: Part 2: DynamoDB, update the linkTable resource with a Global Secondary Index. This index will be used to find entries by link value. Add it under the TableName attribute.
GlobalSecondaryIndexes:
-
IndexName: "link_index"
KeySchema:
-
AttributeName: "link"
KeyType: "HASH"
-
AttributeName: "link_id"
KeyType: "RANGE"
Projection:
ProjectionType: "KEYS_ONLY"
ProvisionedThroughput:
ReadCapacityUnits: "5"
WriteCapacityUnits: "5"
Next, add two new resources for a DynamoDB tables (tagTable and tagLinkTable) which will be used to store the tags and tag-link mappings. Add them under the linkTable resource:
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"
Note the the tagTable resource also has a Global Secondary Index to enable filtering by tag name.
Step 2: Update the Lambda Role Permissions in the CloudFormation Template
Next, update the requestHandlerLambdaExecutionRole resource, adding a permission to run database queries to the list of existing database permissions:
PolicyDocument:
Version: "2012-10-17"
Statement:
-
Effect: "Allow"
Action:
- dynamodb:DeleteItem
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:Scan
- dynamodb:UpdateItem
- dynamodb:Query
This will enable the lambda function to run filtered queries on the database tables.
Step 3: Update the Lambda Function in the CloudFormation Template
Update the requestHandler resource with the new lambda code needed to handle the endpoint requests and database queries:
import json
import boto3
import uuid
# 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
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 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['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['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})
# 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)
Step 4: Add Two New API Resources in the CloudFormation Template
Add two API Gateway resources, one for the tag endpoint, and one for the taglink endpoint.
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
Step 5: Add Methods for the New Endpoints in the CloudFormation Template
Add methods for the tag and taglink endpoints. Update the Deployment resource to depend on the OptionsMethodTagLink resource, which should be the last endpoint method to be created, with the rest in a chain.
ProxyResourceTagGET:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref ApplicationAPI
ResourceId: !Ref tag
HttpMethod: GET
AuthorizationType: NONE
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
IntegrationResponses:
-
StatusCode: 200
MethodResponses:
-
StatusCode: 200
ResponseModels:
application/json: Empty
DependsOn:
- ProxyResourceLinkPOST
ProxyResourceTagPOST:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref ApplicationAPI
ResourceId: !Ref tag
HttpMethod: POST
AuthorizationType: NONE
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
IntegrationResponses:
-
StatusCode: 200
MethodResponses:
-
StatusCode: 200
ResponseModels:
application/json: Empty
DependsOn:
- ProxyResourceTagGET
OptionsMethodTag:
Type: AWS::ApiGateway::Method
Properties:
AuthorizationType: NONE
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:
- ProxyResourceTagPOST
ProxyResourceTagLinkGET:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref ApplicationAPI
ResourceId: !Ref taglink
HttpMethod: GET
AuthorizationType: NONE
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
IntegrationResponses:
-
StatusCode: 200
MethodResponses:
-
StatusCode: 200
ResponseModels:
application/json: Empty
DependsOn:
- ProxyResourceTagPOST
ProxyResourceTagLinkPOST:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref ApplicationAPI
ResourceId: !Ref taglink
HttpMethod: POST
AuthorizationType: NONE
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
IntegrationResponses:
-
StatusCode: 200
MethodResponses:
-
StatusCode: 200
ResponseModels:
application/json: Empty
DependsOn:
- ProxyResourceTagLinkGET
OptionsMethodTagLink:
Type: AWS::ApiGateway::Method
Properties:
AuthorizationType: NONE
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:
- ProxyResourceTagLinkPOST
Deployment:
DependsOn: "OptionsMethodTagLink"
Type: AWS::ApiGateway::Deployment
Properties:
RestApiId:
Ref: "ApplicationAPI"
Description: "Application API deployment"
StageName: "prod"
Step 6: Update the Outputs in the CloudFormation Template
Update the Outputs to include each endpoint URL, as well as the base URL for the API.
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: Launch the CloudFormation Template
After an update to the Description, the CloudFormation template should now look something like the below:
AWSTemplateFormatVersion: "2010-09-09"
Description: Creates a simple API with three endpoints that can be called to POST/GET links and tags and tag links, with a DynamoDB database
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"
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
# 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]['tag_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
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 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:
# query_params = json.loads(query_params)
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['resource'] == '/tag':
if event['httpMethod'] == "POST":
post_body = json.loads(event['body'])
tag = post_body.get('tag')
tag_id = store_tag(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['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})
# 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
ApplicationAPI:
Type: AWS::ApiGateway::RestApi
Properties:
Name: ApplicationAPI
Description: Simple API for an application
DependsOn:
- requestHandler
LambdaPermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !GetAtt
- requestHandler
- Arn
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
ProxyResourceLinkGET:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref ApplicationAPI
ResourceId: !Ref link
HttpMethod: GET
AuthorizationType: NONE
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
IntegrationResponses:
-
StatusCode: 200
MethodResponses:
-
StatusCode: 200
ResponseModels:
application/json: Empty
DependsOn:
- ApplicationAPI
ProxyResourceLinkPOST:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref ApplicationAPI
ResourceId: !Ref link
HttpMethod: POST
AuthorizationType: NONE
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
IntegrationResponses:
-
StatusCode: 200
MethodResponses:
-
StatusCode: 200
ResponseModels:
application/json: Empty
DependsOn:
- ProxyResourceLinkGET
OptionsMethodLink:
Type: AWS::ApiGateway::Method
Properties:
AuthorizationType: NONE
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:
- ProxyResourceLinkPOST
ProxyResourceTagGET:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref ApplicationAPI
ResourceId: !Ref tag
HttpMethod: GET
AuthorizationType: NONE
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
IntegrationResponses:
-
StatusCode: 200
MethodResponses:
-
StatusCode: 200
ResponseModels:
application/json: Empty
DependsOn:
- ProxyResourceLinkPOST
ProxyResourceTagPOST:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref ApplicationAPI
ResourceId: !Ref tag
HttpMethod: POST
AuthorizationType: NONE
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
IntegrationResponses:
-
StatusCode: 200
MethodResponses:
-
StatusCode: 200
ResponseModels:
application/json: Empty
DependsOn:
- ProxyResourceTagGET
OptionsMethodTag:
Type: AWS::ApiGateway::Method
Properties:
AuthorizationType: NONE
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:
- ProxyResourceTagPOST
ProxyResourceTagLinkGET:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref ApplicationAPI
ResourceId: !Ref taglink
HttpMethod: GET
AuthorizationType: NONE
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
IntegrationResponses:
-
StatusCode: 200
MethodResponses:
-
StatusCode: 200
ResponseModels:
application/json: Empty
DependsOn:
- ProxyResourceTagPOST
ProxyResourceTagLinkPOST:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref ApplicationAPI
ResourceId: !Ref taglink
HttpMethod: POST
AuthorizationType: NONE
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
IntegrationResponses:
-
StatusCode: 200
MethodResponses:
-
StatusCode: 200
ResponseModels:
application/json: Empty
DependsOn:
- ProxyResourceTagLinkGET
OptionsMethodTagLink:
Type: AWS::ApiGateway::Method
Properties:
AuthorizationType: NONE
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:
- ProxyResourceTagLinkPOST
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'
Save the CFT to a file called setupapidb2.yml and launch it with the following command:
$ aws cloudformation create-stack --stack-name apidb2 --template-body file://setupapidb2.yml --capabilities CAPABILITY_NAMED_IAM
Poll the progress of the CloudFormation stack with the following command:
$ aws cloudformation describe-stacks --stack-name apidb2
Once complete, the value of “StackStatus” in the response should be “CREATE_COMPLETE” and you should have four API URLs in the “Outputs” section, under each “OutputValue”. The links should be of the form: “https://xxxxxxxxxx.execute-api.your-region.amazonaws.com/prod/ENDPOINT” for each of the endpoints link, tag and taglink. There should also be a base URL under “APIBaseUrl”. This is the URL of the API, minus any specific endpoint.
Step 8: Test the API
Test that everything is working by using the bash test script below. You may need to install curl and jq first with sudo yum install epel-release -y; sudo yum install curl jq or similar, depending on your operating system. The jq package is useful for parsing and displaying json. Once both are installed, copy the script below to a file called test.sh. Be sure to give it executable permissions (sudo chmod u+x test.sh for linux). Then run it with the API Base URL as the sole argument, e.g:
$ /usr/bin/bash test.sh https://xxxxxxxxxx.execute-api.your-region.amazonaws.com/prod
The test script is below:
BASEURL=$1
echo "=========================="
echo "Test POST /link link=https://www.allthecoding.com, tag=blog"
resp=$(curl -X "POST" -H "Content-Type: application/json" -d "{\"link\": \"https://www.allthecoding.com\", \"tag\": \"blog\"}" $BASEURL/link)
echo $resp
link_id1=$(echo $resp | jq '.link_id' | tr -d '"')
echo $link_id1
echo "=========================="
echo "Test GET /tag tag=blog"
resp=$(curl -X "GET" -H "Content-Type: application/json" $BASEURL/tag?tag=blog)
echo $resp
tag_id1=$(echo $resp | jq '.[0].tag_id' | tr -d '"')
echo $tag_id1
echo "=========================="
echo "Test POST /tag tag=stuff"
resp=$(curl -X "POST" -H "Content-Type: application/json" -d "{\"tag\": \"stuff\"}" $BASEURL/tag)
echo $resp
tag_id2=$(echo $resp | jq '.tag_id' | tr -d '"')
echo $tag_id2
echo "=========================="
echo "Test POST /link link=https://www.google.com, tag=stuff"
resp=$(curl -X "POST" -H "Content-Type: application/json" -d "{\"link\": \"https://www.google.com\", \"tag\": \"stuff\"}" $BASEURL/link)
echo $resp
link_id2=$(echo $resp | jq '.link_id' | tr -d '"')
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" -d "{\"link\": \"https://www.google.com\", \"tag_id\": \"$tag_id1\"}" $BASEURL/link)
echo $resp
link_id2=$(echo $resp | jq '.link_id' | tr -d '"')
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" -d "{\"link\": \"https://www.google.com\", \"tag_id\": \"invalid\"}" $BASEURL/link)
echo $resp
link_id2=$(echo $resp | jq '.link_id' | tr -d '"')
echo $link_id2
echo "=========================="
echo "Test GET /link"
resp=$(curl -X "GET" -H "Content-Type: application/json" $BASEURL/link)
echo $resp
echo "=========================="
echo "Test GET /link tag_id=$tag_id1"
resp=$(curl -X "GET" -H "Content-Type: application/json" $BASEURL/link?tag_id=$tag_id1)
echo $resp
echo "=========================="
echo "Test GET /link tag=blog"
resp=$(curl -X "GET" -H "Content-Type: application/json" $BASEURL/link?tag=blog)
echo $resp
echo "=========================="
echo "Test GET /link link_id=$link_id1"
resp=$(curl -X "GET" -H "Content-Type: application/json" $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" $BASEURL/tag?tag_id=$tag_id1)
echo $resp
echo "=========================="
echo "Test GET /tag"
resp=$(curl -X "GET" -H "Content-Type: application/json" $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" -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" -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" -d "{\"link_id\": \"invalid\", \"tag_id\": \"$tag_id1\"}" $BASEURL/taglink)
echo $resp
If all goes well, the script should run with no errors apart from the three expected error responses, where the script will print the expected error response before making the request.
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 apidb2
You will then need to remove any logs that were generated by the Lambda function. To do this:
- Log into the AWS Console (your AWS account) using your Administrator IAM user, and navigate to CloudWatch.
- On the left-hand side, select Logs -> Log Groups.
- Select ‘aws/lambda/requestHandler’.
- Under Actions, select ‘Delete log groups’ and confirm.
Conclusion
You should now be able to create a simple API on AWS with a CloudFormation template, that has three endpoints and three DynamoDB database tables. You’ve also learned how to add Global Secondary Indexes for filter queries. If you want to find out how to add AWS Cognito authentication to the API, read my follow-on post, Creating an API with AWS: Part 4: Cognito Authentication.
Thanks for reading!

2 Responses
[…] Creating an API with AWS: Part 3: Additional Endpoints December 4, 2021 […]
[…] Creating an API with AWS: Part 3: Additional Endpoints December 4, 2021 […]