Skip to content

Commit

Permalink
Add support for Lambda (#76)
Browse files Browse the repository at this point in the history
  • Loading branch information
nickcaballero authored Nov 17, 2023
1 parent 37eddb3 commit 6bf3253
Show file tree
Hide file tree
Showing 34 changed files with 238 additions and 160 deletions.
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ At the moment, this library provides the following:
- `SSM`: allows to retrieve a parameter from AWS Systems Manager
- `Kinesis`: allows to list streams, create streams, put records, list shards, get shard iterators, and get records from AWS Kinesis.
- `EventBridge`: allows to put events to AWS EventBridge.
- `Lambda`: allows to invoke functions in AWS Lambda.
- `V4 signature`: allows to sign requests to amazon AWS services
- `KinesisClient`: allows all APIs for Kinesis available by AWS.

Expand Down Expand Up @@ -375,6 +376,34 @@ export default async function () {
}
```

### Lambda

Consult the `LambdaClient` [dedicated k6 documentation page](https://k6.io/docs/javascript-api/jslib/aws/lambdaclient) for more details on its methods and how to use it.

```javascript
import { AWSConfig, LambdaClient } from 'https://jslib.k6.io/aws/0.11.0/lambda.js'
import { check } from 'k6';

const awsConfig = new AWSConfig({
region: __ENV.AWS_REGION,
accessKeyId: __ENV.AWS_ACCESS_KEY_ID,
secretAccessKey: __ENV.AWS_SECRET_ACCESS_KEY,
sessionToken: __ENV.AWS_SESSION_TOKEN,
})

const lambdaClient = new LambdaClient(awsConfig)

export default async function () {
const response = await lambdaClient.invoke('add-numbers', JSON.stringify({x: 1, y: 2}))

check(response, {
'status is 200': (r) => r.statusCode === 200,
'payload is 3': (r) => r.payload === 3,
})
}

```

## Development

### Contributing
Expand Down
2 changes: 1 addition & 1 deletion build/aws.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/aws.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/event-bridge.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/event-bridge.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/kinesis.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/kinesis.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/kms.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/kms.js.map

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions build/lambda.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions build/lambda.js.LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
1 change: 1 addition & 0 deletions build/lambda.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/s3.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/s3.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/secrets-manager.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/secrets-manager.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/signature.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/signature.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/sqs.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/sqs.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/ssm.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/ssm.js.map

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions examples/lambda.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { AWSConfig, LambdaClient } from '../build/lambda.js'
import { check } from 'k6';

const awsConfig = new AWSConfig({
region: __ENV.AWS_REGION,
accessKeyId: __ENV.AWS_ACCESS_KEY_ID,
secretAccessKey: __ENV.AWS_SECRET_ACCESS_KEY,
sessionToken: __ENV.AWS_SESSION_TOKEN,
})

const lambdaClient = new LambdaClient(awsConfig)

export default async function () {
const response = await lambdaClient.invoke('add-numbers', JSON.stringify({x: 1, y: 2}))

check(response, {
'status is 200': (r) => r.statusCode === 200,
'payload is 3': (r) => r.payload === 3,
})
}
31 changes: 14 additions & 17 deletions localstack/init/ready.d/lambda.sh
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
#!/bin/bash

FUNCTION_NAME="test-jslib-aws-lambda"
testdata_folder="/etc/localstack/init/testdata/lambda"
zip_dir=/tmp/lambda
mkdir -p "$zip_dir"

# 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
for file in "$testdata_folder"/*; do
function_name=$(basename "$file")
function_zip="$zip_dir/$function_name.zip"
(cd "$file" || exit; zip "$function_zip" ./*)

# 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
awslocal lambda create-function \
--function-name "$function_name" \
--runtime nodejs18.x \
--zip-file "fileb://$function_zip" \
--handler index.handler \
--role arn:aws:iam::000000000000:role/lambda-role
done
3 changes: 3 additions & 0 deletions localstack/init/testdata/lambda/test-fail/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
exports.handler = async (event) => {
throw new Error(event)
};
4 changes: 4 additions & 0 deletions localstack/init/testdata/lambda/test-product/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
exports.handler = async (event) => {
console.log('received event:', JSON.stringify(event));
return event.a * event.b
};
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export {
export { SQSClient } from './sqs'
export { KinesisClient } from './internal/kinesis'
export { EventBridgeClient } from './internal/event-bridge'
export { LambdaClient, LambdaInvocationError } from './lambda'
12 changes: 12 additions & 0 deletions src/internal/error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseHTML } from 'k6/html'
import { Response } from 'k6/http'

/**
* Base class to derive errors from
Expand Down Expand Up @@ -35,4 +36,15 @@ export class AWSError extends Error {
const doc = parseHTML(xmlDocument)
return new AWSError(doc.find('Message').text(), doc.find('Code').text())
}

static parse(response: Response): AWSError {
if (response.headers['Content-Type'] === 'application/json') {
const error = response.json() as any;
const message = error.Message || error.message || error.__type || 'An error occurred on the server side';
const code = response.headers['X-Amzn-Errortype'] || error.__type
return new AWSError(message, code)
} else {
return AWSError.parseXML(response.body as string);
}
}
}
163 changes: 74 additions & 89 deletions src/internal/lambda.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
import http, { RefinedResponse, ResponseType } from 'k6/http'
import encoding from 'k6/encoding';

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'
import { HTTPHeaders, HTTPMethod, QueryParameterBag } from './http'


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

commonHeaders: HTTPHeaders

signature: SignatureV4
private readonly signature: SignatureV4
private readonly commonHeaders: HTTPHeaders
private readonly method: HTTPMethod

constructor(awsConfig: AWSConfig) {
super(awsConfig, 'lambda')
Expand All @@ -42,134 +41,120 @@ export class LambdaClient extends AWSClient {
/**
* Invoke an AWS Lambda function
*
* @param {InvokeInput} input - The input for the PutEvents operation.
* @throws {LambdaServiceError}
* @throws {InvalidSignatureError}
* @param {string} name - The name of the function
* @param {string} payload - The payload to send to function
* @param {InvocationOptions} options - Additional options to customize invocation
*
* @throws {LambdaInvocationError}
*/
async invoke(input: InvokeInput) {
const qualifier = input.Qualifier ? `?Qualifier=${input.Qualifier}` : ''
async invoke(
name: string,
payload: string,
options: InvocationOptions = {}
): Promise<InvocationResponse> {
const query: QueryParameterBag = {};
const invocationType = options.invocationType || 'RequestResponse'
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
[AMZ_TARGET_HEADER]: `AWSLambda.${invocationType}`,
'X-Amz-Invocation-Type': invocationType,
'X-Amz-Log-Type': options.logType || 'None'
}
if (options.clientContext) {
headers['X-Amz-Client-Context'] = options.clientContext
}
if (options.qualifier) {
query['Qualifier'] = options.qualifier;
}

const signedRequest = this.signature.sign(
{
method: this.method,
endpoint: this.endpoint,
path: `/2015-03-31/functions/${input.FunctionName}/invocations${qualifier}`,
path: `/2015-03-31/functions/${name}/invocations`,
query,
headers,
body: JSON.stringify(input.Payload ?? ''),
body: 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
this._handle_error(res)

const logResult = res.headers['X-Amz-Log-Result']
const response = {
executedVersion: res.headers['X-Amz-Executed-Version'],
logResult: logResult ? encoding.b64decode(logResult, 'std', 's') : undefined,
statusCode: res.status,
payload: res.body as string
}

return res.json()
const functionError = res.headers['X-Amz-Function-Error']
if (functionError) {
throw new LambdaInvocationError(functionError, response)
} else {
return response
}
}

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

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 (errorMessage == '' && errorCode === 0) {
return
}

if (errorCode === 1500) {
throw new LambdaServiceError(
'An error occured on the server side',
'InternalServiceError',
operation
)
const awsError = AWSError.parse(response)
switch (awsError.code) {
case 'AuthorizationHeaderMalformed':
case 'InvalidSignatureException':
throw new InvalidSignatureError(awsError.message, awsError.code)
default:
throw awsError
}
}
}

enum LambdaOperation {
Invoke = 'Invoke',
}
export class LambdaInvocationError extends Error {
response: InvocationResponse

constructor(message: string, response: InvocationResponse) {
super(`${message}: ${response.payload}`)
this.response = response
}
}

/**
* 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
interface InvocationOptions {
/**
* 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'
invocationType?: 'RequestResponse' | 'Event' | 'DryRun';
/**
* Set to `Tail` to include the execution log in the response. Applies to synchronously invoked functions only.
*/
LogType?: 'None' | 'Tail'
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
clientContext?: string;
/**
* Specify a version or alias to invoke a published version of the function.
*/
Qualifier?: string
Payload?: string
qualifier?: 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
}
}
interface InvocationResponse {
statusCode: number;
executedVersion?: string;
logResult?: string;
payload?: string;
}
2 changes: 1 addition & 1 deletion src/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
export { AWSConfig, InvalidAWSConfigError } from './internal/config'
export { InvalidSignatureError } from './internal/signature'
export {
LambdaServiceError,
LambdaInvocationError,
LambdaClient
} from './internal/lambda'
Loading

0 comments on commit 6bf3253

Please sign in to comment.