Skip to content

Commit

Permalink
Add aws.InvokeFunctionWithParams() for invocations
Browse files Browse the repository at this point in the history
Provides a more general solution for other types of Lambda
invocations.
  • Loading branch information
kbalk committed Mar 26, 2021
1 parent 7fa2fc5 commit be649ef
Show file tree
Hide file tree
Showing 2 changed files with 168 additions and 44 deletions.
126 changes: 90 additions & 36 deletions modules/aws/lambda.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,68 @@ package aws

import (
"encoding/json"
"errors"
"fmt"

"github.com/aws/aws-sdk-go/service/lambda"
"github.com/gruntwork-io/terratest/modules/testing"
"github.com/stretchr/testify/require"
)

// LambdaOptions contains additional parameters for InvokeFunctionWithParams().
// It contains a subset of the fields found in the lambda.InvokeInput struct.
type LambdaOptions struct {
// FunctionName is a required field containing the lambda function name.
FunctionName *string

// InvocationType can be one of "RequestResponse" or "DryRun".
// * RequestResponse (default) - Invoke the function synchronously.
// Keep the connection open until the function returns a response
// or times out.
//
// * DryRun - Validate parameter values and verify that the user or
// role has permission to invoke the function.
InvocationType *string

// Lambda function input; will be converted to JSON.
Payload interface{}
}

// LambdaOutput contains the output from InvokeFunctionWithParams(). The
// fields may or may not have a value depending on the invocation type and
// whether an error occurred or not.
type LambdaOutput struct {
// If present, indicates that an error occurred during function execution.
// Error details are included in the response payload.
FunctionError *string

// The response from the function, or an error object.
Payload []byte

// The HTTP status code for a successful request is in the 200 range.
// For RequestResponse invocation type, the status code is 200.
// For the DryRun invocation type, the status code is 204.
StatusCode *int64
}

// InvokeFunction invokes a lambda function.
func InvokeFunction(t testing.TestingT, region, functionName string, payload interface{}) []byte {
out, err := InvokeFunctionE(t, region, functionName, payload)
input := &LambdaOptions{
FunctionName: &functionName,
Payload: &payload,
}
out, err := InvokeFunctionWithParams(t, region, input)
require.NoError(t, err)
return out
return out.Payload
}

// InvokeFunctionE invokes a lambda function.
func InvokeFunctionE(t testing.TestingT, region, functionName string, payload interface{}) ([]byte, error) {
lambdaClient, err := NewLambdaClientE(t, region)
if err != nil {
return nil, err
}

invokeInput := &lambda.InvokeInput{
input := &LambdaOptions{
FunctionName: &functionName,
Payload: &payload,
}

if payload != nil {
payloadJson, err := json.Marshal(payload)

if err != nil {
return nil, err
}
invokeInput.Payload = payloadJson
}

out, err := lambdaClient.Invoke(invokeInput)
require.NoError(t, err)
out, err := InvokeFunctionWithParams(t, region, input)
if err != nil {
return nil, err
}
Expand All @@ -49,35 +75,63 @@ func InvokeFunctionE(t testing.TestingT, region, functionName string, payload in
return out.Payload, nil
}

// InvokeDryRun invokes a lambda function with an invocation type of DryRun
// and returns a status code of 204 if the invocation is permissable. Any
// other status code indicates an error and further details should be available
// in the returned error.
func InvokeDryRunE(t testing.TestingT, region, functionName string, payload interface{}) (int, error) {
// InvokeFunctionWithParams invokes a lambda function using parameters
// supplied in the LambdaOptions struct and returns values in a LambdaOutput
// struct.
func InvokeFunctionWithParams(t testing.TestingT, region string, input *LambdaOptions) (*LambdaOutput, error) {
lambdaClient, err := NewLambdaClientE(t, region)
if err != nil {
// 401 - Unauthorized.
return 401, err
return nil, err
}

typeDryRun := lambda.InvocationTypeDryRun
invokeInput := &lambda.InvokeInput{
FunctionName: &functionName,
InvocationType: &typeDryRun,
// The function name is a required field in LambdaOptions. If missing,
// report the error.
if input.FunctionName == nil {
msg := "LambdaOptions.FunctionName is a required field"
return &LambdaOutput{FunctionError: &msg}, errors.New(msg)
}

if payload != nil {
payloadJson, err := json.Marshal(payload)
// Verify the InvocationType is one of the allowed values and report
// an error if its not. By default the InvocationType will be
// "RequestResponse".
invocationType := lambda.InvocationTypeRequestResponse
if input.InvocationType != nil {
switch *input.InvocationType {
case
lambda.InvocationTypeRequestResponse,
lambda.InvocationTypeDryRun:
invocationType = *input.InvocationType
default:
msg := fmt.Sprintf("LambdaOptions.InvocationType, if specified, must either be \"%s\" or \"%s\"",
lambda.InvocationTypeRequestResponse,
lambda.InvocationTypeDryRun)
return &LambdaOutput{FunctionError: &msg}, errors.New(msg)
}
}

invokeInput := &lambda.InvokeInput{
FunctionName: input.FunctionName,
InvocationType: &invocationType,
}

if input.Payload != nil {
payloadJson, err := json.Marshal(input.Payload)
if err != nil {
// 400 - Bad Request
return 400, err
return nil, err
}
invokeInput.Payload = payloadJson
}

out, err := lambdaClient.Invoke(invokeInput)
return int(*out.StatusCode), err

// As this function supports different invocation types, so it must
// support different combinations of output.
lambdaOutput := LambdaOutput{
FunctionError: out.FunctionError,
Payload: out.Payload,
StatusCode: out.StatusCode,
}
return &lambdaOutput, err
}

type FunctionError struct {
Expand Down
86 changes: 78 additions & 8 deletions test/terraform_aws_lambda_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,22 +52,92 @@ func TestTerraformAwsLambdaExample(t *testing.T) {
assert.Equal(t, `"hi!"`, string(response))

// Invoke the function, this time causing it to error and capturing the error
response, err := aws.InvokeFunctionE(t, awsRegion, functionName, ExampleFunctionPayload{ShouldFail: true, Echo: "hi!"})
_, err := aws.InvokeFunctionE(t, awsRegion, functionName, ExampleFunctionPayload{ShouldFail: true, Echo: "hi!"})

// Function-specific errors have their own special return
functionError, ok := err.(*aws.FunctionError)
require.True(t, ok)

// Make sure the function-specific error comes back
assert.Contains(t, string(functionError.Payload), "Failed to handle")
}

// Annother example of how to test the Terraform module in
// examples/terraform-aws-lambda-example using Terratest, this time with
// the aws.InvokeFunctionWithParams.
func TestTerraformAwsLambdaWithParamsExample(t *testing.T) {
t.Parallel()

// Give this lambda function a unique ID for a name so we can distinguish it from any other lambdas
// in your AWS account
functionName := fmt.Sprintf("terratest-aws-lambda-withparams-example-%s", random.UniqueId())

// Pick a random AWS region to test in. This helps ensure your code works in all regions.
awsRegion := aws.GetRandomStableRegion(t, nil, nil)

// Construct the terraform options with default retryable errors to handle the most common retryable errors in
// terraform testing.
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
// The path to where our Terraform code is located
TerraformDir: "../examples/terraform-aws-lambda-example",

// Variables to pass to our Terraform code using -var options
Vars: map[string]interface{}{
"function_name": functionName,
},

// Environment variables to set when running Terraform
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": awsRegion,
},
})

// At the end of the test, run `terraform destroy` to clean up any resources that were created
defer terraform.Destroy(t, terraformOptions)

// This will run `terraform init` and `terraform apply` and fail the test if there are any errors
terraform.InitAndApply(t, terraformOptions)

// Conduct a "DryRun" invocation to confirm that the user has
// permission to invoke the function. A "DryRun" invocation does
// not execute the function, so the example test function will not
// be checking the payload.
statusCode, err := aws.InvokeDryRunE(t, awsRegion, functionName, ExampleFunctionPayload{ShouldFail: true, Echo: "bye!"})
require.NoError(t, err)
assert.Equal(t, statusCode, 204)
// Call InvokeFunctionWithParms with an InvocationType of "DryRun".
// A "DryRun" invocation does not execute the function, so the example
// test function will not be checking the payload.
invocationType := "DryRun"
input := &aws.LambdaOptions{
FunctionName: &functionName,
InvocationType: &invocationType,
}
out, err := aws.InvokeFunctionWithParams(t, awsRegion, input)

// With "DryRun", there's no message in the output, but there is
// a status code which will have a value of 204 for a successful
// invocation.
require.Nil(t, err)
assert.Equal(t, int(*out.StatusCode), 204)

// Call InvokeFunctionWithParams with a LambdaOptions struct that's
// missing a function name. The function should fail.
input = &aws.LambdaOptions{
Payload: ExampleFunctionPayload{ShouldFail: false, Echo: "hi!"},
}
out, err = aws.InvokeFunctionWithParams(t, awsRegion, input)
require.NotNil(t, err)
msg := "LambdaOptions.FunctionName is a required field"
assert.Contains(t, err.Error(), msg)
assert.Contains(t, *out.FunctionError, msg)

// Call InvokeFunctionWithParams with a LambdaOptions struct with an
// unsupported InvocationType. The function should fail.
invocationType = "Event"
input = &aws.LambdaOptions{
FunctionName: &functionName,
InvocationType: &invocationType,
Payload: ExampleFunctionPayload{ShouldFail: false, Echo: "hi!"},
}
out, err = aws.InvokeFunctionWithParams(t, awsRegion, input)
require.NotNil(t, err)
msg = "LambdaOptions.InvocationType, if specified, must either be \"RequestResponse\" or \"DryRun\""
assert.Contains(t, err.Error(), msg)
assert.Contains(t, *out.FunctionError, msg)
}

type ExampleFunctionPayload struct {
Expand Down

0 comments on commit be649ef

Please sign in to comment.