Skip to content

Commit

Permalink
feat: Add AWS Lambda Invoke method (#71)
Browse files Browse the repository at this point in the history
Co-authored-by: Théo Crevon <[email protected]>
Co-authored-by: oleiade <[email protected]>
  • Loading branch information
3 people authored Nov 2, 2023
1 parent 59783c7 commit 5133698
Show file tree
Hide file tree
Showing 8 changed files with 270 additions and 19 deletions.
32 changes: 16 additions & 16 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
version: "3.3"
version: '3.3'

services:
localstack:
container_name: "k6-jslib-aws-localstack"
image: "localstack/localstack:2.0.2"
ports:
- "127.0.0.1:4566:4566" # LocalStack Gateway
- "127.0.0.1:4510-4559:4510-4559" # external services port range
environment:
- SERVICES=s3,secretsmanager,sqs,kms,ssm,kinesis
- S3_SKIP_SIGNATURE_VALIDATION=0 # enable signature validation
- DEBUG=${DEBUG-}
- PERSISTENCE=${PERSISTENCE-}
volumes:
- "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack"
- "./localstack/init:/etc/localstack/init"
- "/var/run/docker.sock:/var/run/docker.sock"
localstack:
container_name: 'k6-jslib-aws-localstack'
image: 'localstack/localstack:2.0.2'
ports:
- '127.0.0.1:4566:4566' # LocalStack Gateway
- '127.0.0.1:4510-4559:4510-4559' # external services port range
environment:
- SERVICES=s3,secretsmanager,sqs,kms,ssm,kinesis,lambda
- S3_SKIP_SIGNATURE_VALIDATION=0 # enable signature validation
- DEBUG=${DEBUG-}
- PERSISTENCE=${PERSISTENCE-}
volumes:
- '${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack'
- './localstack/init:/etc/localstack/init'
- '/var/run/docker.sock:/var/run/docker.sock'
21 changes: 21 additions & 0 deletions localstack/init/ready.d/lambda.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/bash

FUNCTION_NAME="test-jslib-aws-lambda"

# Create a dummy lambda function responding with a static string "Hello World!"
cat >index.js <<EOF
exports.handler = async function(event, context) {
return "Hello World!";
}
EOF

# Create a zip file containing the lambda function
zip lambda.zip index.js

# Create a dummy lambda function responding with a static string "Hello World!"
awslocal lambda create-function \
--function-name "$FUNCTION_NAME" \
--runtime nodejs18.x \
--handler index.handler \
--zip-file fileb://lambda.zip \
--role arn:aws:iam::123456789012:role/irrelevant
175 changes: 175 additions & 0 deletions src/internal/lambda.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import http, { RefinedResponse, ResponseType } from 'k6/http'

import { AWSClient } from './client'
import { AWSConfig } from './config'
import { AWSError } from './error'
import { JSONObject } from './json'
import { InvalidSignatureError, SignatureV4 } from './signature'
import { AMZ_TARGET_HEADER } from './constants'
import { HTTPHeaders, HTTPMethod } from './http'

/**
* Class allowing to interact with Amazon AWS's Lambda service
*/
export class LambdaClient extends AWSClient {
method: HTTPMethod

commonHeaders: HTTPHeaders

signature: SignatureV4

constructor(awsConfig: AWSConfig) {
super(awsConfig, 'lambda')

this.signature = new SignatureV4({
service: this.serviceName,
region: this.awsConfig.region,
credentials: {
accessKeyId: this.awsConfig.accessKeyId,
secretAccessKey: this.awsConfig.secretAccessKey,
sessionToken: this.awsConfig.sessionToken,
},
uriEscapePath: true,
applyChecksum: false,
})

this.method = 'POST'
this.commonHeaders = {
'Content-Type': 'application/x-amz-json-1.1',
}
}

/**
* Invoke an AWS Lambda function
*
* @param {InvokeInput} input - The input for the PutEvents operation.
* @throws {LambdaServiceError}
* @throws {InvalidSignatureError}
*/
async invoke(input: InvokeInput) {
const qualifier = input.Qualifier ? `?Qualifier=${input.Qualifier}` : ''
const headers = {
...this.commonHeaders,
[AMZ_TARGET_HEADER]: `AWSLambda.${input.InvocationType}`,
'X-Amz-Invocation-Type': input.InvocationType,
'X-Amz-Log-Type': input.LogType || 'None',
};

if (input.ClientContext) {
headers['X-Amz-Client-Context'] = input.ClientContext
}

const signedRequest = this.signature.sign(
{
method: this.method,
endpoint: this.endpoint,
path: `/2015-03-31/functions/${input.FunctionName}/invocations${qualifier}`,
headers,
body: JSON.stringify(input.Payload ?? ''),
},
{}
)

const res = await http.asyncRequest(this.method, signedRequest.url, signedRequest.body, {
headers: signedRequest.headers,
})
this._handle_error(LambdaOperation.Invoke, res)

if(input.InvocationType === 'Event') {
return
}

return res.json()
}

_handle_error(
operation: LambdaOperation,
response: RefinedResponse<ResponseType | undefined>
) {
const errorCode = response.error_code
if (errorCode === 0) {
return
}

const error = response.json() as JSONObject
if (errorCode >= 1400 && errorCode <= 1499) {
// In the event of certain errors, the message is not set.
// Also, note the inconsistency in casing...
const errorMessage: string =
(error.Message as string) || (error.message as string) || (error.__type as string)

// Handle specifically the case of an invalid signature
if (error.__type === 'InvalidSignatureException') {
throw new InvalidSignatureError(errorMessage, error.__type)
}

// Otherwise throw a standard service error
throw new LambdaServiceError(errorMessage, error.__type as string, operation)
}

if (errorCode === 1500) {
throw new LambdaServiceError(
'An error occured on the server side',
'InternalServiceError',
operation
)
}
}
}

enum LambdaOperation {
Invoke = 'Invoke',
}


/**
* Represents the input for an Invoke operation.
*/
interface InvokeInput {
/**
* The name of the Lambda function, version, or alias.
*
* Supported names formats:
* - Function name: `my-function` (name-only), `my-function:v1` (with alias).
* - Function ARM: `arn:aws:lambda:us-west-2:123456789012:function:my-function`.
* - Partial ARN: `123456789012:function:my-function`.
*/
FunctionName: string
/**
* Defines whether the function is invoked synchronously or asynchronously.
* - `RequestResponse` (default): Invoke the function synchronously.
* - `Event`: Invoke the function asynchronously.
* - `DryRun`: Validate parameter values and verify that the user or role has permission to invoke the function.
*/
InvocationType: 'RequestResponse' | 'Event' | 'DryRun'
/**
* Set to `Tail` to include the execution log in the response. Applies to synchronously invoked functions only.
*/
LogType?: 'None' | 'Tail'
/**
* Up to 3,583 bytes of base64-encoded data about the invoking client to pass to the function in the context object.
*/
ClientContext?: string
/**
* Specify a version or alias to invoke a published version of the function.
*/
Qualifier?: string
Payload?: string
}

export class LambdaServiceError extends AWSError {
operation: LambdaOperation

/**
* Constructs a LambdaServiceError
*
* @param {string} message - human readable error message
* @param {string} code - A unique short code representing the error that was emitted
* @param {string} operation - Name of the failed Operation
*/
constructor(message: string, code: string, operation: LambdaOperation) {
super(message, code)
this.name = 'LambdaServiceError'
this.operation = operation
}
}
7 changes: 7 additions & 0 deletions src/lambda.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Re-Export public symbols
export { AWSConfig, InvalidAWSConfigError } from './internal/config'
export { InvalidSignatureError } from './internal/signature'
export {
LambdaServiceError,
LambdaClient
} from './internal/lambda'
2 changes: 2 additions & 0 deletions tests/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { kinesisTestSuite } from './internal/kinesis.js'
import { signatureV4TestSuite } from './internal/signature.js'
import { sqsTestSuite } from './internal/sqs.js'
import { eventBridgeTestSuite } from './internal/event-bridge.js'
import { lambdaTestSuite } from './internal/lambda.js'

// Must know:
// * end2end tests such as these rely on the localstack
Expand Down Expand Up @@ -94,4 +95,5 @@ export default async function testSuite() {
await ssmTestSuite(testData)
await kinesisTestSuite(testData)
await eventBridgeTestSuite(testData)
await lambdaTestSuite(testData)
}
43 changes: 43 additions & 0 deletions tests/internal/lambda.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { asyncDescribe } from './helpers.js'
import { LambdaServiceError, LambdaClient } from '../../build/lambda.js'

const functionName = 'test-jslib-aws-lambda';

export async function lambdaTestSuite(data) {
const lambda = new LambdaClient(data.awsConfig)

await asyncDescribe('lambda.invoke - RequestResponse', async (expect) => {
let lambdaError

try {
const result = await lambda.invoke({
FunctionName: functionName,
InvocationType: 'RequestResponse',
})

expect(result).to.be.string('Hello World!')
} catch (error) {
lambdaError = error
}

expect(lambdaError).to.be.undefined
})

await asyncDescribe('lambda.invoke - Event', async (expect) => {
let lambdaError

try {
const result = await lambda.invoke({
FunctionName: functionName,
InvocationType: 'Event',
Payload: { foo: 'bar' },
})

expect(result).to.be.undefined
} catch (error) {
lambdaError = error
}

expect(lambdaError).to.be.undefined
})
}
8 changes: 5 additions & 3 deletions tests/internal/s3.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ export async function s3TestSuite(data) {
s3Client.endpoint = s3Endpoint

await asyncDescribe('s3.listBuckets', async (expect) => {
let buckets;
// Act
const buckets = await s3Client.listBuckets()
buckets = await s3Client.listBuckets()

// Assert
expect(buckets).to.be.an('array')
expect(buckets).to.have.lengthOf(1)
expect(buckets[0].name).to.equal(data.s3.testBucketName)
// Because other tests may have created buckets, we can't assume there is only one bucket.
expect(buckets).to.have.lengthOf.above(1)
expect(buckets.map((b) => b.name)).to.contain(data.s3.testBucketName)
})

await asyncDescribe('s3.listObjects', async (expect) => {
Expand Down
1 change: 1 addition & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ module.exports = {
kms: path.resolve(__dirname, 'src/kms.ts'),
kinesis: path.resolve(__dirname, 'src/kinesis.ts'),
'event-bridge': path.resolve(__dirname, 'src/event-bridge.ts'),
lambda: path.resolve(__dirname, 'src/lambda.ts'),

// AWS signature v4
signature: path.resolve(__dirname, 'src/signature.ts'),
Expand Down

0 comments on commit 5133698

Please sign in to comment.