Creating an Image Service using AWS API Gateway with Proxy Resources + CloudFormation

WordPress REST API Basics

So, you already read the title and I don’t need to give boring explanations. But, one important thing is that I’m focusing on creating this API using CloudFormation because creating an API using the web console is not even a piece of cake! But when you do more “organizational” work, you need CloudFormation (IaaC – Infrastructure and Code).

So, here we go.

Problem Definition

We are using an external system to deliver images to the front end and other tools. This service provides more sophisticated features like image transformations and manipulations, apart from serving images. And of course, they charge us to provide this service.

We also have internal projects where we need to process (almost) all of our images, which is a huuuuge amount! And if we are to use this external service, it would cost us a LOT! And they are using our S3 buckets to read our images and use them for their processing, so we technically have the images we need to use for our projects.

So, we wanted to create a secured API Endpoint to serve images for our internal projects which kind of replicates the external service, without directly reading from S3 of course (because it’s ugly!).

What would the API look like?

The API Endpoint will expect a URL like below (which is similar to the external service) and it should deliver the correct image to the caller.

https://abc.execute-api.eu-west-1.amazonaws.com/dev/partnerimages/67/28/67286576.jpeg

We’re passing the image we need to retrieve as part of the URL itself, and NOT inside the body of the request. So, you might already have realized we should use a proxy resource for this. If you haven’t because you don’t know what the hell a proxy resource is, let me explain.

Proxy Resources

When you call an API endpoint such as https://api.dev.com/users/, users is the resource we’re trying to work with. So we can use different methods such as GET, POST, PUT, DELETE, etc. to manipulate this users resource. However, then we need to define different resources we’re going to serve from our API beforehand. Then you can trigger different services/actions for different resources from the API Gateway. But, if you have a single back end (i.e. a Lambda function) to handle all the resources, it doesn’t make sense to create different resources because all the requests will go to the same target anyway. So, it’s better to create a dummy resource, pass all the parameters to the back end, and let the back end decode the URL and the request. These dummy resources are called proxy resources.

Evaluating API Gateway as a Proxy to internal AWS resources via Lambda and  HTTP Proxy · Josh Durbin
https://www.joshdurbin.net/posts/2017-04-initial-performance-benchmarks-python-api-gateway-proxy-vs-elb/

So, we need an endpoint with a proxy resource forwarding all the requests to the same back end, which will be a lambda function in our case.

Authentication

We wanted to have a key for our API not just to restrict access, but also to track usage of it. And we will have one prod stack, dev stack, and multiple stacks for our engineers. And we wanted to have only two API keys for prod and all the dev stacks respectively. This means, the stacks of engineers will still use the dev API key, to make things easier to use, but it makes the CloudFormation a bit complex. Because these API key values should be created outside of the API level, we wanted to create two global stacks for prod and dev APIs.

So, we decided to use AWS Secrets Manager to hold the API key values and refer them from the API Key in the API Gateway. Let me show how it looks.

Shared API Keys

Another issue we had to tackle is to have proper permission to read the images from our lambda functions because they are stored in a different AWS account than our engineering account. So, we also need two global IAM roles for all the lambda functions on prod and dev stacks.

CloudFormation Templates

Global Stack

First, we need to define the API Keys and the IAM role as global resources.

Resources:
  #  Will be used to call the images bucket.
  APILambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub
        - ${StackName}--lambda-role
        - { StackName: !Ref "AWS::StackName" }
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Action:
              - sts:AssumeRole
            Principal:
              Service:
                - lambda.amazonaws.com
      Policies:
      - PolicyName: S3Policy
        PolicyDocument:
          Version: 2012-10-17
          Statement:
            - Effect: Allow
              Action:
                - s3:Get*
                - s3:List*
              Resource:
                - !Sub "arn:aws:s3:::${ImagesBucketName}"
                - !Sub "arn:aws:s3:::${ImagesBucketName}/*"

  APIKey:
    Type: AWS::SecretsManager::Secret
    Properties:
      Description: API key definition for Imaginary API
      Name: !Sub
        - ${StackName}--api-key
        - { StackName: !Ref "AWS::StackName" }
      GenerateSecretString:
        SecretStringTemplate: "{}"
        GenerateStringKey: "api_key"
        PasswordLength: 30
        ExcludeCharacters: '!"#$%&()*+,-./:;<=>?@[\]^_`{|}~'

Outputs:
  APILambdaRole:
    Value: !GetAtt APILambdaRole.Arn
    Export:
      Name: !Sub global--api-role--${Environment}

  APIKeySecret:
    Value: !Ref APIKey
    Export:
      Name: !Sub global--api-key--${Environment}

NOTES:

  • Make sure you give correct permission for your IAM role to be able to read from the S3 bucket.
  • Remember how you have injected the secret. Here, we’re creating a simple JSON object with the api_key as the secret we generate.

Add IAM Role to the Account (optional)

As I explained earlier, our images are stored in a different AWS account, so we need to let this IAM role access it. So, we need to add this IAM role to the Bucket Policy of the S3 bucket. If your bucket is in the same account, then you only need to allow the Lambda role to access that bucket.

{
   "Version": "2012-10-17",
   "Statement": [
     {
       "Sid": "S3AccessImagesAccount",
       "Effect": "Allow",
       "Principal": {
         "AWS": [
           ...
           "arn:aws:iam::account_id:role/global--prod--lambda-role",
           "arn:aws:iam::account_id:role/global--dev--lambda-role",
           ...
         ]
       },
       "Action": [
         "s3:Get",
         "s3:List"
       ],
       "Resource": [
         "arn:aws:s3:::images-bucket",
         "arn:aws:s3:::images-bucket/*"
       ]
     },
     ...
   ]
 }

Creating the RestAPI

This is where things got interesting! We all know the AWS documentation is awesome and it’s super easy to find what we need. So, I had to spend a couple of days finding out how to create a RestAPI with a proxy resource using CloudFormation! Well, it took only a few minutes to do it using the web console, and we all know it’s no way close when we do it with good old CloudFormation. Here is my Stack Overflow question, and also the answer I put myself.

I will explain all the issues I had later, but here is the CloudFormation template you’re looking for.

Resources:
  Lambda:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub
        - ${StackName}-lambda
        - { StackName: !Ref "AWS::StackName" }
      Handler: api.lambda.handler
      Description: Handles API requests
      Role: {"Fn::ImportValue" : {"Fn::Sub" : "global--api-role--${Environment}"}}
      Runtime: python3.8
      Timeout: 300
      MemorySize: 1536
      CodeUri: ../_build/
      Environment:
        Variables:
          ENVIRONMENT: !Ref Environment
          LOG_LEVEL: !Ref LogLevel
          IMAGES_BUCKET: !Ref ImagesBucketName
          IMAGES_PREFIX: !Ref ImagesBucketPrefix

  RestApi:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: !Sub
        - ${StackName}-api
        - { StackName: !Ref "AWS::StackName" }
      Description: Rest API for Images
      BinaryMediaTypes:
        - "*/*"  # <-- This allows the API send binary data (images)
      EndpointConfiguration:
        Types:
          - REGIONAL
      ApiKeySourceType: HEADER  # <-- This is important

  ProxyResource:
    Type: AWS::ApiGateway::Resource
    DependsOn:
      RestApi
    Properties:
      RestApiId: !Ref RestApi
      ParentId: !GetAtt
        - RestApi
        - RootResourceId
      PathPart: '{proxy+}'  # <-- This is important, denotes this is a Proxy Resource

  ProxyResourceANY:
    Type: AWS::ApiGateway::Method
    DependsOn:
      ProxyResource
    Properties:
      RestApiId: !Ref RestApi
      ResourceId: !Ref ProxyResource
      HttpMethod: ANY  # Triggers the lambda for all methods (GET, POST, etc.)
      AuthorizationType: NONE  # No authorization is required
      ApiKeyRequired: true  # <-- Enforces to expect the API Key
      RequestParameters:
        method.request.path.proxy: true  # <-- This is VERY important
      MethodResponses:
        - StatusCode: 200
      Integration:
        Type: AWS_PROXY  # <-- This is SUPER important
        IntegrationHttpMethod: POST
        ContentHandling: CONVERT_TO_TEXT  # <-- Important, depending on your usecase
        PassthroughBehavior: WHEN_NO_MATCH  # <-- This is important
        Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${Lambda.Arn}/invocations
        Credentials: !GetAtt ApiGatewayIamRole.Arn

  RestAPIDeployment:
    Type: AWS::ApiGateway::Deployment
    DependsOn:
      - ProxyResourceANY
    Properties:
      RestApiId: !Ref RestApi
      StageName: !Ref Environment

  ApiKey:
    Type: AWS::ApiGateway::ApiKey
    DependsOn:
      - RestAPIDeployment
    Properties:
      Description: API Key for Images API
      Enabled: 'true'
      StageKeys:
        - RestApiId: !Ref RestApi
          StageName: !Ref Environment
      # We're importing the key value from the Secrets Manager
      Value: !Sub
        - "{{resolve:secretsmanager:${SecretID}:SecretString:${SecretKey}}}"
        - {SecretID: {"Fn::ImportValue" : {"Fn::Sub" : "global--api-key--${Environment}"}}, SecretKey: "api_key"}

  UsagePlan:
    Type: AWS::ApiGateway::UsagePlan
    DependsOn:
      - ApiKey
      - RestAPIDeployment
    Properties:
      ApiStages:
        - ApiId: !Ref RestApi
          Stage: !Ref Environment
      Description: Usage plan for the Images API with the API Key
      UsagePlanName: !Sub
        - ${StackName}-usage-plan
        - { StackName: !Ref "AWS::StackName" }

  UsagePlanKey:
    Type: AWS::ApiGateway::UsagePlanKey
    DependsOn:
      - UsagePlan
    Properties:
      KeyId: !Ref ApiKey 
      KeyType: API_KEY   # <-- This is important
      UsagePlanId: !Ref UsagePlan

  Invoke:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt Lambda.Arn
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub
        - arn:aws:execute-api:${Region}:${AccountId}:${API}/${Env}
        - {Region: !Ref AWS::Region, AccountId: !Ref AWS::AccountId, API: !Ref RestApi, Env: !Ref Environment}

  # Add the IAM role for the API here

Well, that’s quite something, isn’t it! I have marked the important parameters inside the YAML itself, so take a look to see what we could’ve been missed.

NOTES:

  • Make sure you have Depends on segments to make sure you have inter-dependent resources created correctly. Otherwise, you’ll get horrible error messages when you deploy the template.
  • When we create the same API from the web console, it shows as LAMBDA integration. But it actually is a LAMBDA_PROXY integration. I found this out (from a tip from Mukul Vashishtha) by comparing the two Swagger definitions helped me a lot comparing the YAMLs taken out from Export as Swagger + API Gateway Extensions option.
  • According to AWS Support, there are some parameters automatically getting initialized when you’re creating things from the web console, and not when you’re using CloudFormation.

Lambda Function

This is a very simple lambda function to extract the path from the event, retrieve the image, and return it as base64 content.

import json

def handler(event, context):
    # 'path': 'partnerimages/67/28/67286576.jpeg' 
    path = event.get('path')

    if not path:
        return {
            'statusCode': 400,
            'body': json.dumps('No path is provided!')
        }

    # use some dark magic to get the file prefix for the image
    image_prefix = some_dark_magic(path)

    try:
        response = client.get_object(
            Bucket=IMAGES_BUCKET,
            Key=image_prefix
        )

        image = response['Body'].read()

        return {
            'headers': {"Content-Type": "image/jpeg"},
            'statusCode': 200,
            'body': base64.b64encode(image).decode('utf-8'),
            'isBase64Encoded': True
        }

    except Exception as e:
        return {
            'statusCode': 404,
            'body': json.dumps(f'Invalid image path: {path}')
        }

How to use the API

We need two values to use this API.

  1. API Endpoint URL
    This can be extracted from the Stages section of your deployed API.
  2. API Key
    This can be extracted either from the API Keys section in the API Gateway window or from the Secrets Manager window.

Because we’re using the API_KEY as the Authentication Key Type, we MUST add X-API-KEY to the header of the request.

And also, you need to set Accept and Content-Type parameters in the request header to image/jpeg or whatever the image code you’re using for the images.

Here is a sample Python code calling our API.

import requests

headers = {
    'Content-Type': 'image/jpeg',
    'Accept': 'image/jpeg',
    'X-API-KEY': 'you_api_key'
}

image_path = 'partnerimages/67/28/67286576.jpeg'
file_name = image_path.split('/')[-1]
url = f" https://abc.execute-api.eu-west-1.amazonaws.com/dev/{image_path}"

response = requests.get(url, headers=headers)

if response.status_code == 200:
    print('Success!')
    image = response.content  # base64 encoded image content

    with open(file_name, 'wb') as img:
        img.write(image)
    
elif response.status_code == 404:
    print('Not Found.')

Tadaaa!!! Now you have your own API to serve images from an S3 bucket.

Summary

Creating even a simple API using CloudFormation is a pain in the assets, but we need do it! The documentation doesn’t quite explain how to use different parameters with each other, so we should do some trial an error, or talk to Support.

However, this is a very simple API to serve images residing in an S3 bucket (in a different AWS account) with API Key authentication. Two special things about this implementation are that it uses Proxy Resources and shared API Keys.

So, what do you think? Let me know! Until then, here is a cookie for you!

Source: http://objectshowfanonpedia.wikia.com/wiki/File:Bitten_cookie.png

One comment on “Creating an Image Service using AWS API Gateway with Proxy Resources + CloudFormation

Leave a Reply

%d bloggers like this: