Building on AWS

Downloading a file from S3 using API Gateway & AWS Lambda

Part 6 of 7 in a series on building a serverless backend with AWS SAM. Previous: Writing a CSV to S3 from AWS Lambda. Next: AWS Lambda & ECR nuances.

In my last post I showed how we can create and store a csv file in an S3 bucket. Let us now see how we can get the status of a request & get a downloadable link to the csv for completed requests.

The Get Status lambda

Under the src directory, create a new file called “get_status.py” with the below code - get_status.py

from db import db_helper
import boto3
import json
import os
from botocore.exceptions import ClientError
from datetime import datetime

def lambda_handler(event=None, context=None):

    if event['httpMethod'] != "GET":
        return generate_response(404, "Invalid request method")

    query_params = event['queryStringParameters']

    if not validate_params(query_params):
        return generate_response(404, "Invalid request")
       
    try:
        dbHelper = db_helper.DBHelper()
        response = dbHelper.get_order_status(query_params['request_id'])
        print(response)
        if response is None or len(response) == 0:
            return generate_response(404, f"Request not found")

        presigned_url = None
        if response[0]['status'] == 'Complete':
            upload_bucket_name = os.environ['UPLOAD_BUCKET']
            presigned_url = create_presigned_url(upload_bucket_name, response[0]['file_location'])
        
        return generate_response(200, {
            "url": response[0]['url'],
            "request_id": response[0]['request_id'],
            "status": response[0]['status'],
            "updated": datetime.fromtimestamp(response[0]['epoch_time']).strftime('%Y-%m-%d %H:%M:%S'),
            "download_link": presigned_url
        })


    except Exception as e:
        print(e)
        return generate_response(500, f"Error processing request: {e}")

def generate_response(response_code, message):
    return {
        "statusCode": response_code,
        "body": json.dumps(message),
        "headers": { 
            "Access-Control-Allow-Headers" : "Content-Type",
            "Access-Control-Allow-Origin": "*", 
            "Access-Control-Allow-Methods": "GET"
        }
    }

def validate_params(query_params):
    payload_valid = True
    # Check if required keys are in json_map
    keys_required = {'request_id'}
    for key in keys_required:
        if key not in query_params:
            payload_valid = False
            break

    # Check if all the values are strings
    return payload_valid        

def create_presigned_url(bucket_name, object_name, expiration=3600):
    """Generate a presigned URL to share an S3 object

    :param bucket_name: string
    :param object_name: string
    :param expiration: Time in seconds for the presigned URL to remain valid
    :return: Presigned URL as string. If error, returns None.
    """

    # Generate a presigned URL for the S3 object
    s3_client = boto3.client('s3')
    try:
        response = s3_client.generate_presigned_url('get_object',
                                                    Params={'Bucket': bucket_name,
                                                            'Key': object_name},
                                                    ExpiresIn=expiration)
    except ClientError as e:
        print(e)
        return None

    # The response contains the presigned URL
    return response

Add the new file to Dockerfile -

Dockerfile

Update the template.yaml file and add a new resource - template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  python3.9

  Sample SAM Template for serverless-arch-example

Parameters:
  Environment:
    Type: String
    Description: AWS Environment where code is being executed (AWS_SAM_LOCAL or AWS)
    Default: 'AWS'

  DynamoDBUri:
    Type: String
    Description: AWS local DynamoDB instance URI (will only be used if AWSENVNAME is AWS_SAM_LOCAL)
    Default: 'http://docker.for.mac.host.internal:8000'
  
  ProjectName:
    Type: String
    Description: 'Name of the project'
    Default: 'serverless-arch-example'

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
  Function:
    Timeout: 120
    MemorySize: 2048 
    Environment:
      Variables:
        ENVIRONMENT: !Ref Environment
        DYNAMODB_DEV_URI: !Ref DynamoDBUri
        ORDERS_TABLE_NAME: !Ref OrdersTable
        SQS_QUEUE: !Ref OrdersQueue
        UPLOAD_BUCKET: !Ref OrdersBucket

Resources:
  OrdersBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Join ['-', [!Sub '${ProjectName}', 'csvs']]
      LifecycleConfiguration:
        Rules:
          - Id: DeleteContentAfterADay
            ExpirationInDays: 1
            Status: Enabled
      CorsConfiguration:
        CorsRules:
        - AllowedHeaders:
            - "*"
          AllowedMethods:
            - GET
            - PUT
            - POST
            - DELETE
            - HEAD
          AllowedOrigins:
            - "*"
  OrdersTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Join ['-', [!Sub '${ProjectName}', 'orders']]
      AttributeDefinitions:
        - AttributeName: request_id
          AttributeType: S        
      KeySchema:
        - AttributeName: request_id
          KeyType: HASH     
      ProvisionedThroughput:
        ReadCapacityUnits: 3
        WriteCapacityUnits: 3
  OrdersQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: !Join ['-', [!Sub '${ProjectName}', 'orders']]
      VisibilityTimeout: 120  # must be same as lambda timeout

  CreateFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      PackageType: Image
      ImageConfig:
        Command:
          - create.lambda_handler
      Architectures:
        - x86_64
      Events:
        CreateAPI:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            Path: /example/create
            Method: post
      Policies:
        - AmazonDynamoDBFullAccess
        - SQSSendMessagePolicy:
            QueueName: !GetAtt OrdersQueue.QueueName
    Metadata:
      Dockerfile: Dockerfile
      DockerContext: ./src
      DockerTag: python3.9-v1
  
  ProcessFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      FunctionName: !Join ['-', [!Sub '${ProjectName}', 'process']]
      PackageType: Image
      ImageConfig:
        Command:
          - process.lambda_handler
      Architectures:
        - x86_64
      Policies:
        - AmazonDynamoDBFullAccess
        - S3CrudPolicy:
            BucketName: !Ref OrdersBucket
      Events:
        SqsEvent:
          Type: SQS
          Properties:
            Queue: !GetAtt OrdersQueue.Arn
            BatchSize: 1
    Metadata:
      Dockerfile: Dockerfile
      DockerContext: ./src
      DockerTag: python3.9-v1
  GetStatusFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      FunctionName: !Join ['-', [!Sub '${ProjectName}', 'get-status']]
      PackageType: Image
      ImageConfig:
        Command:
          - get_status.lambda_handler
      Architectures:
        - x86_64
      Policies:
        - AmazonDynamoDBFullAccess
        - S3WritePolicy:
            BucketName: !Ref OrdersBucket
        - S3ReadPolicy:
            BucketName: !Ref OrdersBucket
      Events:
        GetStatusAPI:
          Type: Api
          Properties:
            Path: /example/get-status
            Method: get
    Metadata:
      Dockerfile: Dockerfile
      DockerContext: ./src
      DockerTag: python3.9-v1

Outputs:
  # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
  # Find out more about other implicit resources you can reference within SAM
  # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
  CreateAPI:
    Description: "API Gateway endpoint URL for Prod stage for Create function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/example/create"
  CreateFunction:
    Description: "Create Lambda Function ARN"
    Value: !GetAtt CreateFunction.Arn
  CreateFunctionIamRole:
    Description: "Implicit IAM Role created for Create function"
    Value: !GetAtt CreateFunctionRole.Arn
  OrdersTable:
    Description: "DynamoDB Table for orders"
    Value: !GetAtt OrdersTable.Arn
  OrdersQueue:
    Description: "SQS Queue for orders"
    Value: !GetAtt OrdersQueue.Arn
  ProcessFunction:
    Description: "Process Lambda Function ARN"
    Value: !GetAtt ProcessFunction.Arn
  OrdersBucket:
    Description: "S3 bucket for Orders"
    Value: !GetAtt OrdersBucket.Arn
  GetStatus:
    Description: "API Gateway endpoint URL for Prod stage for Get Status function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/example/get-status"
  GetStatusFunction:
    Description: "Get Status Lambda Function ARN"
    Value: !GetAtt GetStatusFunction.Arn
  GetStatusFunctionIamRole:
    Description: "Implicit IAM Role created for Get Status function"
    Value: !GetAtt GetStatusFunctionRole.Arn

Testing locally

We can test the endpoint locally, but before that we need to update the env.json and include the S3 bucket name -

env.json

Build and start the api locally -

sam build
sam local start-api --env-vars ./tests/env.json

You should see an output like -

Console output

Trigger a new request & grab the request id from the DB (since the post will fail without an SQS queue defined). Then test the get status call -

postman

Deploying the code

Just like before, we need to specify an image repository for the new lambda function. Update the samconfig.toml file and add another item to the image_repositories list for GetStatusFunction -

image_repositories = ["CreateFunction=123456789012.dkr.ecr.us-east-2.amazonaws.com/serverlessarchexample8b9687a4/createfunction286a02c8repo",
"ProcessFunction=123456789012.dkr.ecr.us-east-2.amazonaws.com/serverlessarchexample8b9687a4/createfunction286a02c8repo",
"GetStatusFunction=123456789012.dkr.ecr.us-east-2.amazonaws.com/serverlessarchexample8b9687a4/createfunction286a02c8repo"]

Deploy the code to aws -

sam build
sam deploy

The output should look like this -

console output

Grab the get-status endpoint url and try making a request through postman for one of the completed orders from before -

postman

Cmd+Click on the download_link to download the csv file from S3.

And thats it! The SAM CLI has enabled us to leverage infrastructure-as-code to deploy our entire architecture to any aws account within minutes!

Source Code

Here is the source code for the project created here.

Next: Part 7: AWS Lambda & ECR nuances

Also on dev.to