Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Event Bridge #55

Merged
merged 10 commits into from
Sep 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ If the jslib-aws does not support the service you need yet, the best way to get
2. The project files should be formatted using the [Prettier](https://prettier.io/) tool. The project has a `.prettierrc.json` file containing the tool's configuration.
3. The project uses webpack to produce build files. Because k6 does not have native support typescript, and its Javascript runtime does not support the `import` statement, the project uses webpack to produce a file containing all the code that k6 scripts can use. The project has a `webpack.config.js` file containing the tool's configuration. Each service is built into its dedicated file, and an overarching `aws.js` contains them for convenience.
4. To allow easier testing, files in the `src` directory are organized in a public/private structure. Files at the root of the folder are the public files; they import the content of the internal (private) directory and explicitly export the symbols that should be made available to the user. The internal files are the ones that contain the actual implementation of the service. The internal files are not exported and are not meant to be used directly by the user.
5. The project is tested in an end2end fashion using the [k6](https://k6.io/) tool. The tests live in the `tests` directory. The tests are written in Javascript and use the [k6 chai js](https://k6.io/docs/javascript-api/jslib/k6chaijs/) jslib to test the functionality of the library. The `npm test` command runs the test suites. The tests run in a docker container, and the `docker-compose.yml` file contains the configuration for the container. The docker-compose setup spins up a [localstack](https://https://github.com/localstack/localstack) setup, which emulates AWS locally, and the test script performs its assertions against it directly.
5. The project is tested in an end2end fashion using the [k6](https://k6.io/) tool. The tests live in the `tests` directory. The tests are written in Javascript and use the [k6 chai js](https://k6.io/docs/javascript-api/jslib/k6chaijs/) jslib to test the functionality of the library. The `npm test` command runs the test suites. The tests run in a docker container, and the `docker-compose.yml` file contains the configuration for the container. The docker-compose setup spins up a [localstack](https://https://github.com/localstack/localstack) setup, which emulates AWS locally, and the test script performs its assertions against it directly.

### Conventions

Expand All @@ -21,15 +21,16 @@ If the jslib-aws does not support the service you need yet, the best way to get
### How to add a new service

1. Create a new file in the `src/internal` directory. The file's name should be the service's name in [kebab-case](https://en.wikipedia.org/wiki/Letter_case#Kebab_case). For example, if you want to add support for the `AWS Systems Manager` service, the file should be named `systems-manager.ts`.
2. The file should expose a service client class following the above conventions. The class should inherit from `AWSClient`, and its constructor should take an `AWSConfig` object as its only argument.
2. The file should expose a service client class following the above conventions. The class should inherit from `AWSClient`, and its constructor should take an `AWSConfig` object as its only argument.
3. For each service operation (action) the author wishes to implement, the service class should implement a dedicated public method.
4. Most service operations require signing requests using the AWS V4 signature process. The `SignatureV4` construct exposed in the [`src/internal/signature.ts`](https://github.com/grafana/k6-jslib-aws/blob/main/src/internal/signature.ts#L9) file allows signing requests to AWS services easily. Checkout the existing service implementations for examples of how to use it: [in S3Client](https://github.com/grafana/k6-jslib-aws/blob/main/src/internal/s3.ts#L48), and [in SecretsManagerService](https://github.com/grafana/k6-jslib-aws/blob/main/src/internal/secrets-manager.ts#L63) for instance.
5. Tests verifying that the service class works as expected should be added in the `tests/internal` directory. The dedicated test file should follow the same naming convention as the service class file, except it should have the `.js` extension. For example, if the service class file is named `systems-manager.ts`, the test file should be called `systems-manager.js`.
6. Test files should consists in a k6 script using the [k6 chai js](https://k6.io/docs/javascript-api/jslib/k6chaijs/) library, and exporting a single `{serviceName}TestSuite(data)` function. This function should consist of a set of `describe` statements containing the actual test assertions, as demonstrated in the [existing s3 test suite](https://github.com/grafana/k6-jslib-aws/blob/main/tests/internal/s3.js). The test suite should be imported and called in the [`tests/internal/index.js`](https://github.com/grafana/k6-jslib-aws/blob/main/tests/index.js) test script, which is the entry point for the test suite.
7. If the tests depend on a specific pre-existing state of the localstack setup, you can add a dedicated script in the `tests/internal/localstack_init` folder. Localstack will execute all the commands present in this script during its setup phase.
8. The `npm test` command runs the test suite. This command will build the project and run the tests against the spun-up localstack docker container. The `docker-compose.yml` file contains the configuration for the container.
9. Once the tests pass, the `src/index.ts` file should export the service class in the `src/index.ts` file so the user can use it.
10. To get the build system to produce a dedicated
5. Add a new [entry](https://webpack.js.org/concepts/entry-points/) in `webpack.config.js` to the service you created in Step 1.
6. Tests verifying that the service class works as expected should be added in the `tests/internal` directory. The dedicated test file should follow the same naming convention as the service class file, except it should have the `.js` extension. For example, if the service class file is named `systems-manager.ts`, the test file should be called `systems-manager.js`.
7. Test files should consists in a k6 script using the [k6 chai js](https://k6.io/docs/javascript-api/jslib/k6chaijs/) library, and exporting a single `{serviceName}TestSuite(data)` function. This function should consist of a set of `describe` statements containing the actual test assertions, as demonstrated in the [existing s3 test suite](https://github.com/grafana/k6-jslib-aws/blob/main/tests/internal/s3.js). The test suite should be imported and called in the [`tests/internal/index.js`](https://github.com/grafana/k6-jslib-aws/blob/main/tests/index.js) test script, which is the entry point for the test suite.
8. If the tests depend on a specific pre-existing state of the localstack setup, you can add a dedicated script in the `tests/internal/localstack_init` folder. Localstack will execute all the commands present in this script during its setup phase.
9. The `npm test` command runs the test suite. This command will build the project and run the tests against the spun-up localstack docker container. The `docker-compose.yml` file contains the configuration for the container.
10. Once the tests pass, the `src/index.ts` file should export the service class in the `src/index.ts` file so the user can use it.
11. To get the build system to produce a build of your new service, run `npm run webpack`. Make sure that you commit the new build-related files too.

### Publishing a new version

Expand All @@ -47,6 +48,7 @@ If the jslib-aws does not support the service you need yet, the best way to get
### Prepare a version PR

In a PR:

1. Bump the version in the `package.json` file.
2. Run the `npm update` command to update the `package-lock.json` file.
3. Run the `npm run webpack` command to ensure the build system produces the latest distributable files.
Expand All @@ -55,13 +57,13 @@ In a PR:

1. Tag the latest main branch's commit with the new version, following the [semantic versioning](https://semver.org/) convention, prefixed with a `v` character, as in: `v0.7.1`.
2. Create a dedicated GitHub version:
1. Pointing to the tag created above
2. Using the same name as the tag created above
3. Including the build files included in the commit tagged above
1. Pointing to the tag created above
2. Using the same name as the tag created above
3. Including the build files included in the commit tagged above

### Publishing the new version

1. Open a PR on the [jslib repository](https://github.com/grafana/jslib.k6.io) using the `build/` directory files following the [new version instructions](https://github.com/grafana/jslib.k6.io#updating-a-version-of-a-js-package-listed-in-packagejson-dependencies).
2. Make sure the k6 documentation website is updated to include the new version of the library:
1. The documented API should reflect the new version.
2. All the links to the library should point to the new version.
1. The documented API should reflect the new version.
2. All the links to the library should point to the new version.
3 changes: 3 additions & 0 deletions build/event-bridge.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions build/event-bridge.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/event-bridge.js.map

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/event-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { AWSConfig, InvalidAWSConfigError } from './internal/config'
export { EventBridgeClient, EventBridgeServiceError } from './internal/event-bridge'
export { InvalidSignatureError } from './internal/signature'
165 changes: 165 additions & 0 deletions src/internal/event-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
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 Event Bridge service
*/
export class EventBridgeClient extends AWSClient {
method: HTTPMethod

commonHeaders: HTTPHeaders

signature: SignatureV4

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

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',
}
}

/**
* Sends custom events to Amazon EventBridge so that they can be matched to rules.
*
* @param {PutEventsInput} input - The input for the PutEvents operation.
* @throws {EventBridgeServiceError}
* @throws {InvalidSignatureError}
*/
async putEvents(input: PutEventsInput) {
const parsedEvent = {
...input,
Entries: input.Entries.map((entry) => ({
...entry,
Detail: JSON.stringify(entry.Detail),
})),
}

const signedRequest = this.signature.sign(
{
method: this.method,
protocol: this.awsConfig.scheme,
hostname: this.host,
path: '/',
headers: {
...this.commonHeaders,
[AMZ_TARGET_HEADER]: `AWSEvents.PutEvents`,
},
body: JSON.stringify(parsedEvent),
},
{}
)

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

_handle_error(
operation: EventBridgeOperation,
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 EventBridgeServiceError(errorMessage, error.__type as string, operation)
}

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

enum EventBridgeOperation {
PutEvents = 'PutEvents',
}

/**
* Represents an event to be submitted.
*
* @typedef {Object} PutEventEntry
*
* @property {string} Detail - A valid serialized JSON object. There is no other schema imposed. The JSON object may contain fields and nested sub-objects.
* @property {string} DetailType - Free-form string, with a maximum of 128 characters, used to decide what fields to expect in the event detail.
* @property {string} EventBusName - The name or ARN of the event bus to receive the event. Only the rules that are associated with this event bus are used to match the event. If you omit this, the default event bus is used.
* @property {string[]} Resources - AWS resources, identified by Amazon Resource Name (ARN), which the event primarily concerns. Any number, including zero, may be present.
* @property {string} Source - The source of the event.
*/
interface PutEventEntry {
Source: string
Detail: JSONObject
DetailType: string
EventBusName?: string
Resources?: [string]
}

/**
* Represents the input for a put events operation.
*
* @typedef {Object} PutEventsInput
*
* @property {string} [EndpointId] - The optional URL subdomain of the endpoint.
* @property {PutEventEntry[]} Entries - An array of entries that defines an event in your system.
*/
interface PutEventsInput {
EndpointId?: string
Entries: PutEventEntry[]
}

export class EventBridgeServiceError extends AWSError {
operation: EventBridgeOperation

/**
* Constructs a EventBridgeServiceError
*
* @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: EventBridgeOperation) {
super(message, code)
this.name = 'EventBridgeServiceError'
this.operation = operation
}
}
34 changes: 34 additions & 0 deletions src/internal/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* @module json
* @description
*
* This module provides types and interfaces to ensure type-safety
* when working with JSON structures in TypeScript. It ensures that
* values, arrays, and objects conform to the JSON format, preventing
* the use of non-JSON-safe values.
*/

/**
* Represents a valid JSON value. In JSON, values must be one of:
* string, number, boolean, null, array or object.
*
* @type JSONValue
*/
export type JSONValue = string | number | boolean | null | JSONArray | JSONObject;

/**
* Represents a valid JSON array. A JSON array is a list of `JSONValue` items.
* This extends the built-in Array type to ensure that all its members are
* valid JSON values.
*
* @interface JSONArray
*/
export interface JSONArray extends Array<JSONValue> { }

/**
* Represents a valid JSON object. A JSON object is a collection of key-value
* pairs, where each key is a string and each value is a `JSONValue`.
*
* @interface JSONObject
*/
export interface JSONObject { [key: string]: JSONValue; }
18 changes: 10 additions & 8 deletions tests/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ssmTestSuite } from './internal/ssm.js'
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'

// Must know:
// * end2end tests such as these rely on the localstack
Expand Down Expand Up @@ -84,12 +85,13 @@ const testData = {
},
}

export default async function testSuite() {
signatureV4TestSuite(testData)
await s3TestSuite(testData)
await secretsManagerTestSuite(testData)
await kmsTestSuite(testData)
await sqsTestSuite(testData)
await ssmTestSuite(testData)
await kinesisTestSuite(testData)
export default async function testSuite(data) {
signatureV4TestSuite(data)
await s3TestSuite(data)
await secretsManagerTestSuite(data)
await kmsTestSuite(data)
await sqsTestSuite(data)
await ssmTestSuite(data)
await kinesisTestSuite(data)
await eventBridgeTestSuite(data)
}
32 changes: 32 additions & 0 deletions tests/internal/event-bridge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { asyncDescribe } from './helpers.js'
import { EventBridgeClient, EventBridgeServiceError } from '../../build/event-bridge.js'

export async function eventBridgeTestSuite(data) {
const eventBridge = new EventBridgeClient(data.awsConfig)

await asyncDescribe('eventBridge.putEvents', async (expect) => {
let putEventsError

try {
await eventBridge.putEvents({
Entries: [
{
Detail: {
id: 'id',
type: 'VIEW',
show: 5,
timestamp: '1690858574000',
},
DetailType: 'Video View',
Resources: ['goo'],
Source: 'nice',
},
],
})
} catch (error) {
putEventsError = error
}

expect(putEventsError).to.be.undefined
})
}
1 change: 1 addition & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ module.exports = {
ssm: path.resolve(__dirname, 'src/ssm.ts'),
kms: path.resolve(__dirname, 'src/kms.ts'),
kinesis: path.resolve(__dirname, 'src/kinesis.ts'),
'event-bridge': path.resolve(__dirname, 'src/event-bridge.ts'),

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