Echo

To start, we’ll create a HTTPS accessible lambda function that simply echoes back the contents of incoming API Gateway Lambda event. The source for this is the SpartaHTML.

For reference, the helloWorld function is below.

import (
  awsLambdaEvents "github.com/aws/aws-lambda-go/events"
  spartaAPIGateway "github.com/mweagle/Sparta/v3/aws/apigateway"
)

func helloWorld(ctx context.Context,
  gatewayEvent spartaAWSEvents.APIGatewayRequest) (*spartaAPIGateway.Response, error) {
  logger, loggerOk := ctx.Value(sparta.ContextKeyLogger).(*zerolog.Logger)
  if loggerOk {
    logger.Info("Hello world structured log message")
  }
  // Return a message, together with the incoming input...
  return spartaAPIGateway.NewResponse(http.StatusOK, &helloWorldResponse{
    Message: fmt.Sprintf("Hello world 🌏"),
    Request: gatewayEvent,
  }), nil
}

API Gateway

The first requirement is to create a new API instance via sparta.NewAPIGateway().

stage := sparta.NewStage("prod")
apiGateway := sparta.NewAPIGateway("MySpartaAPI", stage)

In the example above, we’re also including a Stage value. A non-nil Stage value will cause the registered API to be deployed. If the Stage value is nil, a REST API will be created, but it will not be deployed (and therefore not publicly accessible).

Resource

The next step is to associate a URL path with the sparta.LambdaAWSInfo struct that represents the go function:

func spartaHTMLLambdaFunctions(api *sparta.API) []*sparta.LambdaAWSInfo {
  var lambdaFunctions []*sparta.LambdaAWSInfo
  lambdaFn, _ := sparta.NewAWSLambda(sparta.LambdaName(helloWorld),
    helloWorld,
    sparta.IAMRoleDefinition{})

  if nil != api {
    apiGatewayResource, _ := api.NewResource("/hello", lambdaFn)

    // We only return http.StatusOK
    apiMethod, apiMethodErr := apiGatewayResource.NewMethod("GET",
      http.StatusOK,
      http.StatusInternalServerError)
    if nil != apiMethodErr {
      panic("Failed to create /hello resource: " + apiMethodErr.Error())
    }
    // The lambda resource only supports application/json Unmarshallable
    // requests.
    apiMethod.SupportedRequestContentTypes = []string{"application/json"}
  }
  return append(lambdaFunctions, lambdaFn)
}

Our helloWorld only supports GET. We’ll see how a single lambda function can support multiple HTTP methods shortly.

Provision

The final step is to to provide the API instance to Sparta.Main()

// Register the function with the API Gateway
apiStage := sparta.NewStage("v1")
apiGateway := sparta.NewAPIGateway("SpartaHTML", apiStage)

Once the service is successfully provisioned, the Outputs key will include the API Gateway Deployed URL (sample):

INFO[0096] ────────────────────────────────────────────────
INFO[0096] Stack Outputs
INFO[0096] ────────────────────────────────────────────────
INFO[0096] S3SiteURL                                     Description="S3 Website URL" Value="http://spartahtml-mweagle-s3site89c05c24a06599753eb3ae4e-1w6rehqu6x04c.s3-website-us-west-2.amazonaws.com"
INFO[0096] APIGatewayURL                                 Description="API Gateway URL" Value="https://w2tefhnt4b.execute-api.us-west-2.amazonaws.com/v1"
INFO[0096] ────────────────────────────────────────────────

Combining the API Gateway URL OutputValue with our resource path (/hello/world/test), we get the absolute URL to our lambda function: https://w2tefhnt4b.execute-api.us-west-2.amazonaws.com/v1/hello

Verify

Let’s query the lambda function and see what the event data is at execution time. The snippet below is pretty printed by piping the response through jq.

$ curl -vs https://3e7ux226ga.execute-api.us-west-2.amazonaws.com/v1/hello | jq .
*   Trying 52.84.237.220...
* TCP_NODELAY set
* Connected to 3e7ux226ga.execute-api.us-west-2.amazonaws.com (52.84.237.220) port 443 (#0)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
* Server certificate: *.execute-api.us-west-2.amazonaws.com
* Server certificate: Amazon
* Server certificate: Amazon Root CA 1
* Server certificate: Starfield Services Root Certificate Authority - G2
> GET /v1/hello HTTP/1.1
> Host: 3e7ux226ga.execute-api.us-west-2.amazonaws.com
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: application/json
< Content-Length: 1137
< Connection: keep-alive
< Date: Mon, 29 Jan 2018 14:15:28 GMT
< x-amzn-RequestId: db7f5734-04fe-11e8-b264-c70ecab3a032
< Access-Control-Allow-Origin: http://spartahtml-mweagle-s3site89c05c24a06599753eb3ae4e-419zo4dp8n2d.s3-website-us-west-2.amazonaws.com
< Access-Control-Allow-Headers: Content-Type,X-Amz-Date,Authorization,X-Api-Key
< Access-Control-Allow-Methods: *
< X-Amzn-Trace-Id: sampled=0;root=1-5a6f2c80-efb0f84554384252abca6d15
< X-Cache: Miss from cloudfront
< Via: 1.1 570a1979c411cb4529fa1e711db52490.cloudfront.net (CloudFront)
< X-Amz-Cf-Id: -UsCegiR1K3vJUFyAo9IMrWGdH8rKW6UBrtJLjxZqke19r0cxMl1NA==
<
{ [1137 bytes data]
* Connection #0 to host 3e7ux226ga.execute-api.us-west-2.amazonaws.com left intact
{
  "Message": "Hello world 🌏",
  "Request": {
    "method": "GET",
    "body": {},
    "headers": {
      "Accept": "*/*",
      "CloudFront-Forwarded-Proto": "https",
      "CloudFront-Is-Desktop-Viewer": "true",
      "CloudFront-Is-Mobile-Viewer": "false",
      "CloudFront-Is-SmartTV-Viewer": "false",
      "CloudFront-Is-Tablet-Viewer": "false",
      "CloudFront-Viewer-Country": "US",
      "Host": "3e7ux226ga.execute-api.us-west-2.amazonaws.com",
      "User-Agent": "curl/7.54.0",
      "Via": "1.1 570a1979c411cb4529fa1e711db52490.cloudfront.net (CloudFront)",
      "X-Amz-Cf-Id": "vAFNTV5uAMeTG9JN6IORnA7LYJhZyB3jHV7vh-7lXn2uZQUR6eHQUw==",
      "X-Amzn-Trace-Id": "Root=1-5a6f2c80-2b48a9c86a30b0162d8ab1f1",
      "X-Forwarded-For": "73.118.138.121, 205.251.214.60",
      "X-Forwarded-Port": "443",
      "X-Forwarded-Proto": "https"
    },
    "queryParams": {},
    "pathParams": {},
    "context": {
      "appId": "",
      "method": "GET",
      "requestId": "db7f5734-04fe-11e8-b264-c70ecab3a032",
      "resourceId": "401s9n",
      "resourcePath": "/hello",
      "stage": "v1",
      "identity": {
        "accountId": "",
        "apiKey": "",
        "caller": "",
        "cognitoAuthenticationProvider": "",
        "cognitoAuthenticationType": "",
        "cognitoIdentityId": "",
        "cognitoIdentityPoolId": "",
        "sourceIp": "73.118.138.121",
        "user": "",
        "userAgent": "curl/7.54.0",
        "userArn": ""
      }
    }
  }
}

While this demonstrates that our lambda function is publicly accessible, it’s not immediately obvious where the *event data is being populated.

Mapping Templates

The event data that’s actually supplied to echoS3Event is the complete HTTP request body. This content is what the API Gateway sends to our lambda function, which is defined by the integration mapping. This event data also includes the values of any whitelisted parameters. When the API Gateway Method is defined, it optionally includes any whitelisted query params and header values that should be forwarded to the integration target. For this example, we’re not whitelisting any params, so those fields (queryParams, pathParams) are empty. Then for each integration target (which can be AWS Lambda, a mock, or a HTTP Proxy), it’s possible to transform the API Gateway request data and whitelisted arguments into a format that’s more amenable to the target.

Sparta uses a pass-through template that passes all valid data, with minor Body differences based on the inbound Content-Type:

application/json

#*
Provide an automatic pass through template that transforms all inputs
into the JSON payload sent to a golang function. The JSON behavior attempts to parse
the incoming HTTP body as JSON assign it to the `body` field.

See
  https://forums.aws.amazon.com/thread.jspa?threadID=220274&tstart=0
  http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html
*#
{
  "method": "$context.httpMethod",
  "body" : $input.json('$'),
  "headers": {
    #foreach($param in $input.params().header.keySet())
    "$param": "$util.escapeJavaScript($input.params().header.get($param))" #if($foreach.hasNext),#end
    #end
  },
  "queryParams": {
    #foreach($param in $input.params().querystring.keySet())
    "$param": "$util.escapeJavaScript($input.params().querystring.get($param))" #if($foreach.hasNext),#end

    #end
  },
  "pathParams": {
    #foreach($param in $input.params().path.keySet())
    "$param": "$util.escapeJavaScript($input.params().path.get($param))" #if($foreach.hasNext),#end
    #end
  },
  "context" : {
    "apiId" : "$util.escapeJavaScript($context.apiId)",
    "method" : "$util.escapeJavaScript($context.httpMethod)",
    "requestId" : "$util.escapeJavaScript($context.requestId)",
    "resourceId" : "$util.escapeJavaScript($context.resourceId)",
    "resourcePath" : "$util.escapeJavaScript($context.resourcePath)",
    "stage" : "$util.escapeJavaScript($context.stage)",
    "identity" : {
      "accountId" : "$util.escapeJavaScript($context.identity.accountId)",
      "apiKey" : "$util.escapeJavaScript($context.identity.apiKey)",
      "caller" : "$util.escapeJavaScript($context.identity.caller)",
      "cognitoAuthenticationProvider" : "$util.escapeJavaScript($context.identity.cognitoAuthenticationProvider)",
      "cognitoAuthenticationType" : "$util.escapeJavaScript($context.identity.cognitoAuthenticationType)",
      "cognitoIdentityId" : "$util.escapeJavaScript($context.identity.cognitoIdentityId)",
      "cognitoIdentityPoolId" : "$util.escapeJavaScript($context.identity.cognitoIdentityPoolId)",
      "sourceIp" : "$util.escapeJavaScript($context.identity.sourceIp)",
      "user" : "$util.escapeJavaScript($context.identity.user)",
      "userAgent" : "$util.escapeJavaScript($context.identity.userAgent)",
      "userArn" : "$util.escapeJavaScript($context.identity.userArn)"
    }
  },
   "authorizer": {
     #foreach($param in $context.authorizer.keySet())
     "$param": "$util.escapeJavaScript($context.authorizer.get($param))" #if($foreach.hasNext),#end
     #end
   }
}

* (Default Content-Type)

#*
Provide an automatic pass through template that transforms all inputs
into the JSON payload sent to a golang function. The default behavior passes the 'body'
key as raw string.

See
  https://forums.aws.amazon.com/thread.jspa?threadID=220274&tstart=0
  http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html
*#
{
  "method": "$context.httpMethod",
  "body" : "$input.path('$')",
  "headers": {
    #foreach($param in $input.params().header.keySet())
    "$param": "$util.escapeJavaScript($input.params().header.get($param))" #if($foreach.hasNext),#end
    #end
  },
  "queryParams": {
    #foreach($param in $input.params().querystring.keySet())
    "$param": "$util.escapeJavaScript($input.params().querystring.get($param))" #if($foreach.hasNext),#end

    #end
  },
  "pathParams": {
    #foreach($param in $input.params().path.keySet())
    "$param": "$util.escapeJavaScript($input.params().path.get($param))" #if($foreach.hasNext),#end

    #end
  },
  "context" : {
    "apiId" : "$util.escapeJavaScript($context.apiId)",
    "method" : "$util.escapeJavaScript($context.httpMethod)",
    "requestId" : "$util.escapeJavaScript($context.requestId)",
    "resourceId" : "$util.escapeJavaScript($context.resourceId)",
    "resourcePath" : "$util.escapeJavaScript($context.resourcePath)",
    "stage" : "$util.escapeJavaScript($context.stage)",
    "identity" : {
      "accountId" : "$util.escapeJavaScript($context.identity.accountId)",
      "apiKey" : "$util.escapeJavaScript($context.identity.apiKey)",
      "caller" : "$util.escapeJavaScript($context.identity.caller)",
      "cognitoAuthenticationProvider" : "$util.escapeJavaScript($context.identity.cognitoAuthenticationProvider)",
      "cognitoAuthenticationType" : "$util.escapeJavaScript($context.identity.cognitoAuthenticationType)",
      "cognitoIdentityId" : "$util.escapeJavaScript($context.identity.cognitoIdentityId)",
      "cognitoIdentityPoolId" : "$util.escapeJavaScript($context.identity.cognitoIdentityPoolId)",
      "sourceIp" : "$util.escapeJavaScript($context.identity.sourceIp)",
      "user" : "$util.escapeJavaScript($context.identity.user)",
      "userAgent" : "$util.escapeJavaScript($context.identity.userAgent)",
      "userArn" : "$util.escapeJavaScript($context.identity.userArn)"
    }
  },
   "authorizer": {
     #foreach($param in $context.authorizer.keySet())
     "$param": "$util.escapeJavaScript($context.authorizer.get($param))" #if($foreach.hasNext),#end
     #end
   }
}

The default mapping templates forwards all whitelisted data & body to the lambda function. You can see by switching on the method field would allow a single function to handle different HTTP methods.

The next example shows how to unmarshal this data and perform request-specific actions.

Proxying Envelope

Because the integration request returned a successful response, the API Gateway response body contains only our lambda’s output ($input.json('$.body')).

To return an error that API Gateway can properly translate into an HTTP status code, use an apigateway.NewErrorResponse type. This custom error type includes fields that trigger integration mappings based on the inline HTTP StatusCode. The proper error code is extracted by lifting the code value from the Lambda’s response body and using a template override

If you look at the Integration Response section of the /hello/world/test resource in the Console, you’ll see a list of Regular Expression matches:

Cleanup

Before moving on, remember to decommission the service via:

go run application.go delete

Wrapping Up

Now that we know what data is actually being sent to our API Gateway-connected Lambda function, we’ll move on to performing a more complex operation, including returning a custom HTTP response body.

Notes