Creating an API with AWS

When developing an API (Application Programming Interface) from scratch, there are many things to think about. Where to host it, how to deal with load balancing, which API framework to use… the list goes on. In short, developing an API from scratch takes a lot of time and effort. But AWS allows you to create an API in minutes. In this post, I’ll show you how.

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 a CloudFormation Template

CloudFormation templates are configuration documents that can be used to tell AWS what resources and services to provision. They are useful because you can provision a number of different resources at once. Your provisioned resources are called a ‘stack’. When you want to deprovision those same resources, you only need to issue one command to delete all the resources in the ‘stack’ at once.

CloudFormation templates can be in yaml or json format. For this example, the template will be in yaml format.

At the top of the CloudFormation template (I’m going to call it CFT from now onwards), place the version and description lines:

AWSTemplateFormatVersion: "2010-09-09"
Description: Creates a simple API endpoint that returns a list of links

The AWSTemplateFormatVersion field tells AWS how to interpret the CFT. Currently there is only one version available. The Description field is where we add the description of what this CFT is going to build.

Next we’ll add a Resources section, which is contains the bulk of the CFT. The first resource we’ll add is a Lambda execution IAM role with associated policies, requestHandlerLambdaExecutionRole:

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: "*"
          RoleName: requestHandlerLambdaExecutionRole

This role allows the Lambda function (which comes later in the CFT) to run. The Lambda function is a piece of code that runs on demand. In our case, this Lambda will run whenever someone makes a request to the API we are building. The AssumeRolePolicyDocument section gives the Lambda service permission to assume this role. The PolicyDocument section determines what the Lambda function is allowed to do. In this case, it is given permission to generate logs.

The next resource in the CFT is the Lambda function itself, the requestHandler resource:

requestHandler:
        Type: "AWS::Lambda::Function"
        Properties: 
          Code:
            ZipFile: !Sub |
                import json
                
                
                def lambda_handler(event, context):
                    try:
                        print(event)
                        print(context)
                        # 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": json.dumps({
                                    "links": ["https://www.google.com", "https://www.amazon.com"],
                                }),
                            "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.
                        error_response(str(err), context.aws_request_id)
                    
                    
                    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': '*',
                            },
                        }

          Description: Lambda function for processing api endpoint requests
          FunctionName: requestHandler
          Handler: index.lambda_handler
          Role: 
            Fn::GetAtt: 
            - "requestHandlerLambdaExecutionRole"
            - "Arn"
          Runtime: python3.8
        DependsOn:
            - requestHandlerLambdaExecutionRole

Here we adding python code directly to the CFT. This python code forms our request-handling Lambda function. Whenever a request is made to the API, this Lambda function will be triggered. As you can see from the code, it returns a json response with a list of links. Error handling is also included in the code, in case something goes wrong. Note the DependsOn attribute for the resource. This is telling AWS that the requestHandlerLambdaExecutionRole resource must be built before the requestHandler resource.

The next resource is the ApplicationAPI, an APIGateway resource. APIGateway is AWS’s API service.

ApplicationAPI:
        Type: AWS::ApiGateway::RestApi
        Properties:
            Name: ApplicationAPI
            Description: Simple API for an application
        DependsOn: 
            - requestHandler

The next resource, LambdaPermission, gives permission to the API resource (ApplicationAPI) to invoke our Lambda function:

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

Next up is an endpoint for the API, which we will call link:

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

Now we add a GET method to our endpoint, ProxyResourceGET. This handles proxying API link endpoint GET requests to the Lambda function.

ProxyResourceGET:
        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

We’ll also add an OPTIONS method to our link endpoint, OptionsMethod. This won’t be used directly, but is needed in case we want to preform cross-origin API requests. This is where we call our API from a different domain from where it is hosted. This is likely going to be the case, because we are likely to host any website that calls the API in a different location to AWS’s API Gateway domains.

OptionsMethod:
        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: 
            - ProxyResourceGET

The final resource is Deployment, which deploys the API to an environment (called a ‘stage’). The stage name here is called ‘prod’, but you can call it anything you like.

Deployment: 
      DependsOn: "OptionsMethod"
      Type: AWS::ApiGateway::Deployment
      Properties: 
        RestApiId: 
          Ref: "ApplicationAPI"
        Description: "Application API deployment"
        StageName: "prod"

The last section of the CFT is the Outputs section. This is where we can output any created resource information. In here, we’ll add the API endpoint URL that we’ll need to use to make requests.

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

The whole CFT, when all put together, will be:

AWSTemplateFormatVersion: "2010-09-09"
Description: Creates a simple API endpoint that returns a list of links
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: "*"
          RoleName: requestHandlerLambdaExecutionRole


    requestHandler:
        Type: "AWS::Lambda::Function"
        Properties: 
          Code:
            ZipFile: !Sub |
                import json
                
                
                def lambda_handler(event, context):
                    try:
                        print(event)
                        print(context)
                        # 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": json.dumps({
                                    "links": ["https://www.google.com", "https://www.amazon.com"],
                                }),
                            "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.
                        error_response(str(err), context.aws_request_id)
                    
                    
                    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': '*',
                            },
                        }

          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
    
    ProxyResourceGET:
        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
              
    OptionsMethod:
        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: 
            - ProxyResourceGET
                
    Deployment: 
      DependsOn: "OptionsMethod"
      Type: AWS::ApiGateway::Deployment
      Properties: 
        RestApiId: 
          Ref: "ApplicationAPI"
        Description: "Application API deployment"
        StageName: "prod"
Outputs:
  APIUrl:
    Description: Application API Gateway link endpoint URL
    Value: !Join
        - ''
        - - 'https://'
          - !Ref ApplicationAPI
          - '.execute-api.'
          - !Ref AWS::Region
          - '.amazonaws.com/prod/link'

Step 2: Launch the CloudFormation Template

Save the CFT to a file called setupapi.yml. Using the AWS CLI, launch the CFT with the following command:

$ aws cloudformation create-stack --stack-name api --template-body file://setupapi.yml --capabilities CAPABILITY_NAMED_IAM

You can poll the following command until the StackStatus field in the output changes to “CREATE_COMPLETE”. At that point, the CFT stack has been built.

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

Once complete, you’ll also see the API URL in the Outputs section of the response, next to OutputValue. It should look something like “https://xxxxxxx.execute-api.x-region-1.amazonaws.com/prod/link”.

Step 3: Test the API

Test the API by navigating to the endpoint URL in your browser. If successful, you should see this response displayed:

{"links": ["https://www.google.com", "https://www.amazon.com"]}

Step 4: 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 api

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 set up your own simple API on AWS in minutes. If you want to learn about adding a DynamoDB database to your API, read my follow-on post Creating an API with AWS: Part 2: DynamoDB. Thanks for reading, see you next time!