From 71c2eef2b920ba6ee77b63284535beb79b1ec3af Mon Sep 17 00:00:00 2001 From: Bret Ambrose Date: Tue, 3 Dec 2024 14:54:06 -0800 Subject: [PATCH 1/2] Request response browser streaming (#579) Co-authored-by: Bret Ambrose --- .builder/actions/crt_size_check.py | 2 +- lib/browser.ts | 2 + lib/browser/io.ts | 10 +- lib/browser/mqtt.ts | 9 + lib/browser/mqtt5.ts | 9 + lib/browser/mqtt_request_response.spec.ts | 661 ++++++ lib/browser/mqtt_request_response.ts | 1172 ++++++++++ .../protocol_adapter.spec.ts | 515 +++++ .../mqtt_request_response/protocol_adapter.ts | 516 +++++ .../protocol_adapter_mock.ts | 238 ++ .../subscription_manager.spec.ts | 1967 +++++++++++++++++ .../subscription_manager.ts | 653 ++++++ .../mqtt_request_response_impl.spec.ts | 1643 ++++++++++++++ lib/common/io.ts | 92 + lib/common/mqtt.spec.ts | 1 + lib/common/mqtt_request_response.ts | 238 ++ lib/common/mqtt_request_response_internal.ts | 20 + lib/common/mqtt_shared.ts | 62 + lib/index.ts | 2 + lib/native/binding.d.ts | 38 + lib/native/io.ts | 27 +- lib/native/mqtt_request_response.spec.ts | 656 ++++++ lib/native/mqtt_request_response.ts | 306 +++ source/event_stream.c | 1 - source/module.c | 224 +- source/module.h | 51 + source/mqtt5_client.c | 55 +- source/mqtt5_client.h | 4 + source/mqtt_client_connection.c | 9 + source/mqtt_client_connection.h | 6 + source/mqtt_request_response.c | 1772 +++++++++++++++ source/mqtt_request_response.h | 25 + test/browser/jest.config.js | 1 + test/mqtt5.ts | 3 +- test/mqtt_request_response.ts | 542 +++++ test/test_env.ts | 3 +- tsconfig.browser.json | 3 +- 37 files changed, 11461 insertions(+), 77 deletions(-) create mode 100644 lib/browser/mqtt_request_response.spec.ts create mode 100644 lib/browser/mqtt_request_response.ts create mode 100644 lib/browser/mqtt_request_response/protocol_adapter.spec.ts create mode 100644 lib/browser/mqtt_request_response/protocol_adapter.ts create mode 100644 lib/browser/mqtt_request_response/protocol_adapter_mock.ts create mode 100644 lib/browser/mqtt_request_response/subscription_manager.spec.ts create mode 100644 lib/browser/mqtt_request_response/subscription_manager.ts create mode 100644 lib/browser/mqtt_request_response_impl.spec.ts create mode 100644 lib/common/mqtt_request_response.ts create mode 100644 lib/common/mqtt_request_response_internal.ts create mode 100644 lib/native/mqtt_request_response.spec.ts create mode 100644 lib/native/mqtt_request_response.ts create mode 100644 source/mqtt_request_response.c create mode 100644 source/mqtt_request_response.h create mode 100644 test/mqtt_request_response.ts diff --git a/.builder/actions/crt_size_check.py b/.builder/actions/crt_size_check.py index b9c363a42..49c1d2c7d 100644 --- a/.builder/actions/crt_size_check.py +++ b/.builder/actions/crt_size_check.py @@ -11,7 +11,7 @@ def run(self, env): # Maximum package size (for current platform) in bytes # NOTE: if you increase this, you might also need to increase the # limit in continuous-delivery/pack.sh - max_size = 8_000_000 + max_size = 8_250_000 # size of current folder folder_size = 0 # total size in bytes diff --git a/lib/browser.ts b/lib/browser.ts index d373b669a..2add273e3 100644 --- a/lib/browser.ts +++ b/lib/browser.ts @@ -21,6 +21,7 @@ import * as http from './browser/http'; import * as crypto from './browser/crypto'; import * as iot from './browser/iot'; import * as auth from './browser/auth'; +import * as mqtt_request_response from './browser/mqtt_request_response'; import { ICrtError, CrtError } from './browser/error'; export { @@ -31,6 +32,7 @@ export { io, iot, mqtt, + mqtt_request_response, mqtt5, platform, promise, diff --git a/lib/browser/io.ts b/lib/browser/io.ts index cf120279f..1ce2939f7 100644 --- a/lib/browser/io.ts +++ b/lib/browser/io.ts @@ -19,8 +19,10 @@ * @mergeTarget */ -export { TlsVersion, SocketType, SocketDomain } from "../common/io"; -import { SocketType, SocketDomain } from "../common/io"; +import { setLogLevel, LogLevel, SocketType, SocketDomain } from "../common/io"; +// Do not re-export the logging functions in common; they are package-private +export { setLogLevel, LogLevel, TlsVersion, SocketType, SocketDomain } from "../common/io"; + /** * @return false, as ALPN is not configurable from the browser @@ -112,3 +114,7 @@ export class SocketOptions { public keep_alive_max_failed_probes = 0) { } } + +export function enable_logging(level: LogLevel) { + setLogLevel(level); +} diff --git a/lib/browser/mqtt.ts b/lib/browser/mqtt.ts index 6dce4749a..63ed272da 100644 --- a/lib/browser/mqtt.ts +++ b/lib/browser/mqtt.ts @@ -644,6 +644,15 @@ export class MqttClientConnection extends BufferedEventEmitter { }); } + /** + * Queries whether the client is currently connected + * + * @returns whether the client is currently connected + */ + is_connected() : boolean { + return this.currentState == MqttBrowserClientState.Connected; + } + private on_connect = (connack: mqtt.IConnackPacket) => { this.on_online(connack.sessionPresent); } diff --git a/lib/browser/mqtt5.ts b/lib/browser/mqtt5.ts index 434e366da..1fbdb2c77 100644 --- a/lib/browser/mqtt5.ts +++ b/lib/browser/mqtt5.ts @@ -623,6 +623,15 @@ export class Mqtt5Client extends BufferedEventEmitter implements mqtt5.IMqtt5Cli }); } + /** + * Queries whether the client is currently connected + * + * @returns whether the client is currently connected + */ + isConnected() : boolean { + return this.lifecycleEventState == Mqtt5ClientLifecycleEventState.Connected; + } + /** * Event emitted when the client encounters a disruptive error condition. Not currently used. * diff --git a/lib/browser/mqtt_request_response.spec.ts b/lib/browser/mqtt_request_response.spec.ts new file mode 100644 index 000000000..b4559f6eb --- /dev/null +++ b/lib/browser/mqtt_request_response.spec.ts @@ -0,0 +1,661 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + + +import * as auth from "./auth"; +import * as test_env from "@test/test_env" +import * as aws_iot_311 from "./aws_iot"; +import * as aws_iot_5 from "./aws_iot_mqtt5"; +import * as mqtt5 from "./mqtt5"; +import * as mqtt_request_response from "./mqtt_request_response"; +import {once} from "events"; +import * as mrr_test from "@test/mqtt_request_response"; +import {v4 as uuid} from "uuid"; +import * as test_utils from "../../test/mqtt5"; + +jest.setTimeout(10000); + +function getTestingCredentials() : auth.AWSCredentials { + let credentials : auth.AWSCredentials = { + aws_access_id: test_utils.ClientEnvironmentalConfig.AWS_IOT_ACCESS_KEY_ID, + aws_secret_key: test_utils.ClientEnvironmentalConfig.AWS_IOT_SECRET_ACCESS_KEY, + aws_region: test_env.AWS_IOT_ENV.MQTT5_REGION + }; + + if (test_utils.ClientEnvironmentalConfig.AWS_IOT_SESSION_TOKEN !== "") { + credentials.aws_sts_token = test_utils.ClientEnvironmentalConfig.AWS_IOT_SESSION_TOKEN; + } + + return credentials; +} + +function createClientBuilder5() : aws_iot_5.AwsIotMqtt5ClientConfigBuilder { + let credentials : auth.AWSCredentials = getTestingCredentials(); + let provider = new auth.StaticCredentialProvider(credentials); + + let builder = aws_iot_5.AwsIotMqtt5ClientConfigBuilder.newWebsocketMqttBuilderWithSigv4Auth(test_env.AWS_IOT_ENV.MQTT5_HOST, { + credentialsProvider: provider, + region: test_env.AWS_IOT_ENV.MQTT5_REGION + }); + + return builder; +} + +function createClientBuilder311() : aws_iot_311.AwsIotMqttConnectionConfigBuilder { + let credentials : auth.AWSCredentials = getTestingCredentials(); + let provider = new auth.StaticCredentialProvider(credentials); + + let builder = aws_iot_311.AwsIotMqttConnectionConfigBuilder.new_with_websockets(); + + builder.with_endpoint(test_env.AWS_IOT_ENV.MQTT5_HOST); + builder.with_client_id(`node-mqtt-unit-test-${uuid()}`) + builder.with_credential_provider(provider); + + return builder; +} + +function initClientBuilderFactories() { + // @ts-ignore + mrr_test.setClientBuilderFactories(createClientBuilder5, createClientBuilder311); +} + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Create Destroy Mqtt5', async () => { + initClientBuilderFactories(); + let context = new mrr_test.TestingContext({ + version: mrr_test.ProtocolVersion.Mqtt5 + }); + await context.open(); + + await context.close(); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Create Destroy Mqtt311', async () => { + initClientBuilderFactories(); + let context = new mrr_test.TestingContext({ + version: mrr_test.ProtocolVersion.Mqtt311 + }); + await context.open(); + + await context.close(); +}); + + + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Success Rejected Mqtt5', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_success_rejected_test(mrr_test.ProtocolVersion.Mqtt5, true); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Success Rejected Mqtt311', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_success_rejected_test(mrr_test.ProtocolVersion.Mqtt311, true); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Success Rejected No CorrelationToken Mqtt5', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_success_rejected_test(mrr_test.ProtocolVersion.Mqtt5, false); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Success Rejected No CorrelationToken Mqtt311', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_success_rejected_test(mrr_test.ProtocolVersion.Mqtt311, false); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('UpdateNamedShadow Success Accepted Mqtt5', async () => { + initClientBuilderFactories(); + await mrr_test.do_update_named_shadow_success_accepted_test(mrr_test.ProtocolVersion.Mqtt5, true); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('UpdateNamedShadow Success Accepted Mqtt311', async () => { + initClientBuilderFactories(); + await mrr_test.do_update_named_shadow_success_accepted_test(mrr_test.ProtocolVersion.Mqtt311, true); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('UpdateNamedShadow Success Accepted No CorrelationToken Mqtt5', async () => { + initClientBuilderFactories(); + await mrr_test.do_update_named_shadow_success_accepted_test(mrr_test.ProtocolVersion.Mqtt5, false); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('UpdateNamedShadow Success Accepted No CorrelationToken Mqtt311', async () => { + initClientBuilderFactories(); + await mrr_test.do_update_named_shadow_success_accepted_test(mrr_test.ProtocolVersion.Mqtt311, false); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Timeout Mqtt5', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_timeout_test(mrr_test.ProtocolVersion.Mqtt5, true); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Timeout Mqtt311', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_timeout_test(mrr_test.ProtocolVersion.Mqtt311, true); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Timeout No CorrelationToken Mqtt5', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_timeout_test(mrr_test.ProtocolVersion.Mqtt5, false); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Timeout No CorrelationToken Mqtt311', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_timeout_test(mrr_test.ProtocolVersion.Mqtt311, false); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure On Close Mqtt5', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_on_close_test(mrr_test.ProtocolVersion.Mqtt5, "closed"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure On Close Mqtt311', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_on_close_test(mrr_test.ProtocolVersion.Mqtt311, "closed"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure zero max request response subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_no_max_request_response_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure zero max request response subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_no_max_request_response_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure invalid max request response subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_invalid_max_request_response_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure invalid max request response subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_invalid_max_request_response_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure undefined config mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_undefined_config, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure undefined config mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_undefined_config, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure undefined max request response subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_undefined_max_request_response_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure undefined max request response subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_undefined_max_request_response_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure null max request response subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_null_max_request_response_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure null max request response subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_null_max_request_response_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure missing max request response subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_missing_max_request_response_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure missing max request response subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_missing_max_request_response_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure undefined max streaming subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_undefined_max_streaming_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure undefined max streaming subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_undefined_max_streaming_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure null max streaming subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_null_max_streaming_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure null max streaming subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_null_max_streaming_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure missing max streaming subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_missing_max_streaming_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure missing max streaming subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_missing_max_streaming_subscriptions, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure missing max streaming subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_invalid_operation_timeout, "Invalid client options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Client creation failure missing max streaming subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_invalid_operation_timeout, "Invalid client options"); +}); + + +test('Client creation failure null protocol client mqtt311', async() => { + let config : mqtt_request_response.RequestResponseClientOptions = { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions : 2, + operationTimeoutInSeconds : 5, + }; + + // @ts-ignore + expect(() => {mqtt_request_response.RequestResponseClient.newFromMqtt311(null, config)}).toThrow("protocol client is null"); +}); + +test('Client creation failure null protocol client mqtt5', async() => { + let config : mqtt_request_response.RequestResponseClientOptions = { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions : 2, + operationTimeoutInSeconds : 5, + }; + + // @ts-ignore + expect(() => {mqtt_request_response.RequestResponseClient.newFromMqtt5(null, config)}).toThrow("protocol client is null"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure No Subscription Topic Filters', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + delete new_options.subscriptionTopicFilters; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Null Subscription Topic Filters', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.subscriptionTopicFilters = null; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Subscription Topic Filters Not An Array', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.subscriptionTopicFilters = "null"; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Subscription Topic Filters Empty', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.subscriptionTopicFilters = []; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure No Response Paths', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + delete new_options.responsePaths; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Null Response Paths', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.responsePaths = null; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Response Paths Not An Array', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.responsePaths = "null"; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Response Paths Empty', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.responsePaths = []; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Response Path No Topic', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + delete new_options.responsePaths[0].topic; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Response Path Null Topic', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.responsePaths[0].topic = null; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Response Path Bad Topic Type', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.responsePaths[0].topic = 5; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Response Path Null Correlation Token Json Path', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.responsePaths[0].correlationTokenJsonPath = null; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Response Path Bad Correlation Token Json Path Type', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.responsePaths[0].correlationTokenJsonPath = {}; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure No Publish Topic', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + delete new_options.publishTopic; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Null Publish Topic', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.publishTopic = null; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Bad Publish Topic Type', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.publishTopic = {someValue: null}; + + return new_options; + }); +}); + + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure No Payload', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + delete new_options.payload; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Null Payload', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.payload = null; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Null Correlation Token', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.correlationToken = null; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Bad Correlation Token Type', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.correlationToken = ["something"]; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Protocol Invalid Topic', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + new_options.publishTopic = "#/illegal/#/topic"; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Null Options', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "Invalid request options", + // @ts-ignore + (options : mqtt_request_response.RequestResponseOperationOptions) => { + return null; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('GetNamedShadow Failure Submit After Close', async () => { + initClientBuilderFactories(); + let context = new mrr_test.TestingContext({ + version: mrr_test.ProtocolVersion.Mqtt5 + }); + + await context.open(); + await context.close(); + + let requestOptions = mrr_test.createRejectedGetNamedShadowRequest(true); + try { + await context.client.submitRequest(requestOptions); + expect(false); + } catch (err: any) { + expect(err.message).toContain("already been closed"); + } +}); + +////////////////////////////////////////////// +// Streaming Ops NYI + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('ShadowUpdated Streaming Operation Success Open/Close MQTT5', async () => { + initClientBuilderFactories(); + await mrr_test.do_streaming_operation_new_open_close_test(mrr_test.ProtocolVersion.Mqtt5); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('ShadowUpdated Streaming Operation Success Open/Close MQTT311', async () => { + initClientBuilderFactories(); + await mrr_test.do_streaming_operation_new_open_close_test(mrr_test.ProtocolVersion.Mqtt311); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('ShadowUpdated Streaming Operation Success Incoming Publish MQTT5', async () => { + initClientBuilderFactories(); + await mrr_test.do_streaming_operation_incoming_publish_test(mrr_test.ProtocolVersion.Mqtt5); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('ShadowUpdated Streaming Operation Success Incoming Publish MQTT311', async () => { + initClientBuilderFactories(); + await mrr_test.do_streaming_operation_incoming_publish_test(mrr_test.ProtocolVersion.Mqtt311); +}); + +// We only have a 5-based test because there's no way to stop the 311 client without destroying it in the process. +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('ShadowUpdated Streaming Operation Success Subscription Events MQTT5', async () => { + + await mrr_test.do_streaming_operation_subscription_events_test({ + version: mrr_test.ProtocolVersion.Mqtt5, + builder_mutator5: (builder) => { + builder.withSessionBehavior(mqtt5.ClientSessionBehavior.Clean); + return builder; + } + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Streaming Operation Failure Reopen', async () => { + let context = new mrr_test.TestingContext({ + version: mrr_test.ProtocolVersion.Mqtt5 + }); + + await context.open(); + + let topic_filter = `not/a/real/shadow/${uuid()}`; + let streaming_options : mqtt_request_response.StreamingOperationOptions = { + subscriptionTopicFilter : topic_filter, + } + + let stream = context.client.createStream(streaming_options); + + let initialSubscriptionComplete = once(stream, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + stream.open(); + + await initialSubscriptionComplete; + + stream.open(); + + stream.close(); + + // multi-opening or multi-closing are fine, but opening after a close is not + expect(() => {stream.open()}).toThrow(); + + stream.close(); + + await context.close(); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Streaming Operation Auto Close', async () => { + let context = new mrr_test.TestingContext({ + version: mrr_test.ProtocolVersion.Mqtt5 + }); + + await context.open(); + + let topic_filter = `not/a/real/shadow/${uuid()}`; + let streaming_options : mqtt_request_response.StreamingOperationOptions = { + subscriptionTopicFilter : topic_filter, + } + + let stream = context.client.createStream(streaming_options); + + let initialSubscriptionComplete = once(stream, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + stream.open(); + + await initialSubscriptionComplete; + + stream.open(); + + await context.close(); + + // Closing the client should close the operation automatically; verify that by verifying that open now generates + // an exception + expect(() => {stream.open()}).toThrow(); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Streaming Operation Creation Failure Null Options', async () => { + // @ts-ignore + await mrr_test.do_invalid_streaming_operation_config_test(null, "Invalid streaming options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Streaming Operation Creation Failure Undefined Options', async () => { + // @ts-ignore + await mrr_test.do_invalid_streaming_operation_config_test(undefined, "Invalid streaming options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Streaming Operation Creation Failure Null Filter', async () => { + await mrr_test.do_invalid_streaming_operation_config_test({ + // @ts-ignore + subscriptionTopicFilter : null, + }, "Invalid streaming options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Streaming Operation Creation Failure Invalid Filter Type', async () => { + await mrr_test.do_invalid_streaming_operation_config_test({ + // @ts-ignore + subscriptionTopicFilter : 5, + }, "Invalid streaming options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_cred())('Streaming Operation Creation Failure Invalid Filter Value', async () => { + await mrr_test.do_invalid_streaming_operation_config_test({ + subscriptionTopicFilter : "#/hello/#", + }, "Invalid streaming options"); +}); diff --git a/lib/browser/mqtt_request_response.ts b/lib/browser/mqtt_request_response.ts new file mode 100644 index 000000000..7f1f82511 --- /dev/null +++ b/lib/browser/mqtt_request_response.ts @@ -0,0 +1,1172 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +/** + * + * @packageDocumentation + * @module mqtt_request_response + * @mergeTarget + * + */ + +import * as protocol_client_adapter from "./mqtt_request_response/protocol_adapter"; +import * as subscription_manager from "./mqtt_request_response/subscription_manager"; +import {MqttClientConnection} from "./mqtt"; +import {Mqtt5Client} from "./mqtt5"; +import * as mqtt_request_response from "../common/mqtt_request_response"; +import * as mqtt_request_response_internal from "../common/mqtt_request_response_internal"; +import {BufferedEventEmitter} from "../common/event"; +import {CrtError} from "./error"; +import {LiftedPromise, newLiftedPromise} from "../common/promise"; +import * as io from "../common/io"; +import * as mqtt_shared from "../common/mqtt_shared"; + +export * from "../common/mqtt_request_response"; + +enum OperationState { + /* creation -> in event loop enqueue */ + None, + + /* in event loop queue -> non blocked response from subscription manager */ + Queued, + + /* subscribing response from sub manager -> subscription success/failure event */ + PendingSubscription, + + /* (request only) subscription success -> (publish failure OR correlated response received) */ + PendingResponse, + + /* (streaming only) subscription success -> (operation finished OR subscription ended event) */ + Subscribed, + + /* (streaming only) (subscription failure OR subscription ended) -> operation close/terminate */ + Terminal, +} + +function operationStateToString(state: OperationState) { + switch(state) { + case OperationState.None: + return "None"; + case OperationState.Queued: + return "Queued"; + case OperationState.PendingSubscription: + return "PendingSubscription"; + case OperationState.PendingResponse: + return "PendingResponse"; + case OperationState.Subscribed: + return "Subscribed"; + case OperationState.Terminal: + return "Terminal"; + default: + return "Unknown"; + } +} + +enum OperationType { + RequestResponse, + Streaming +} + +interface Operation { + id: number, + type: OperationType, + state: OperationState, + pendingSubscriptionCount: number, + inClientTables: boolean +} + +interface RequestResponseOperation extends Operation { + options: mqtt_request_response.RequestResponseOperationOptions, + resultPromise: LiftedPromise +} + +interface StreamingOperation extends Operation { + options: mqtt_request_response.StreamingOperationOptions, + operation: StreamingOperationInternal, +} + +interface ResponsePathEntry { + refCount: number, + correlationTokenPath?: string[], +} + +interface ServiceTaskWrapper { + serviceTask : ReturnType; + nextServiceTime : number; +} + +function areClientOptionsValid(options: mqtt_request_response.RequestResponseClientOptions) : boolean { + if (!options) { + return false; + } + + if (!options.maxRequestResponseSubscriptions) { + return false; + } + + if (!Number.isInteger(options.maxRequestResponseSubscriptions)) { + return false; + } + + if (options.maxRequestResponseSubscriptions < 2) { + return false; + } + + if (!options.maxStreamingSubscriptions) { + return false; + } + + if (!Number.isInteger(options.maxStreamingSubscriptions)) { + return false; + } + + if (options.operationTimeoutInSeconds) { + if (!Number.isInteger(options.operationTimeoutInSeconds)) { + return false; + } + + if (options.operationTimeoutInSeconds <= 0) { + return false; + } + } + + return true; +} + +interface StreamingOperationInternalOptions { + close: () => void, + open: () => void +} + +/** + * An AWS MQTT service streaming operation. A streaming operation listens to messages on + * a particular topic, deserializes them using a service model, and emits the modeled data as Javascript events. + */ +export class StreamingOperationBase extends BufferedEventEmitter implements mqtt_request_response.IStreamingOperation { + + private internalOptions: StreamingOperationInternalOptions; + private state = mqtt_request_response_internal.StreamingOperationState.None; + + constructor(options: StreamingOperationInternalOptions) { + super(); + this.internalOptions = options; + } + + /** + * Triggers the streaming operation to start listening to the configured stream of events. Has no effect on an + * already-open operation. It is an error to attempt to re-open a closed streaming operation. + */ + open() : void { + if (this.state == mqtt_request_response_internal.StreamingOperationState.None) { + this.internalOptions.open(); + this.state = mqtt_request_response_internal.StreamingOperationState.Open; + } else if (this.state == mqtt_request_response_internal.StreamingOperationState.Closed) { + throw new CrtError("MQTT streaming operation already closed"); + } + } + + /** + * Stops a streaming operation from listening to the configured stream of events + */ + close(): void { + if (this.state != mqtt_request_response_internal.StreamingOperationState.Closed) { + this.state = mqtt_request_response_internal.StreamingOperationState.Closed; + this.internalOptions.close(); + } + } + + /** + * Event emitted when the stream's subscription status changes. + * + * Listener type: {@link SubscriptionStatusListener} + * + * @event + */ + static SUBSCRIPTION_STATUS : string = 'subscriptionStatus'; + + /** + * Event emitted when a stream message is received + * + * Listener type: {@link IncomingPublishListener} + * + * @event + */ + static INCOMING_PUBLISH : string = 'incomingPublish'; + + on(event: 'subscriptionStatus', listener: mqtt_request_response.SubscriptionStatusListener): this; + + on(event: 'incomingPublish', listener: mqtt_request_response.IncomingPublishListener): this; + + on(event: string | symbol, listener: (...args: any[]) => void): this { + super.on(event, listener); + return this; + } +} + +class StreamingOperationInternal extends StreamingOperationBase { + + private constructor(options: StreamingOperationInternalOptions) { + super(options); + } + + static newInternal(options: StreamingOperationInternalOptions) : StreamingOperationInternal { + let operation = new StreamingOperationInternal(options); + + return operation; + } + + triggerIncomingPublishEvent(publishEvent: mqtt_request_response.IncomingPublishEvent) : void { + process.nextTick(() => { + this.emit(StreamingOperationBase.INCOMING_PUBLISH, publishEvent); + }); + } + + triggerSubscriptionStatusUpdateEvent(statusEvent: mqtt_request_response.SubscriptionStatusEvent) : void { + process.nextTick(() => { + this.emit(StreamingOperationBase.SUBSCRIPTION_STATUS, statusEvent); + }); + } +} + +/** + * Native implementation of an MQTT-based request-response client tuned for AWS MQTT services. + * + * Supports streaming operations (listen to a stream of modeled events from an MQTT topic) and request-response + * operations (performs the subscribes, publish, and incoming publish correlation and error checking needed to + * perform simple request-response operations over MQTT). + */ +export class RequestResponseClient extends BufferedEventEmitter implements mqtt_request_response.IRequestResponseClient { + + private static logSubject = "RequestResponseClient"; + + private readonly operationTimeoutInSeconds: number; + private nextOperationId : number = 1; + private protocolClientAdapter : protocol_client_adapter.ProtocolClientAdapter; + private subscriptionManager : subscription_manager.SubscriptionManager; + private state : mqtt_request_response_internal.RequestResponseClientState = mqtt_request_response_internal.RequestResponseClientState.Ready; + private serviceTask? : ServiceTaskWrapper; + + private operations : Map = new Map(); + private streamingOperationsByTopicFilter : Map> = new Map>(); // topic filter -> set of operation ids + private correlationTokenPathsByResponsePaths : Map = new Map(); // response topic -> response path entry + private operationsByCorrelationToken : Map = new Map(); // correlation token -> operation id + + private operationQueue : Array = new Array; + + constructor(protocolClientAdapter: protocol_client_adapter.ProtocolClientAdapter, options: mqtt_request_response.RequestResponseClientOptions) { + if (!areClientOptionsValid(options)) { + throw new CrtError("Invalid client options passed to RequestResponseClient constructor"); + } + + super(); + + this.operationTimeoutInSeconds = options.operationTimeoutInSeconds ?? 60; + this.protocolClientAdapter = protocolClientAdapter; + + this.protocolClientAdapter.addListener(protocol_client_adapter.ProtocolClientAdapter.PUBLISH_COMPLETION, this.handlePublishCompletionEvent.bind(this)); + this.protocolClientAdapter.addListener(protocol_client_adapter.ProtocolClientAdapter.CONNECTION_STATUS, this.handleConnectionStatusEvent.bind(this)); + this.protocolClientAdapter.addListener(protocol_client_adapter.ProtocolClientAdapter.INCOMING_PUBLISH, this.handleIncomingPublishEvent.bind(this)); + + let config : subscription_manager.SubscriptionManagerConfig = { + maxRequestResponseSubscriptions: options.maxRequestResponseSubscriptions, + maxStreamingSubscriptions: options.maxStreamingSubscriptions, + operationTimeoutInSeconds: this.operationTimeoutInSeconds, + } + + this.subscriptionManager = new subscription_manager.SubscriptionManager(protocolClientAdapter, config); + + this.subscriptionManager.addListener(subscription_manager.SubscriptionManager.SUBSCRIBE_SUCCESS, this.handleSubscribeSuccessEvent.bind(this)); + this.subscriptionManager.addListener(subscription_manager.SubscriptionManager.SUBSCRIBE_FAILURE, this.handleSubscribeFailureEvent.bind(this)); + this.subscriptionManager.addListener(subscription_manager.SubscriptionManager.SUBSCRIPTION_ENDED, this.handleSubscriptionEndedEvent.bind(this)); + this.subscriptionManager.addListener(subscription_manager.SubscriptionManager.STREAMING_SUBSCRIPTION_ESTABLISHED, this.handleStreamingSubscriptionEstablishedEvent.bind(this)); + this.subscriptionManager.addListener(subscription_manager.SubscriptionManager.STREAMING_SUBSCRIPTION_LOST, this.handleStreamingSubscriptionLostEvent.bind(this)); + this.subscriptionManager.addListener(subscription_manager.SubscriptionManager.STREAMING_SUBSCRIPTION_HALTED, this.handleStreamingSubscriptionHaltedEvent.bind(this)); + this.subscriptionManager.addListener(subscription_manager.SubscriptionManager.SUBSCRIPTION_ORPHANED, this.handleSubscriptionOrphanedEvent.bind(this)); + this.subscriptionManager.addListener(subscription_manager.SubscriptionManager.UNSUBSCRIBE_COMPLETE, this.handleUnsubscribeCompleteEvent.bind(this)); + } + + /** + * Creates a new MQTT service request-response client that uses an MQTT5 client as the protocol implementation. + * + * @param protocolClient protocol client to use for all operations + * @param options configuration options for the desired request-response client + */ + static newFromMqtt5(protocolClient: Mqtt5Client, options: mqtt_request_response.RequestResponseClientOptions): RequestResponseClient { + if (!protocolClient) { + throw new CrtError("protocol client is null"); + } + + let adapter = protocol_client_adapter.ProtocolClientAdapter.newFrom5(protocolClient); + let client = new RequestResponseClient(adapter, options); + + return client; + } + + /** + * Creates a new MQTT service request-response client that uses an MQTT311 client as the protocol implementation. + * + * @param protocolClient protocol client to use for all operations + * @param options configuration options for the desired request-response client + */ + static newFromMqtt311(protocolClient: MqttClientConnection, options: mqtt_request_response.RequestResponseClientOptions) : RequestResponseClient { + if (!protocolClient) { + throw new CrtError("protocol client is null"); + } + + let adapter = protocol_client_adapter.ProtocolClientAdapter.newFrom311(protocolClient); + let client = new RequestResponseClient(adapter, options); + + return client; + } + + /** + * Triggers cleanup of native resources associated with the request-response client. Closing a client will fail + * all incomplete requests and close all outstanding streaming operations. + * + * This must be called when finished with a client; otherwise, native resources will leak. + */ + close(): void { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Closed) { + io.logInfo(RequestResponseClient.logSubject, `closing MQTT RequestResponseClient`); + this.state = mqtt_request_response_internal.RequestResponseClientState.Closed; + this.closeAllOperations(); + + this.protocolClientAdapter.close(); + this.subscriptionManager.close(); + } + } + + /** + * Submits a request to the request-response client. + * + * @param requestOptions description of the request to perform + * + * Returns a promise that resolves to a response to the request or an error describing how the request attempt + * failed. + * + * A "successful" request-response execution flow is defined as "the service sent a response payload that + * correlates with the request payload." Upon deserialization (which is the responsibility of the service model + * client, one layer up), such a payload may actually indicate a failure. + */ + async submitRequest(requestOptions: mqtt_request_response.RequestResponseOperationOptions): Promise { + let resultPromise : LiftedPromise = newLiftedPromise(); + + if (this.state == mqtt_request_response_internal.RequestResponseClientState.Closed) { + resultPromise.reject(new CrtError("MQTT request-response client has already been closed")); + return resultPromise.promise; + } + + try { + validateRequestOptions(requestOptions); + } catch (err) { + resultPromise.reject(err); + return resultPromise.promise; + } + + let id = this.nextOperationId; + this.nextOperationId++; + + let operation : RequestResponseOperation = { + id: id, + type: OperationType.RequestResponse, + state: OperationState.Queued, + pendingSubscriptionCount: requestOptions.subscriptionTopicFilters.length, + inClientTables: false, + options: requestOptions, + resultPromise: resultPromise, + }; + + this.operations.set(id, operation); + this.operationQueue.push(id); + + setTimeout(() => { + try { + this.completeRequestResponseOperationWithError(id, new CrtError("Operation timeout")); + } catch (err) { + ; + } + }, this.operationTimeoutInSeconds * 1000); + + this.wakeServiceTask(); + + io.logInfo(RequestResponseClient.logSubject, `request-response operation with id "${id}" submitted to operation queue`); + + return resultPromise.promise; + } + + /** + * Creates a new streaming operation from a set of configuration options. A streaming operation provides a + * mechanism for listening to a specific event stream from an AWS MQTT-based service. + * + * @param streamOptions configuration options for the streaming operation + * + * browser/node implementers are covariant by returning an implementation of IStreamingOperation. This split + * is necessary because event listening (which streaming operations need) cannot be modeled on an interface. + */ + createStream(streamOptions: mqtt_request_response.StreamingOperationOptions) : StreamingOperationBase { + if (this.state == mqtt_request_response_internal.RequestResponseClientState.Closed) { + throw new CrtError("MQTT request-response client has already been closed"); + } + + validateStreamingOptions(streamOptions); + + let id = this.nextOperationId; + this.nextOperationId++; + + let internalOptions: StreamingOperationInternalOptions = { + open: () => { this.openStreamingOperation(id); }, + close: () => { this.closeStreamingOperation(id); }, + }; + + let internalOperation = StreamingOperationInternal.newInternal(internalOptions); + + let operation : StreamingOperation = { + id: id, + type: OperationType.Streaming, + state: OperationState.None, + pendingSubscriptionCount: 1, + inClientTables: false, + options: streamOptions, + operation: internalOperation + }; + + this.operations.set(id, operation); + + return internalOperation; + } + + private canOperationDequeue(operation: Operation) : boolean { + if (operation.type != OperationType.RequestResponse) { + return true; + } + + let rrOperation = operation as RequestResponseOperation; + let correlationToken = rrOperation.options.correlationToken ?? ""; + + return !this.operationsByCorrelationToken.has(correlationToken); + } + + private static buildSuscriptionListFromOperation(operation : Operation) : string[] { + if (operation.type == OperationType.RequestResponse) { + let rrOperation = operation as RequestResponseOperation; + return rrOperation.options.subscriptionTopicFilters; + } else { + let streamingOperation = operation as StreamingOperation; + return new Array(streamingOperation.options.subscriptionTopicFilter); + } + } + + private addOperationToInProgressTables(operation: Operation) { + if (operation.type == OperationType.Streaming) { + let streamingOperation = operation as StreamingOperation; + let filter = streamingOperation.options.subscriptionTopicFilter; + let existingSet = this.streamingOperationsByTopicFilter.get(filter); + if (!existingSet) { + existingSet = new Set(); + this.streamingOperationsByTopicFilter.set(filter, existingSet); + + io.logDebug(RequestResponseClient.logSubject, `adding topic filter "${filter}" to streaming subscriptions table`); + } + + existingSet.add(operation.id); + io.logDebug(RequestResponseClient.logSubject, `adding operation ${operation.id} to streaming subscriptions table under topic filter "${filter}"`); + } else { + let rrOperation = operation as RequestResponseOperation; + + let correlationToken = rrOperation.options.correlationToken ?? ""; + this.operationsByCorrelationToken.set(correlationToken, operation.id); + + io.logDebug(RequestResponseClient.logSubject, `operation ${operation.id} registered with correlation token "${correlationToken}"`); + + for (let path of rrOperation.options.responsePaths) { + let existingEntry = this.correlationTokenPathsByResponsePaths.get(path.topic); + if (!existingEntry) { + existingEntry = { + refCount: 0 + }; + + if (path.correlationTokenJsonPath) { + existingEntry.correlationTokenPath = path.correlationTokenJsonPath.split('.'); + } + + this.correlationTokenPathsByResponsePaths.set(path.topic, existingEntry); + + io.logDebug(RequestResponseClient.logSubject, `adding response path "${path.topic}" to response path table`); + } + + existingEntry.refCount++; + io.logDebug(RequestResponseClient.logSubject, `operation ${operation.id} adding reference to response path "${path.topic}"`); + } + } + + operation.inClientTables = true; + } + + private handleAcquireSubscriptionResult(operation: Operation, result: subscription_manager.AcquireSubscriptionResult) { + if (result == subscription_manager.AcquireSubscriptionResult.Failure || result == subscription_manager.AcquireSubscriptionResult.NoCapacity) { + this.completeOperationWithError(operation.id, new CrtError(`Acquire subscription error: ${subscription_manager.acquireSubscriptionResultToString(result)}`)); + return; + } + + this.addOperationToInProgressTables(operation); + + if (result == subscription_manager.AcquireSubscriptionResult.Subscribing) { + this.changeOperationState(operation, OperationState.PendingSubscription); + return; + } + + if (operation.type == OperationType.Streaming) { + this.changeOperationState(operation, OperationState.Subscribed); + + let streamingOperation = operation as StreamingOperation; + streamingOperation.operation.triggerSubscriptionStatusUpdateEvent({ + type: mqtt_request_response.SubscriptionStatusEventType.SubscriptionEstablished + }); + } else { + this.applyRequestResponsePublish(operation as RequestResponseOperation); + } + } + + private service() { + this.serviceTask = undefined; + + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Ready) { + return; + } + + this.subscriptionManager.purge(); + + io.logDebug(RequestResponseClient.logSubject, `servicing operation queue with ${this.operationQueue.length} entries`); + while (this.operationQueue.length > 0) { + let headId = this.operationQueue[0]; + let operation = this.operations.get(headId); + if (!operation) { + this.operationQueue.shift(); + continue; + } + + if (!this.canOperationDequeue(operation)) { + io.logDebug(RequestResponseClient.logSubject, `operation ${headId} cannot be dequeued`); + break; + } + + let acquireOptions : subscription_manager.AcquireSubscriptionConfig = { + topicFilters: RequestResponseClient.buildSuscriptionListFromOperation(operation), + operationId: headId, + type: (operation.type == OperationType.RequestResponse) ? subscription_manager.SubscriptionType.RequestResponse : subscription_manager.SubscriptionType.EventStream, + }; + + let acquireResult = this.subscriptionManager.acquireSubscription(acquireOptions); + io.logDebug(RequestResponseClient.logSubject, `servicing queued operation ${operation.id} yielded acquire subscription result of "${subscription_manager.acquireSubscriptionResultToString(acquireResult)}"`); + if (acquireResult == subscription_manager.AcquireSubscriptionResult.Blocked) { + break; + } + + this.operationQueue.shift(); + this.handleAcquireSubscriptionResult(operation, acquireResult); + } + } + + private clearServiceTask() { + if (this.serviceTask) { + clearTimeout(this.serviceTask.serviceTask); + this.serviceTask = undefined; + } + } + + private tryScheduleServiceTask(serviceTime: number) { + if (this.serviceTask) { + if (serviceTime >= this.serviceTask.nextServiceTime) { + return; + } + + this.clearServiceTask(); + } + + let futureMs = Math.max(0, Date.now() - serviceTime); + this.serviceTask = { + serviceTask: setTimeout(() => { this.service(); }, futureMs), + nextServiceTime: serviceTime, + } + + io.logDebug(RequestResponseClient.logSubject, `service task scheduled for execution in ${futureMs} MS`); + } + + private wakeServiceTask() : void { + this.tryScheduleServiceTask(Date.now()); + } + + private closeAllOperations() : void { + let operations = Array.from(this.operations).map(([key, value]) => key); + for (let id of operations) { + this.completeOperationWithError(id, new CrtError("Request-response client closed")); + } + } + + private removeStreamingOperationFromTopicFilterSet(topicFilter: string, id: number) { + let operationSet = this.streamingOperationsByTopicFilter.get(topicFilter); + if (!operationSet) { + return; + } + + operationSet.delete(id); + io.logDebug(RequestResponseClient.logSubject, `removed operation ${id} from streaming topic filter table entry for "${topicFilter}"`); + if (operationSet.size > 0) { + return; + } + + this.streamingOperationsByTopicFilter.delete(topicFilter); + io.logDebug(RequestResponseClient.logSubject, `removed streaming topic filter table entry for "${topicFilter}"`); + } + + private decRefResponsePaths(topic: string) { + let pathEntry = this.correlationTokenPathsByResponsePaths.get(topic); + if (!pathEntry) { + return; + } + + pathEntry.refCount--; + io.logDebug(RequestResponseClient.logSubject, `dec-refing response path entry for "${topic}", ${pathEntry.refCount} references left`); + if (pathEntry.refCount < 1) { + io.logDebug(RequestResponseClient.logSubject, `removing response path entry for "${topic}"`); + this.correlationTokenPathsByResponsePaths.delete(topic); + } + } + + private removeRequestResponseOperation(operation: RequestResponseOperation) { + io.logDebug(RequestResponseClient.logSubject, `removing request-response operation ${operation.id} from client state`); + this.operations.delete(operation.id); + + if (operation.inClientTables) { + for (let responsePath of operation.options.responsePaths) { + this.decRefResponsePaths(responsePath.topic); + } + + let correlationToken = operation.options.correlationToken ?? ""; + this.operationsByCorrelationToken.delete(correlationToken); + } + + let releaseOptions : subscription_manager.ReleaseSubscriptionsConfig = { + topicFilters: operation.options.subscriptionTopicFilters, + operationId: operation.id, + }; + this.subscriptionManager.releaseSubscription(releaseOptions); + } + + private removeStreamingOperation(operation: StreamingOperation) { + io.logDebug(RequestResponseClient.logSubject, `removing streaming operation ${operation.id} from client state`); + this.operations.delete(operation.id); + + if (operation.inClientTables) { + this.removeStreamingOperationFromTopicFilterSet(operation.options.subscriptionTopicFilter, operation.id); + } + + let releaseOptions : subscription_manager.ReleaseSubscriptionsConfig = { + topicFilters: new Array(operation.options.subscriptionTopicFilter), + operationId: operation.id, + }; + this.subscriptionManager.releaseSubscription(releaseOptions); + } + + private removeOperation(id: number) { + let operation = this.operations.get(id); + if (!operation) { + return; + } + + if (operation.type == OperationType.RequestResponse) { + this.removeRequestResponseOperation(operation as RequestResponseOperation); + } else { + this.removeStreamingOperation(operation as StreamingOperation); + } + } + + private completeRequestResponseOperationWithError(id: number, err: CrtError) { + let operation = this.operations.get(id); + if (!operation) { + return; + } + + io.logInfo(RequestResponseClient.logSubject, `request-response operation ${id} completed with error: "${JSON.stringify(err)}"`); + + this.removeOperation(id); + + if (operation.type != OperationType.RequestResponse) { + return; + } + + let rrOperation = operation as RequestResponseOperation; + let promise = rrOperation.resultPromise; + + promise.reject(err); + } + + private haltStreamingOperationWithError(id: number, err: CrtError) { + let operation = this.operations.get(id); + if (!operation) { + return; + } + + io.logInfo(RequestResponseClient.logSubject, `streaming operation ${id} halted with error: "${JSON.stringify(err)}"`); + + this.removeOperation(id); + + if (operation.type != OperationType.Streaming) { + return; + } + + let streamingOperation = operation as StreamingOperation; + if (operation.state != OperationState.Terminal && operation.state != OperationState.None) { + streamingOperation.operation.triggerSubscriptionStatusUpdateEvent({ + type: mqtt_request_response.SubscriptionStatusEventType.SubscriptionHalted, + error: err + }); + } + + this.changeOperationState(operation, OperationState.Terminal); + + // this is mostly a no-op except it's the only way we can guarantee that the streaming operation state also gets + // flipped to closed + streamingOperation.operation.close(); + } + + private completeOperationWithError(id: number, err: CrtError) { + let operation = this.operations.get(id); + if (!operation) { + return; + } + + if (operation.type == OperationType.RequestResponse) { + this.completeRequestResponseOperationWithError(id, err); + } else { + this.haltStreamingOperationWithError(id, err); + } + } + + private completeRequestResponseOperationWithResponse(id: number, responseTopic: string, payload: ArrayBuffer) { + let operation = this.operations.get(id); + if (!operation) { + return; + } + + io.logInfo(RequestResponseClient.logSubject, `request-response operation ${id} successfully completed with response"`); + + this.removeOperation(id); + + if (operation.type != OperationType.RequestResponse) { + return; + } + + let rrOperation = operation as RequestResponseOperation; + let promise = rrOperation.resultPromise; + + promise.resolve({ + topic: responseTopic, + payload: payload + }); + } + + private handlePublishCompletionEvent(event: protocol_client_adapter.PublishCompletionEvent) { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Ready) { + return; + } + + let id = event.completionData as number; + if (event.err) { + this.completeRequestResponseOperationWithError(id, event.err as CrtError); + } else { + io.logDebug(RequestResponseClient.logSubject, `request-response operation ${id} successfully published request payload"`); + } + } + + private handleConnectionStatusEvent(event: protocol_client_adapter.ConnectionStatusEvent) { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Ready) { + return; + } + + if (event.status == protocol_client_adapter.ConnectionState.Connected && this.operationQueue.length > 0) { + this.wakeServiceTask(); + } + } + + private handleIncomingPublishEventStreaming(event: protocol_client_adapter.IncomingPublishEvent, operations: Set) { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Ready) { + return; + } + + for (let id of operations) { + let operation = this.operations.get(id); + if (!operation) { + continue; + } + + if (operation.type != OperationType.Streaming) { + continue; + } + + let streamingOperation = operation as StreamingOperation; + streamingOperation.operation.triggerIncomingPublishEvent({ + payload: event.payload + }); + } + } + + private handleIncomingPublishEventRequestResponse(event: protocol_client_adapter.IncomingPublishEvent, responsePathEntry: ResponsePathEntry) { + + io.logDebug(RequestResponseClient.logSubject, `processing incoming publish event on response path topic "${event.topic}"`); + if (!event.payload) { + io.logError(RequestResponseClient.logSubject, `incoming publish on response path topic "${event.topic}" has no payload`); + return; + } + + try { + let correlationToken : string | undefined = undefined; + + if (!responsePathEntry.correlationTokenPath) { + correlationToken = ""; + } else { + let payloadAsString = new TextDecoder().decode(new Uint8Array(event.payload)); + let payloadAsJson = JSON.parse(payloadAsString); + let segmentValue : any = payloadAsJson; + for (let segment of responsePathEntry.correlationTokenPath) { + let segmentPropertyValue = segmentValue[segment]; + if (!segmentPropertyValue) { + io.logError(RequestResponseClient.logSubject, `incoming publish on response path topic "${event.topic}" does not have a correlation token at the expected JSON path`); + break; + } + + segmentValue = segmentValue[segment]; + } + + if (segmentValue && typeof(segmentValue) === "string") { + correlationToken = segmentValue as string; + } + } + + if (correlationToken === undefined) { + io.logError(RequestResponseClient.logSubject, `A valid correlation token could not be inferred for incoming publish on response path topic "${event.topic}"`); + return; + } + + let id = this.operationsByCorrelationToken.get(correlationToken); + if (!id) { + io.logDebug(RequestResponseClient.logSubject, `incoming publish on response path topic "${event.topic}" with correlation token "${correlationToken}" does not have an originating request entry`); + return; + } + + this.completeRequestResponseOperationWithResponse(id, event.topic, event.payload); + } catch (err) { + io.logError(RequestResponseClient.logSubject, `incoming publish on response path topic "${event.topic}" triggered exception: ${JSON.stringify(err)}`); + } + } + + private handleIncomingPublishEvent(event: protocol_client_adapter.IncomingPublishEvent) { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Ready) { + return; + } + + let responsePathEntry = this.correlationTokenPathsByResponsePaths.get(event.topic); + if (responsePathEntry) { + this.handleIncomingPublishEventRequestResponse(event, responsePathEntry); + } + + let streamingOperationSet = this.streamingOperationsByTopicFilter.get(event.topic); + if (streamingOperationSet) { + this.handleIncomingPublishEventStreaming(event, streamingOperationSet); + } + } + + private handleSubscribeSuccessEvent(event: subscription_manager.SubscribeSuccessEvent) { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Ready) { + return; + } + + io.logDebug(RequestResponseClient.logSubject, `subscribe success event received for operation ${event.operationId} using topic filter "${event.topicFilter}"`); + let operation = this.operations.get(event.operationId); + if (!operation) { + return; + } + + let rrOperation = operation as RequestResponseOperation; + rrOperation.pendingSubscriptionCount--; + if (rrOperation.pendingSubscriptionCount === 0) { + this.applyRequestResponsePublish(rrOperation); + } else { + io.logDebug(RequestResponseClient.logSubject, `operation ${event.operationId} has ${rrOperation.pendingSubscriptionCount} pending subscriptions left`); + } + } + + private handleSubscribeFailureEvent(event: subscription_manager.SubscribeFailureEvent) { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Ready) { + return; + } + + io.logDebug(RequestResponseClient.logSubject, `subscribe failure event received for operation ${event.operationId} using topic filter "${event.topicFilter}"`); + this.completeRequestResponseOperationWithError(event.operationId, new CrtError("Subscribe failure")); + } + + private handleSubscriptionEndedEvent(event: subscription_manager.SubscriptionEndedEvent) { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Ready) { + return; + } + + io.logDebug(RequestResponseClient.logSubject, `subscription ended event received for operation ${event.operationId} using topic filter "${event.topicFilter}"`); + this.completeRequestResponseOperationWithError(event.operationId, new CrtError("Subscription Ended Early")); + } + + private handleStreamingSubscriptionEstablishedEvent(event: subscription_manager.StreamingSubscriptionEstablishedEvent) { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Ready) { + return; + } + + let operation = this.operations.get(event.operationId); + if (!operation) { + return; + } + + if (operation.state == OperationState.Terminal) { + return; + } + + if (operation.type != OperationType.Streaming) { + return; + } + + let streamingOperation = operation as StreamingOperation; + streamingOperation.operation.triggerSubscriptionStatusUpdateEvent({ + type: mqtt_request_response.SubscriptionStatusEventType.SubscriptionEstablished + }); + + this.changeOperationState(operation, OperationState.Subscribed); + } + + private handleStreamingSubscriptionLostEvent(event: subscription_manager.StreamingSubscriptionLostEvent) { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Ready) { + return; + } + + let operation = this.operations.get(event.operationId); + if (!operation) { + return; + } + + if (operation.state == OperationState.Terminal) { + return; + } + + if (operation.type != OperationType.Streaming) { + return; + } + + let streamingOperation = operation as StreamingOperation; + streamingOperation.operation.triggerSubscriptionStatusUpdateEvent({ + type: mqtt_request_response.SubscriptionStatusEventType.SubscriptionLost, + }); + } + + private handleStreamingSubscriptionHaltedEvent(event: subscription_manager.StreamingSubscriptionHaltedEvent) { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Ready) { + return; + } + + let operation = this.operations.get(event.operationId); + if (!operation) { + return; + } + + if (operation.state == OperationState.Terminal) { + return; + } + + if (operation.type != OperationType.Streaming) { + return; + } + + let streamingOperation = operation as StreamingOperation; + streamingOperation.operation.triggerSubscriptionStatusUpdateEvent({ + type: mqtt_request_response.SubscriptionStatusEventType.SubscriptionHalted, + error: new CrtError(`Subscription Failure for topic filter "${event.topicFilter}"`) + }); + + this.changeOperationState(operation, OperationState.Terminal); + } + + private handleSubscriptionOrphanedEvent(event: subscription_manager.SubscriptionOrphanedEvent) { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Ready) { + return; + } + + io.logDebug(RequestResponseClient.logSubject, `subscription orphaned event received for topic filter "${event.topicFilter}"`); + this.wakeServiceTask(); + } + + private handleUnsubscribeCompleteEvent(event: subscription_manager.UnsubscribeCompleteEvent) { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Ready) { + return; + } + + io.logDebug(RequestResponseClient.logSubject, `unsubscribe completion event received for topic filter "${event.topicFilter}"`); + this.wakeServiceTask(); + } + + private changeOperationState(operation: Operation, state: OperationState) { + if (state == operation.state) { + return; + } + + io.logDebug(RequestResponseClient.logSubject, `operation ${operation.id} changing state from "${operationStateToString(operation.state)}" to "${operationStateToString(state)}"`); + + operation.state = state; + } + + private applyRequestResponsePublish(operation: RequestResponseOperation) { + let publishOptions = { + topic: operation.options.publishTopic, + payload: operation.options.payload, + timeoutInSeconds: this.operationTimeoutInSeconds, + completionData: operation.id + }; + + try { + io.logDebug(RequestResponseClient.logSubject, `submitting publish for request-response operation ${operation.id}`); + this.protocolClientAdapter.publish(publishOptions); + this.changeOperationState(operation, OperationState.PendingResponse); + } catch (err) { + let errorStringified = JSON.stringify(err); + this.completeRequestResponseOperationWithError(operation.id, new CrtError(`Publish error: "${errorStringified}"`)); + io.logError(RequestResponseClient.logSubject, `request-response operation ${operation.id} synchronously failed publish step due to error: ${errorStringified}`); + } + } + + private openStreamingOperation(id: number) { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Ready) { + throw new CrtError(`Attempt to open streaming operation with id "${id}" after client closed`); + } + + let operation = this.operations.get(id); + if (!operation) { + throw new CrtError(`Attempt to open untracked streaming operation with id "${id}"`); + } + + if (operation.state != OperationState.None) { + throw new CrtError(`Attempt to open already-opened streaming operation with id "${id}"`); + } + + operation.state = OperationState.Queued; + this.operationQueue.push(id); + + this.wakeServiceTask(); + + io.logInfo(RequestResponseClient.logSubject, `streaming operation with id "${id}" submitted to operation queue`); + } + + private closeStreamingOperation(id: number) { + let operation = this.operations.get(id); + if (!operation) { + // don't throw here intentionally; there's a bit of a recursive tangle with closing streaming operations + return; + } + + this.haltStreamingOperationWithError(id, new CrtError("Streaming operation closed")); + } +} + +function validateResponsePath(responsePath: mqtt_request_response.ResponsePath) { + if (!mqtt_shared.isValidTopic(responsePath.topic)) { + throw new CrtError(`"${JSON.stringify(responsePath.topic)})" is not a valid topic`); + } + + if (responsePath.correlationTokenJsonPath) { + if (typeof(responsePath.correlationTokenJsonPath) !== 'string') { + throw new CrtError(`"${JSON.stringify(responsePath.correlationTokenJsonPath)})" is not a valid correlation token path`); + } + } +} + +function validateRequestOptions(requestOptions: mqtt_request_response.RequestResponseOperationOptions) { + if (!requestOptions) { + throw new CrtError("Invalid request options - null options"); + } + + if (!requestOptions.subscriptionTopicFilters) { + throw new CrtError("Invalid request options - null subscriptionTopicFilters"); + } + + if (!Array.isArray(requestOptions.subscriptionTopicFilters)) { + throw new CrtError("Invalid request options - subscriptionTopicFilters is not an array"); + } + + if (requestOptions.subscriptionTopicFilters.length === 0) { + throw new CrtError("Invalid request options - subscriptionTopicFilters is empty"); + } + + for (const topicFilter of requestOptions.subscriptionTopicFilters) { + if (!mqtt_shared.isValidTopicFilter(topicFilter)) { + throw new CrtError(`Invalid request options - "${JSON.stringify(topicFilter)}" is not a valid topic filter`); + } + } + + if (!requestOptions.responsePaths) { + throw new CrtError("Invalid request options - null responsePaths"); + } + + if (!Array.isArray(requestOptions.responsePaths)) { + throw new CrtError("Invalid request options - responsePaths is not an array"); + } + + if (requestOptions.responsePaths.length === 0) { + throw new CrtError("Invalid request options - responsePaths is empty"); + } + + for (const responsePath of requestOptions.responsePaths) { + try { + validateResponsePath(responsePath); + } catch (err) { + throw new CrtError(`Invalid request options - invalid response path: ${JSON.stringify(err)}`); + } + } + + if (!requestOptions.publishTopic) { + throw new CrtError("Invalid request options - null publishTopic"); + } + + if (!mqtt_shared.isValidTopic(requestOptions.publishTopic)) { + throw new CrtError(`Invalid request options - "${JSON.stringify(requestOptions.publishTopic)}" is not a valid topic`); + } + + if (!requestOptions.payload) { + throw new CrtError("Invalid request options - null payload"); + } + + if (requestOptions.payload.byteLength == 0) { + throw new CrtError("Invalid request options - empty payload"); + } + + if (requestOptions.correlationToken) { + if (typeof(requestOptions.correlationToken) !== 'string') { + throw new CrtError("Invalid request options - correlationToken is not a string"); + } + } else if (requestOptions.correlationToken === null) { + throw new CrtError("Invalid request options - correlationToken null"); + } +} + +function validateStreamingOptions(streamOptions: mqtt_request_response.StreamingOperationOptions) { + if (!streamOptions) { + throw new CrtError("Invalid streaming options - null options"); + } + + if (!streamOptions.subscriptionTopicFilter) { + throw new CrtError("Invalid streaming options - null subscriptionTopicFilter"); + } + + if (typeof(streamOptions.subscriptionTopicFilter) !== 'string') { + throw new CrtError("Invalid streaming options - subscriptionTopicFilter not a string"); + } + + if (!mqtt_shared.isValidTopicFilter(streamOptions.subscriptionTopicFilter)) { + throw new CrtError("Invalid streaming options - subscriptionTopicFilter not a valid topic filter"); + } +} diff --git a/lib/browser/mqtt_request_response/protocol_adapter.spec.ts b/lib/browser/mqtt_request_response/protocol_adapter.spec.ts new file mode 100644 index 000000000..70e4a9188 --- /dev/null +++ b/lib/browser/mqtt_request_response/protocol_adapter.spec.ts @@ -0,0 +1,515 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + + +import * as mqtt311 from "../mqtt"; +import * as mqtt5 from "../mqtt5"; +import * as protocol_adapter from "./protocol_adapter"; +import * as aws_iot_mqtt311 from "../aws_iot"; +import * as aws_iot_mqtt5 from "../aws_iot_mqtt5"; +import {v4 as uuid} from "uuid"; +import * as test_utils from "../../../test/mqtt5"; +import * as auth from "../auth"; +import {once} from "events"; + +jest.setTimeout(10000); + +enum ProtocolVersion { + Mqtt311, + Mqtt5 +} + +interface TestingOptions { + version: ProtocolVersion, + builder_mutator5?: (builder: aws_iot_mqtt5.AwsIotMqtt5ClientConfigBuilder) => aws_iot_mqtt5.AwsIotMqtt5ClientConfigBuilder, + builder_mutator311?: (builder: aws_iot_mqtt311.AwsIotMqttConnectionConfigBuilder) => aws_iot_mqtt311.AwsIotMqttConnectionConfigBuilder, +} + +function getTestingCredentials() : auth.AWSCredentials { + let credentials : auth.AWSCredentials = { + aws_access_id: test_utils.ClientEnvironmentalConfig.AWS_IOT_ACCESS_KEY_ID, + aws_secret_key: test_utils.ClientEnvironmentalConfig.AWS_IOT_SECRET_ACCESS_KEY, + aws_region: "us-east-1" + }; + + if (test_utils.ClientEnvironmentalConfig.AWS_IOT_SESSION_TOKEN !== "") { + credentials.aws_sts_token = test_utils.ClientEnvironmentalConfig.AWS_IOT_SESSION_TOKEN; + } + + return credentials; +} + +function build_protocol_client_mqtt5(builder_mutator?: (builder: aws_iot_mqtt5.AwsIotMqtt5ClientConfigBuilder) => aws_iot_mqtt5.AwsIotMqtt5ClientConfigBuilder) : mqtt5.Mqtt5Client { + let provider: auth.StaticCredentialProvider = new auth.StaticCredentialProvider(getTestingCredentials()); + + let builder = aws_iot_mqtt5.AwsIotMqtt5ClientConfigBuilder.newWebsocketMqttBuilderWithSigv4Auth( + test_utils.ClientEnvironmentalConfig.AWS_IOT_HOST, + { + credentialsProvider: provider, + // the region extraction logic does not work for gamma endpoint formats so pass in region manually + region: "us-east-1" + } + ); + + builder.withConnectProperties({ + keepAliveIntervalSeconds: 1200, + clientId: `client-${uuid()}` + }); + + if (builder_mutator) { + builder = builder_mutator(builder); + } + + return new mqtt5.Mqtt5Client(builder.build()); +} + +function build_protocol_client_mqtt311(builder_mutator?: (builder: aws_iot_mqtt311.AwsIotMqttConnectionConfigBuilder) => aws_iot_mqtt311.AwsIotMqttConnectionConfigBuilder) : mqtt311.MqttClientConnection { + let provider: auth.StaticCredentialProvider = new auth.StaticCredentialProvider(getTestingCredentials()); + + let builder = aws_iot_mqtt311.AwsIotMqttConnectionConfigBuilder.new_builder_for_websocket(); + builder.with_credential_provider(provider); + builder.with_endpoint(test_utils.ClientEnvironmentalConfig.AWS_IOT_HOST); + builder.with_client_id(uuid()); + + if (builder_mutator) { + builder = builder_mutator(builder); + } + + let client = new mqtt311.MqttClient(); + + if (builder_mutator) { + builder = builder_mutator(builder); + } + + let connection = client.new_connection(builder.build()); + connection.on('error', (_) => {}); + + return connection; +} + +class TestingContext { + + mqtt311Client?: mqtt311.MqttClientConnection; + mqtt5Client?: mqtt5.Mqtt5Client; + + adapter: protocol_adapter.ProtocolClientAdapter; + + private protocolStarted : boolean = false; + + constructor(options: TestingOptions) { + if (options.version == ProtocolVersion.Mqtt5) { + this.mqtt5Client = build_protocol_client_mqtt5(options.builder_mutator5); + this.adapter = protocol_adapter.ProtocolClientAdapter.newFrom5(this.mqtt5Client); + } else { + this.mqtt311Client = build_protocol_client_mqtt311(options.builder_mutator311); + this.adapter = protocol_adapter.ProtocolClientAdapter.newFrom311(this.mqtt311Client); + } + } + + async open() { + await this.startProtocolClient(); + } + + async close() { + this.adapter.close(); + await this.stopProtocolClient(); + } + + async startProtocolClient() { + if (!this.protocolStarted) { + this.protocolStarted = true; + if (this.mqtt5Client) { + let connected = once(this.mqtt5Client, mqtt5.Mqtt5Client.CONNECTION_SUCCESS); + this.mqtt5Client.start(); + + await connected; + } + + if (this.mqtt311Client) { + await this.mqtt311Client.connect(); + } + } + } + + async stopProtocolClient() { + if (this.protocolStarted) { + this.protocolStarted = false; + if (this.mqtt5Client) { + let stopped = once(this.mqtt5Client, mqtt5.Mqtt5Client.STOPPED); + this.mqtt5Client.stop(); + await stopped; + + this.mqtt5Client.close(); + } + + if (this.mqtt311Client) { + await this.mqtt311Client.disconnect(); + } + } + } +} + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Create/Destroy - Mqtt5', async () => { + let context = new TestingContext({ + version: ProtocolVersion.Mqtt5 + }); + await context.open(); + + await context.close(); +}); + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Create/Destroy - Mqtt311', async () => { + let context = new TestingContext({ + version: ProtocolVersion.Mqtt311 + }); + await context.open(); + + await context.close(); +}); + +async function do_subscribe_success_test(version: ProtocolVersion) : Promise { + let context = new TestingContext({ + version: version + }); + + await context.open(); + + let subscribe_event_promise = once(context.adapter, protocol_adapter.ProtocolClientAdapter.SUBSCRIBE_COMPLETION); + + context.adapter.subscribe({ + topicFilter: "a/b/c", + timeoutInSeconds: 30 + }); + + let subscribe_event = (await subscribe_event_promise)[0]; + expect(subscribe_event.err).toBeUndefined(); + expect(subscribe_event.topicFilter).toEqual("a/b/c"); + + await context.close(); +} + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Subscribe Success - Mqtt5', async () => { + await do_subscribe_success_test(ProtocolVersion.Mqtt5); +}); + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Subscribe Success - Mqtt311', async () => { + await do_subscribe_success_test(ProtocolVersion.Mqtt311); +}); + +async function do_subscribe_timeout_test(version: ProtocolVersion) : Promise { + let context = new TestingContext({ + version: version + }); + + await context.open(); + + let subscribe_event_promise = once(context.adapter, protocol_adapter.ProtocolClientAdapter.SUBSCRIBE_COMPLETION); + + context.adapter.subscribe({ + topicFilter: "a/b/c", + timeoutInSeconds: .001 // sketchy but no other reliable timeout possibilities are available + }); + + let subscribe_event : protocol_adapter.SubscribeCompletionEvent = (await subscribe_event_promise)[0]; + expect(subscribe_event.topicFilter).toEqual("a/b/c"); + expect(subscribe_event.err).toBeDefined(); + + // @ts-ignore + let errorAsString = subscribe_event.err.toString(); + expect(errorAsString).toContain("Timeout"); + + await context.close(); +} + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Subscribe Timeout - Mqtt5', async () => { + await do_subscribe_timeout_test(ProtocolVersion.Mqtt5); +}); + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Subscribe Timeout - Mqtt311', async () => { + await do_subscribe_timeout_test(ProtocolVersion.Mqtt311); +}); + +async function do_subscribe_failure_test(version: ProtocolVersion) : Promise { + let context = new TestingContext({ + version: version + }); + + await context.open(); + + let subscribe_event_promise = once(context.adapter, protocol_adapter.ProtocolClientAdapter.SUBSCRIBE_COMPLETION); + let bad_topic_filter = "b".repeat(512); + context.adapter.subscribe({ + topicFilter: bad_topic_filter, + timeoutInSeconds: 30 + }); + + let subscribe_event : protocol_adapter.SubscribeCompletionEvent = (await subscribe_event_promise)[0]; + expect(subscribe_event.topicFilter).toEqual(bad_topic_filter); + + // On 5 this fails with a suback reason code, on 311 the connection gets closed by IoT Core + expect(subscribe_event.err).toBeDefined(); + + await context.close(); +} + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Subscribe Failure - Mqtt5', async () => { + await do_subscribe_failure_test(ProtocolVersion.Mqtt5); +}); + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Subscribe Failure - Mqtt311', async () => { + await do_subscribe_failure_test(ProtocolVersion.Mqtt311); +}); + +async function do_unsubscribe_success_test(version: ProtocolVersion) : Promise { + let context = new TestingContext({ + version: version + }); + + await context.open(); + + let unsubscribe_event_promise = once(context.adapter, protocol_adapter.ProtocolClientAdapter.UNSUBSCRIBE_COMPLETION); + + context.adapter.unsubscribe({ + topicFilter: "a/b/c", + timeoutInSeconds: 30 + }); + + let unsubscribe_event = (await unsubscribe_event_promise)[0]; + expect(unsubscribe_event.err).toBeUndefined(); + expect(unsubscribe_event.topicFilter).toEqual("a/b/c"); + + await context.close(); +} + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Unsubscribe Success - Mqtt5', async () => { + await do_unsubscribe_success_test(ProtocolVersion.Mqtt5); +}); + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Unsubscribe Success - Mqtt311', async () => { + await do_unsubscribe_success_test(ProtocolVersion.Mqtt311); +}); + +async function do_unsubscribe_timeout_test(version: ProtocolVersion) : Promise { + let context = new TestingContext({ + version: version + }); + + await context.open(); + + let unsubscribe_event_promise = once(context.adapter, protocol_adapter.ProtocolClientAdapter.UNSUBSCRIBE_COMPLETION); + + context.adapter.unsubscribe({ + topicFilter: "a/b/c", + timeoutInSeconds: .001 // sketchy but no other reliable timeout possibilities are available + }); + + let unsubscribe_event = (await unsubscribe_event_promise)[0]; + expect(unsubscribe_event.topicFilter).toEqual("a/b/c"); + expect(unsubscribe_event.err).toBeDefined(); + + // @ts-ignore + let errorAsString = unsubscribe_event.err.toString(); + expect(errorAsString).toContain("Timeout"); + + await context.close(); +} + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Unsubscribe Timeout - Mqtt5', async () => { + await do_unsubscribe_timeout_test(ProtocolVersion.Mqtt5); +}); + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Unsubscribe Timeout - Mqtt311', async () => { + await do_unsubscribe_timeout_test(ProtocolVersion.Mqtt311); +}); + +async function do_unsubscribe_failure_test(version: ProtocolVersion) : Promise { + let context = new TestingContext({ + version: version + }); + + await context.open(); + + let unsubscribe_event_promise = once(context.adapter, protocol_adapter.ProtocolClientAdapter.UNSUBSCRIBE_COMPLETION); + context.adapter.unsubscribe({ + topicFilter: "#/b#/#", + timeoutInSeconds: 30 + }); + + let unsubscribe_event = (await unsubscribe_event_promise)[0]; + expect(unsubscribe_event.topicFilter).toEqual("#/b#/#"); + + // On 5 this fails with an unsuback reason code, on 311 the connection gets closed by IoT Core + expect(unsubscribe_event.err).toBeDefined(); + + await context.open(); +} + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Unsubscribe Failure - Mqtt5', async () => { + await do_unsubscribe_failure_test(ProtocolVersion.Mqtt5); +}); + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Unsubscribe Failure - Mqtt311', async () => { + await do_unsubscribe_failure_test(ProtocolVersion.Mqtt311); +}); + +async function do_get_connection_state_test(version: ProtocolVersion) { + let context = new TestingContext({ + version: version + }); + + expect(context.adapter.getConnectionState()).toEqual(protocol_adapter.ConnectionState.Disconnected); + + await context.open(); + + expect(context.adapter.getConnectionState()).toEqual(protocol_adapter.ConnectionState.Connected); + + await context.close(); +} + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter getConnectionState - Mqtt5', async () => { + await do_get_connection_state_test(ProtocolVersion.Mqtt5); +}); + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter getConnectionState - Mqtt311', async () => { + await do_get_connection_state_test(ProtocolVersion.Mqtt311); +}); + +async function do_connection_event_test(version: ProtocolVersion) { + let context = new TestingContext({ + version: version + }); + + let event1_promise = once(context.adapter, protocol_adapter.ProtocolClientAdapter.CONNECTION_STATUS); + + await context.open(); + + let connection_event1 : protocol_adapter.ConnectionStatusEvent = (await event1_promise)[0]; + expect(connection_event1.status).toEqual(protocol_adapter.ConnectionState.Connected); + expect(connection_event1.joinedSession).toEqual(false); + + let event2_promise = once(context.adapter, protocol_adapter.ProtocolClientAdapter.CONNECTION_STATUS); + + await context.stopProtocolClient(); + + let connection_event2 : protocol_adapter.ConnectionStatusEvent = (await event2_promise)[0]; + expect(connection_event2.status).toEqual(protocol_adapter.ConnectionState.Disconnected); +} + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Connection Event Sequence - Mqtt5', async () => { + await do_connection_event_test(ProtocolVersion.Mqtt5); +}); + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Connection Event Sequence - Mqtt311', async () => { + await do_connection_event_test(ProtocolVersion.Mqtt311); +}); + +async function do_publish_success_test(version: ProtocolVersion) : Promise { + let context = new TestingContext({ + version: version + }); + + await context.open(); + + let publish_event_promise = once(context.adapter, protocol_adapter.ProtocolClientAdapter.PUBLISH_COMPLETION); + + var encoder = new TextEncoder(); + let payload: ArrayBuffer = encoder.encode("A payload"); + let completionData = 42; + + context.adapter.publish({ + topic: "a/b/c", + payload: payload, + timeoutInSeconds: 30, + completionData: completionData, + }); + + let publish_event : protocol_adapter.PublishCompletionEvent = (await publish_event_promise)[0]; + expect(publish_event.err).toBeUndefined(); + expect(publish_event.completionData).toEqual(completionData); + + await context.close(); +} + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Publish Success - Mqtt5', async () => { + await do_publish_success_test(ProtocolVersion.Mqtt5); +}); + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Publish Success - Mqtt311', async () => { + await do_publish_success_test(ProtocolVersion.Mqtt311); +}); + +async function do_publish_timeout_test(version: ProtocolVersion) : Promise { + let context = new TestingContext({ + version: version + }); + + await context.open(); + + let publish_event_promise = once(context.adapter, protocol_adapter.ProtocolClientAdapter.PUBLISH_COMPLETION); + + var encoder = new TextEncoder(); + let payload: ArrayBuffer = encoder.encode("A payload"); + let completionData = 42; + + context.adapter.publish({ + topic: "a/b/c", + payload: payload, + timeoutInSeconds: .001, + completionData: completionData, + }); + + let publish_event : protocol_adapter.PublishCompletionEvent = (await publish_event_promise)[0]; + expect(publish_event.completionData).toEqual(completionData); + expect(publish_event.err).toBeDefined(); + + // @ts-ignore + let errorAsString = publish_event.err.toString(); + expect(errorAsString).toContain("Timeout"); + + await context.close(); +} + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Publish Timeout - Mqtt5', async () => { + await do_publish_timeout_test(ProtocolVersion.Mqtt5); +}); + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Publish Timeout - Mqtt311', async () => { + await do_publish_timeout_test(ProtocolVersion.Mqtt311); +}); + +// There's no straightforward, reliable way to generate publish failures against IoT Core, so no failure tests + +test_utils.conditional_test(test_utils.ClientEnvironmentalConfig.hasIoTCoreEnvironmentCred())('Protocol Adapter Use After Close', async () => { + let context = new TestingContext({ + version: ProtocolVersion.Mqtt5 + }); + + await context.open(); + await context.close(); + + var encoder = new TextEncoder(); + let payload: ArrayBuffer = encoder.encode("A payload"); + let completionData = 42; + let publishOptions = { + topic: "a/b/c", + payload: payload, + timeoutInSeconds: .001, + completionData: completionData, + }; + expect(() => { context.adapter.publish(publishOptions); }).toThrow(); + + let unsubscribeOptions ={ + topicFilter: "a/b/c", + timeoutInSeconds: 30 + }; + expect(() => { context.adapter.unsubscribe(unsubscribeOptions); }).toThrow(); + + let subscribeOptions ={ + topicFilter: "a/b/c", + timeoutInSeconds: 30 + }; + expect(() => { context.adapter.subscribe(subscribeOptions); }).toThrow(); + +}); diff --git a/lib/browser/mqtt_request_response/protocol_adapter.ts b/lib/browser/mqtt_request_response/protocol_adapter.ts new file mode 100644 index 000000000..34c3aacd7 --- /dev/null +++ b/lib/browser/mqtt_request_response/protocol_adapter.ts @@ -0,0 +1,516 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +/** + * + * @packageDocumentation + * @module mqtt_request_response + * + */ + +import {CrtError, ICrtError} from "../error"; +import * as mqtt311 from "../mqtt"; +import * as mqtt5 from "../mqtt5"; +import * as mqtt_request_response from "../../common/mqtt_request_response"; +import {BufferedEventEmitter} from "../../common/event"; +import {QoS} from "../mqtt"; + + +const MS_PER_SECOND : number = 1000; + +export interface PublishOptions { + topic: string, + payload: mqtt_request_response.RequestPayload, + timeoutInSeconds: number, + completionData: any +} + +export interface PublishCompletionEvent { + completionData: any, + err?: ICrtError +} + +export type PublishCompletionEventListener = (event: PublishCompletionEvent) => void; + +export interface SubscribeOptions { + topicFilter: string, + timeoutInSeconds: number, +} + +export interface SubscribeCompletionEvent { + topicFilter: string, + err?: ICrtError, + retryable?: boolean, +} + +export type SubscribeCompletionEventListener = (event: SubscribeCompletionEvent) => void; + +export interface UnsubscribeOptions { + topicFilter: string, + timeoutInSeconds: number, +} + +export interface UnsubscribeCompletionEvent { + topicFilter: string, + err?: ICrtError, + retryable?: boolean +} + +export type UnsubscribeCompletionEventListener = (event: UnsubscribeCompletionEvent) => void; + +export enum ConnectionState { + Connected = 0, + Disconnected = 1, +} + +export interface ConnectionStatusEvent { + status: ConnectionState, + joinedSession?: boolean, +} + +export type ConnectionStatusEventListener = (event: ConnectionStatusEvent) => void; + +export interface IncomingPublishEvent { + topic: string, + payload: ArrayBuffer +} + +export type IncomingPublishEventListener = (event: IncomingPublishEvent) => void; + +/* + * Provides a client-agnostic wrapper around the MQTT functionality needed by the browser request-response client. + * + * This is a direct port of aws-c-mqtt's aws_mqtt_protocol_adapter implementation. + * + * We use events and not promises for all of these operations because it's critical that the request-response + * client never awaits on async promises directly. All promise waits are done on scheduled tasks instead. + */ +export class ProtocolClientAdapter extends BufferedEventEmitter { + + private closed: boolean; + private client5?: mqtt5.Mqtt5Client; + private client311?: mqtt311.MqttClientConnection; + private connectionState: ConnectionState; + + private connectionSuccessListener5 : mqtt5.ConnectionSuccessEventListener = (event : mqtt5.ConnectionSuccessEvent) => { + this.connectionState = ConnectionState.Connected; + setImmediate(() => { this.emit(ProtocolClientAdapter.CONNECTION_STATUS, { + status: ConnectionState.Connected, + joinedSession: event.connack.sessionPresent, + })}); + }; + + private disconnectionListener5 : mqtt5.DisconnectionEventListener = (event : mqtt5.DisconnectionEvent) => { + this.connectionState = ConnectionState.Disconnected; + setImmediate(() => { this.emit(ProtocolClientAdapter.CONNECTION_STATUS, { + status: ConnectionState.Disconnected, + })}); + }; + + private incomingPublishListener5 : mqtt5.MessageReceivedEventListener = (event: mqtt5.MessageReceivedEvent) => { + setImmediate(() => { this.emit(ProtocolClientAdapter.INCOMING_PUBLISH, { + topic: event.message.topicName, + payload: event.message.payload + })}); + }; + + private connectionSuccessListener311 : mqtt311.MqttConnectionSuccess = (event : mqtt311.OnConnectionSuccessResult) => { + this.connectionState = ConnectionState.Connected; + setImmediate(() => { this.emit(ProtocolClientAdapter.CONNECTION_STATUS, { + status: ConnectionState.Connected, + joinedSession: event.session_present, + })}); + }; + + private disconnectionListener311 : mqtt311.MqttConnectionDisconnected = () => { + this.connectionState = ConnectionState.Disconnected; + setImmediate(() => { this.emit(ProtocolClientAdapter.CONNECTION_STATUS, { + status: ConnectionState.Disconnected, + })}); + }; + + private incomingPublishListener311 : mqtt311.OnMessageCallback = (topic: string, payload: ArrayBuffer, dup: boolean, qos: QoS, retain: boolean) => { + setImmediate(() => { this.emit(ProtocolClientAdapter.INCOMING_PUBLISH, { + topic: topic, + payload: payload + })}); + }; + + private constructor() { + super(); + + this.connectionState = ConnectionState.Disconnected; + this.closed = false; + } + + public static newFrom5(client: mqtt5.Mqtt5Client) : ProtocolClientAdapter { + let adapter = new ProtocolClientAdapter(); + + adapter.client5 = client; + + client.addListener(mqtt5.Mqtt5Client.CONNECTION_SUCCESS, adapter.connectionSuccessListener5); + client.addListener(mqtt5.Mqtt5Client.DISCONNECTION, adapter.disconnectionListener5); + client.addListener(mqtt5.Mqtt5Client.MESSAGE_RECEIVED, adapter.incomingPublishListener5); + + adapter.connectionState = client.isConnected() ? ConnectionState.Connected : ConnectionState.Disconnected; + + return adapter; + } + + public static newFrom311(client: mqtt311.MqttClientConnection) : ProtocolClientAdapter { + let adapter = new ProtocolClientAdapter(); + + adapter.client311 = client; + + client.addListener(mqtt311.MqttClientConnection.CONNECTION_SUCCESS, adapter.connectionSuccessListener311); + client.addListener(mqtt311.MqttClientConnection.DISCONNECT, adapter.disconnectionListener311); + client.addListener(mqtt311.MqttClientConnection.MESSAGE, adapter.incomingPublishListener311); + + adapter.connectionState = client.is_connected() ? ConnectionState.Connected : ConnectionState.Disconnected; + + return adapter; + } + + public close() : void { + if (this.closed) { + return; + } + + this.closed = true; + + if (this.client5) { + this.client5.removeListener(mqtt5.Mqtt5Client.CONNECTION_SUCCESS, this.connectionSuccessListener5); + this.client5.removeListener(mqtt5.Mqtt5Client.DISCONNECTION, this.disconnectionListener5); + this.client5.removeListener(mqtt5.Mqtt5Client.MESSAGE_RECEIVED, this.incomingPublishListener5); + this.client5 = undefined; + } + + if (this.client311) { + this.client311.removeListener(mqtt311.MqttClientConnection.CONNECTION_SUCCESS, this.connectionSuccessListener311); + this.client311.removeListener(mqtt311.MqttClientConnection.DISCONNECT, this.disconnectionListener311); + this.client311.removeListener(mqtt311.MqttClientConnection.MESSAGE, this.incomingPublishListener311); + this.client311 = undefined; + } + } + + public publish(publishOptions : PublishOptions) : void { + + if (this.closed) { + throw new CrtError(ProtocolClientAdapter.ADAPTER_CLOSED); + } + + var publishResult: PublishCompletionEvent | undefined = undefined; + + setImmediate(async () => { + var publishPromise: Promise; + if (this.client5) { + let packet: mqtt5.PublishPacket = { + topicName: publishOptions.topic, + qos: mqtt5.QoS.AtLeastOnce, + payload: publishOptions.payload, + }; + + publishPromise = this.client5.publish(packet).then( + (result) => { + if (!publishResult) { + publishResult = { + completionData: publishOptions.completionData + }; + + if (result && !mqtt5.isSuccessfulPubackReasonCode(result.reasonCode)) { + publishResult.err = new CrtError(ProtocolClientAdapter.FAILING_PUBACK_REASON_CODE); + } + } + }, + (err) => { + if (!publishResult) { + publishResult = { + completionData: publishOptions.completionData, + err: err + }; + } + } + ); + } else if (this.client311) { + publishPromise = this.client311.publish(publishOptions.topic, publishOptions.payload, mqtt311.QoS.AtLeastOnce).then( + (response) => { + if (!publishResult) { + publishResult = { + completionData: publishOptions.completionData + }; + } + }, + (err) => { + if (!publishResult) { + publishResult = { + completionData: publishOptions.completionData, + err: err + }; + } + } + ); + } else { + throw new CrtError(ProtocolClientAdapter.ILLEGAL_ADAPTER_STATE); + } + + let timeoutPromise: Promise = new Promise( + resolve => setTimeout(() => { + if (!publishResult) { + publishResult = { + completionData: publishOptions.completionData, + err: new CrtError(ProtocolClientAdapter.OPERATION_TIMEOUT) + }; + } + }, + publishOptions.timeoutInSeconds * MS_PER_SECOND)); + + await Promise.race([publishPromise, timeoutPromise]); + + this.emit(ProtocolClientAdapter.PUBLISH_COMPLETION, publishResult); + }); + } + + public subscribe(subscribeOptions: SubscribeOptions) : void { + + if (this.closed) { + throw new CrtError(ProtocolClientAdapter.ADAPTER_CLOSED); + } + + var subscribeResult: SubscribeCompletionEvent | undefined = undefined; + + setImmediate(async () => { + var subscribePromise: Promise; + if (this.client5) { + let packet: mqtt5.SubscribePacket = { + subscriptions: [ + { + topicFilter: subscribeOptions.topicFilter, + qos: mqtt5.QoS.AtLeastOnce, + } + ] + }; + + subscribePromise = this.client5.subscribe(packet).then( + (suback) => { + if (!subscribeResult) { + subscribeResult = { + topicFilter: subscribeOptions.topicFilter, + }; + + let reasonCode = suback.reasonCodes[0]; + if (!mqtt5.isSuccessfulSubackReasonCode(reasonCode)) { + subscribeResult.err = new CrtError(ProtocolClientAdapter.FAILING_SUBACK_REASON_CODE); + subscribeResult.retryable = ProtocolClientAdapter.isSubackReasonCodeRetryable(reasonCode); + } + } + }, + (err) => { + if (!subscribeResult) { + subscribeResult = { + topicFilter: subscribeOptions.topicFilter, + err: err, + retryable: false + }; + } + } + ); + } else if (this.client311) { + subscribePromise = this.client311.subscribe(subscribeOptions.topicFilter, mqtt311.QoS.AtLeastOnce).then( + (response) => { + if (!subscribeResult) { + subscribeResult = { + topicFilter: subscribeOptions.topicFilter + }; + + if (response.qos >= 128) { + subscribeResult.err = new CrtError(ProtocolClientAdapter.FAILING_SUBACK_REASON_CODE); + subscribeResult.retryable = true; + } else if (response.error_code) { + subscribeResult.err = new CrtError("Internal Error"); + subscribeResult.retryable = true; + } + } + }, + (err) => { + if (!subscribeResult) { + subscribeResult = { + topicFilter: subscribeOptions.topicFilter, + err: err, + retryable: false, + }; + } + } + ); + } else { + throw new CrtError(ProtocolClientAdapter.ILLEGAL_ADAPTER_STATE); + } + + let timeoutPromise: Promise = new Promise( + resolve => setTimeout(() => { + if (!subscribeResult) { + subscribeResult = { + topicFilter: subscribeOptions.topicFilter, + err: new CrtError(ProtocolClientAdapter.OPERATION_TIMEOUT), + retryable: true, + }; + } + }, + subscribeOptions.timeoutInSeconds * MS_PER_SECOND)); + + await Promise.race([subscribePromise, timeoutPromise]); + + this.emit(ProtocolClientAdapter.SUBSCRIBE_COMPLETION, subscribeResult); + }); + } + + public unsubscribe(unsubscribeOptions: UnsubscribeOptions) : void { + + if (this.closed) { + throw new CrtError(ProtocolClientAdapter.ADAPTER_CLOSED); + } + + var unsubscribeResult: UnsubscribeCompletionEvent | undefined = undefined; + + setImmediate(async () => { + var unsubscribePromise: Promise; + + if (this.client5) { + let packet : mqtt5.UnsubscribePacket = { + topicFilters: [ unsubscribeOptions.topicFilter ] + }; + + unsubscribePromise = this.client5.unsubscribe(packet).then( + (unsuback) => { + if (!unsubscribeResult) { + unsubscribeResult = { + topicFilter: unsubscribeOptions.topicFilter + }; + + let reasonCode = unsuback.reasonCodes[0]; + if (!mqtt5.isSuccessfulUnsubackReasonCode(unsuback.reasonCodes[0])) { + unsubscribeResult.err = new CrtError(ProtocolClientAdapter.FAILING_UNSUBACK_REASON_CODE); + unsubscribeResult.retryable = ProtocolClientAdapter.isUnsubackReasonCodeRetryable(reasonCode); + } + } + }, + (err) => { + if (!unsubscribeResult) { + unsubscribeResult = { + topicFilter: unsubscribeOptions.topicFilter, + err: err, + retryable: false, + } + } + } + ); + } else if (this.client311) { + unsubscribePromise = this.client311.unsubscribe(unsubscribeOptions.topicFilter).then( + (_) => { + if (!unsubscribeResult) { + unsubscribeResult = { + topicFilter: unsubscribeOptions.topicFilter + }; + } + }, + (err) => { + if (!unsubscribeResult) { + unsubscribeResult = { + topicFilter: unsubscribeOptions.topicFilter, + err: err, + retryable: false, + }; + } + } + ); + } else { + throw new CrtError(ProtocolClientAdapter.ILLEGAL_ADAPTER_STATE); + } + + let timeoutPromise: Promise = new Promise( + resolve => setTimeout(() => { + if (!unsubscribeResult) { + unsubscribeResult = { + topicFilter: unsubscribeOptions.topicFilter, + err: new CrtError(ProtocolClientAdapter.OPERATION_TIMEOUT), + retryable: true, + }; + } + }, + unsubscribeOptions.timeoutInSeconds * MS_PER_SECOND)); + + await Promise.race([unsubscribePromise, timeoutPromise]); + + this.emit(ProtocolClientAdapter.UNSUBSCRIBE_COMPLETION, unsubscribeResult); + }); + } + + public getConnectionState() : ConnectionState { + if (this.closed) { + throw new CrtError(ProtocolClientAdapter.ADAPTER_CLOSED); + } + + return this.connectionState; + } + + static PUBLISH_COMPLETION : string = 'publishCompletion'; + + static SUBSCRIBE_COMPLETION : string = 'subscribeCompletion'; + + static UNSUBSCRIBE_COMPLETION : string = 'unsubscribeCompletion'; + + static CONNECTION_STATUS : string = 'connectionStatus'; + + static INCOMING_PUBLISH : string = 'incomingPublish'; + + on(event: 'publishCompletion', listener: PublishCompletionEventListener): this; + + on(event: 'subscribeCompletion', listener: SubscribeCompletionEventListener): this; + + on(event: 'unsubscribeCompletion', listener: UnsubscribeCompletionEventListener): this; + + on(event: 'connectionStatus', listener: ConnectionStatusEventListener): this; + + on(event: 'incomingPublish', listener: IncomingPublishEventListener): this; + + on(event: string | symbol, listener: (...args: any[]) => void): this { + super.on(event, listener); + return this; + } + + private static FAILING_PUBACK_REASON_CODE = "Failing Puback Reason Code"; + + private static FAILING_SUBACK_REASON_CODE = "Failing Suback Reason Code"; + + private static FAILING_UNSUBACK_REASON_CODE = "Failing Unsuback Reason Code"; + + private static ILLEGAL_ADAPTER_STATE = "Illegal Adapter State"; + + private static OPERATION_TIMEOUT = "Operation Timeout"; + + private static ADAPTER_CLOSED = "Protocol Client Adapter Closed"; + + private static isUnsubackReasonCodeRetryable(reasonCode: mqtt5.UnsubackReasonCode) : boolean { + switch (reasonCode) { + case mqtt5.UnsubackReasonCode.ImplementationSpecificError: + case mqtt5.UnsubackReasonCode.PacketIdentifierInUse: + return true; + + default: + return false; + } + } + + private static isSubackReasonCodeRetryable(reasonCode: mqtt5.SubackReasonCode) : boolean { + switch (reasonCode) { + case mqtt5.SubackReasonCode.PacketIdentifierInUse: + case mqtt5.SubackReasonCode.ImplementationSpecificError: + case mqtt5.SubackReasonCode.QuotaExceeded: + return true; + + default: + return false; + } + } +} diff --git a/lib/browser/mqtt_request_response/protocol_adapter_mock.ts b/lib/browser/mqtt_request_response/protocol_adapter_mock.ts new file mode 100644 index 000000000..7ee7da5ab --- /dev/null +++ b/lib/browser/mqtt_request_response/protocol_adapter_mock.ts @@ -0,0 +1,238 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + + +import * as protocol_adapter from "./protocol_adapter"; +import {BufferedEventEmitter} from "../../common/event"; +import {ICrtError} from "../../common/error"; +import * as subscription_manager from "./subscription_manager"; +import {IncomingPublishEventListener} from "./protocol_adapter"; + + +export interface ProtocolAdapterApiCall { + methodName: string; + args: any; +} + +export interface MockProtocolAdapterOptions { + subscribeHandler?: (adapter: MockProtocolAdapter, subscribeOptions: protocol_adapter.SubscribeOptions, context?: any) => void, + subscribeHandlerContext?: any, + unsubscribeHandler?: (adapter: MockProtocolAdapter, unsubscribeOptions: protocol_adapter.UnsubscribeOptions, context?: any) => void, + unsubscribeHandlerContext?: any, + publishHandler?: (adapter: MockProtocolAdapter, publishOptions: protocol_adapter.PublishOptions, context?: any) => void, + publishHandlerContext?: any, +} + +export class MockProtocolAdapter extends BufferedEventEmitter { + + private apiCalls: Array = new Array(); + private connectionState: protocol_adapter.ConnectionState = protocol_adapter.ConnectionState.Disconnected; + + constructor(private options?: MockProtocolAdapterOptions) { + super(); + } + + // ProtocolAdapter API + close() : void { + + } + + publish(publishOptions : protocol_adapter.PublishOptions) : void { + this.apiCalls.push({ + methodName: "publish", + args: publishOptions + }); + + if (this.options && this.options.publishHandler) { + this.options.publishHandler(this, publishOptions, this.options.publishHandlerContext); + } + } + + subscribe(subscribeOptions : protocol_adapter.SubscribeOptions) : void { + this.apiCalls.push({ + methodName: "subscribe", + args: subscribeOptions + }); + + if (this.options && this.options.subscribeHandler) { + this.options.subscribeHandler(this, subscribeOptions, this.options.subscribeHandlerContext); + } + } + + unsubscribe(unsubscribeOptions : protocol_adapter.UnsubscribeOptions) : void { + this.apiCalls.push({ + methodName: "unsubscribe", + args: unsubscribeOptions + }); + + if (this.options && this.options.unsubscribeHandler) { + this.options.unsubscribeHandler(this, unsubscribeOptions,this.options.unsubscribeHandlerContext); + } + } + + // Internal Testing API + connect(joinedSession?: boolean) : void { + if (this.connectionState === protocol_adapter.ConnectionState.Disconnected) { + this.connectionState = protocol_adapter.ConnectionState.Connected; + + this.emit('connectionStatus', { + status: protocol_adapter.ConnectionState.Connected, + joinedSession: joinedSession + }); + } + } + + disconnect() : void { + if (this.connectionState === protocol_adapter.ConnectionState.Connected) { + this.connectionState = protocol_adapter.ConnectionState.Disconnected; + + this.emit('connectionStatus', { + status: protocol_adapter.ConnectionState.Disconnected, + }); + } + } + + getApiCalls(): Array { + return this.apiCalls; + } + + getConnectionState() : protocol_adapter.ConnectionState { + return this.connectionState; + } + + completeSubscribe(topicFilter: string, err?: ICrtError, retryable?: boolean) : void { + let event : protocol_adapter.SubscribeCompletionEvent = { + topicFilter: topicFilter + }; + if (err !== undefined) { + event.err = err; + } + if (retryable !== undefined) { + event.retryable = retryable; + } + + // TODO - rework tests to pass with deferred event emission + this.emit(protocol_adapter.ProtocolClientAdapter.SUBSCRIBE_COMPLETION, event); + } + + completeUnsubscribe(topicFilter: string, err?: ICrtError, retryable?: boolean) : void { + let event : protocol_adapter.UnsubscribeCompletionEvent = { + topicFilter: topicFilter + }; + if (err !== undefined) { + event.err = err; + } + if (retryable !== undefined) { + event.retryable = retryable; + } + + // TODO - rework tests to pass with deferred event emission + this.emit(protocol_adapter.ProtocolClientAdapter.UNSUBSCRIBE_COMPLETION, event); + } + + completePublish(completionData: any, err?: ICrtError) : void { + let event : protocol_adapter.PublishCompletionEvent = { + completionData: completionData + }; + + if (err) { + event.err = err; + } + + this.emit(protocol_adapter.ProtocolClientAdapter.PUBLISH_COMPLETION, event); + } + + triggerIncomingPublish(topic: string, payload: ArrayBuffer) : void { + let event : protocol_adapter.IncomingPublishEvent = { + topic : topic, + payload: payload + }; + + this.emit(protocol_adapter.ProtocolClientAdapter.INCOMING_PUBLISH, event); + } + + // Events + on(event: 'publishCompletion', listener: protocol_adapter.PublishCompletionEventListener): this; + + on(event: 'subscribeCompletion', listener: protocol_adapter.SubscribeCompletionEventListener): this; + + on(event: 'unsubscribeCompletion', listener: protocol_adapter.UnsubscribeCompletionEventListener): this; + + on(event: 'connectionStatus', listener: protocol_adapter.ConnectionStatusEventListener): this; + + on(event: 'incomingPublish', listener: IncomingPublishEventListener): this; + + on(event: string | symbol, listener: (...args: any[]) => void): this { + super.on(event, listener); + return this; + } +} + +export interface SubscriptionManagerEvent { + type: subscription_manager.SubscriptionEventType, + data: any, +}; + +export function subscriptionManagerEventSequenceContainsEvent(eventSequence: SubscriptionManagerEvent[], expectedEvent: SubscriptionManagerEvent) : boolean { + for (let event of eventSequence) { + if (event.type !== expectedEvent.type) { + continue; + } + + if (expectedEvent.data.hasOwnProperty('operationId')) { + if (!event.data.hasOwnProperty('operationId') || expectedEvent.data.operationId !== event.data.operationId) { + continue; + } + } + + if (expectedEvent.data.hasOwnProperty('topicFilter')) { + if (!event.data.hasOwnProperty('topicFilter') || expectedEvent.data.topicFilter !== event.data.topicFilter) { + continue; + } + } + + return true; + } + + return false; +} + +export function subscriptionManagerEventSequenceContainsEvents(eventSequence: SubscriptionManagerEvent[], expectedEvents: SubscriptionManagerEvent[]) : boolean { + for (let expectedEvent of expectedEvents) { + if (!subscriptionManagerEventSequenceContainsEvent(eventSequence, expectedEvent)) { + return false; + } + } + + return true; +} + +export function protocolAdapterApiCallSequenceContainsApiCall(apiCallSequence: ProtocolAdapterApiCall[], expectedApiCall: ProtocolAdapterApiCall) : boolean { + for (let apiCall of apiCallSequence) { + if (apiCall.methodName !== expectedApiCall.methodName) { + continue; + } + + if (expectedApiCall.args.hasOwnProperty('topicFilter')) { + if (!apiCall.args.hasOwnProperty('topicFilter') || expectedApiCall.args.topicFilter !== apiCall.args.topicFilter) { + continue; + } + } + + return true; + } + + return false; +} + +export function protocolAdapterApiCallSequenceContainsApiCalls(apiCallSequence: ProtocolAdapterApiCall[], expectedApiCalls: ProtocolAdapterApiCall[]) : boolean { + for (let expectedApiCall of expectedApiCalls) { + if (!protocolAdapterApiCallSequenceContainsApiCall(apiCallSequence, expectedApiCall)) { + return false; + } + } + + return true; +} \ No newline at end of file diff --git a/lib/browser/mqtt_request_response/subscription_manager.spec.ts b/lib/browser/mqtt_request_response/subscription_manager.spec.ts new file mode 100644 index 000000000..18e4c27cd --- /dev/null +++ b/lib/browser/mqtt_request_response/subscription_manager.spec.ts @@ -0,0 +1,1967 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + + +import * as subscription_manager from "./subscription_manager"; +import {once} from "events"; +import {newLiftedPromise} from "../../common/promise"; +import {CrtError} from "../error"; +import * as protocol_adapter_mock from "./protocol_adapter_mock"; + +jest.setTimeout(10000); + + +function createBasicSubscriptionManagerConfig() : subscription_manager.SubscriptionManagerConfig { + return { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions: 1, + operationTimeoutInSeconds: 30, + }; +} + +test('Subscription Manager - Acquire Subscribing Success', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let filter2 = "hello/world"; + let filter3 = "a/b/events"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'subscribe', + args: { + topicFilter: filter2, + timeoutInSeconds: 30 + } + }, + { + methodName: 'subscribe', + args: { + topicFilter: filter3, + timeoutInSeconds: 30 + } + } + ); + + let subscribeSuccessPromise1 = once(subscriptionManager, subscription_manager.SubscriptionManager.SUBSCRIBE_SUCCESS); + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + adapter.completeSubscribe(filter1); + + let subscribeSuccess1 = (await subscribeSuccessPromise1)[0]; + expect(subscribeSuccess1.topicFilter).toEqual(filter1); + expect(subscribeSuccess1.operationId).toEqual(1); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 1)); + + let subscribeSuccessPromise2 = once(subscriptionManager, subscription_manager.SubscriptionManager.SUBSCRIBE_SUCCESS); + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + adapter.completeSubscribe(filter2); + + let subscribeSuccess2 = (await subscribeSuccessPromise2)[0]; + expect(subscribeSuccess2.topicFilter).toEqual(filter2); + expect(subscribeSuccess2.operationId).toEqual(2); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 2)); + + let streamingSubscriptionEstablishedPromise = once(subscriptionManager, subscription_manager.SubscriptionManager.STREAMING_SUBSCRIPTION_ESTABLISHED); + + expect(subscriptionManager.acquireSubscription({ + operationId: 3, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter3] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + adapter.completeSubscribe(filter3); + + let streamingSubscriptionEstablished = (await streamingSubscriptionEstablishedPromise)[0]; + expect(streamingSubscriptionEstablished.topicFilter).toEqual(filter3); + expect(streamingSubscriptionEstablished.operationId).toEqual(3); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Acquire Multiple Subscribing Success', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/accepted"; + let filter2 = "a/b/rejected"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'subscribe', + args: { + topicFilter: filter2, + timeoutInSeconds: 30 + } + }, + ); + + let allPromise = newLiftedPromise(); + let subscribeSuccesses = new Array(); + subscriptionManager.addListener(subscription_manager.SubscriptionManager.SUBSCRIBE_SUCCESS, (event) => { + subscribeSuccesses.push(event); + if (subscribeSuccesses.length == 2) { + allPromise.resolve(); + } + }); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1, filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + adapter.completeSubscribe(filter1); + adapter.completeSubscribe(filter2); + + await allPromise.promise; + + let expectedSubscribeSuccesses = new Array( + { + topicFilter: filter1, + operationId: 1, + }, + { + topicFilter: filter2, + operationId: 1, + } + ); + expect(subscribeSuccesses).toEqual(expectedSubscribeSuccesses); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Acquire Existing Subscribing', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let filter2 = "hello/world"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'subscribe', + args: { + topicFilter: filter2, + timeoutInSeconds: 30 + } + } + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); + + expect(subscriptionManager.acquireSubscription({ + operationId: 3, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(subscriptionManager.acquireSubscription({ + operationId: 4, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Acquire Multi Existing Subscribing', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let filter2 = "hello/world"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'subscribe', + args: { + topicFilter: filter2, + timeoutInSeconds: 30 + } + } + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1, filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1, filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Acquire Multi Partially Subscribed', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let filter2 = "hello/world"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'subscribe', + args: { + topicFilter: filter2, + timeoutInSeconds: 30 + } + } + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 1)); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1, filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Acquire Subscribed Success', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let filter2 = "hello/world"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'subscribe', + args: { + topicFilter: filter2, + timeoutInSeconds: 30 + } + }, + ); + + let subscribeSuccessPromise1 = once(subscriptionManager, subscription_manager.SubscriptionManager.SUBSCRIBE_SUCCESS); + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + adapter.completeSubscribe(filter1); + + let subscribeSuccess1 = (await subscribeSuccessPromise1)[0]; + expect(subscribeSuccess1.topicFilter).toEqual(filter1); + expect(subscribeSuccess1.operationId).toEqual(1); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 1)); + + let streamingSubscriptionEstablishedPromise = once(subscriptionManager, subscription_manager.SubscriptionManager.STREAMING_SUBSCRIPTION_ESTABLISHED); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + adapter.completeSubscribe(filter2); + + let streamingSubscriptionEstablished = (await streamingSubscriptionEstablishedPromise)[0]; + expect(streamingSubscriptionEstablished.topicFilter).toEqual(filter2); + expect(streamingSubscriptionEstablished.operationId).toEqual(2); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); + + expect(subscriptionManager.acquireSubscription({ + operationId: 3, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribed); + + expect(subscriptionManager.acquireSubscription({ + operationId: 4, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribed); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Acquire Multi Subscribed Success', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let filter2 = "hello/world"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'subscribe', + args: { + topicFilter: filter2, + timeoutInSeconds: 30 + } + }, + ); + + let subscribeSuccessPromise1 = once(subscriptionManager, subscription_manager.SubscriptionManager.SUBSCRIBE_SUCCESS); + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + adapter.completeSubscribe(filter1); + + let subscribeSuccess1 = (await subscribeSuccessPromise1)[0]; + expect(subscribeSuccess1.topicFilter).toEqual(filter1); + expect(subscribeSuccess1.operationId).toEqual(1); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 1)); + + let subscribeSuccessPromise2 = once(subscriptionManager, subscription_manager.SubscriptionManager.SUBSCRIBE_SUCCESS); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + adapter.completeSubscribe(filter2); + + let subscribeSuccess2 = (await subscribeSuccessPromise2)[0]; + expect(subscribeSuccess2.topicFilter).toEqual(filter2); + expect(subscribeSuccess2.operationId).toEqual(2); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); + + expect(subscriptionManager.acquireSubscription({ + operationId: 3, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1, filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribed); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Acquire Request-Response Blocked', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let filter2 = "hello/world"; + let filter3 = "fail/ure"; + + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'subscribe', + args: { + topicFilter: filter2, + timeoutInSeconds: 30 + } + }, + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 1)); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); + + expect(subscriptionManager.acquireSubscription({ + operationId: 3, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter3] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Blocked); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Acquire Multi Request-Response Partial Blocked', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let filter2 = "hello/world"; + let filter3 = "fail/ure"; + + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter2, filter3] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Blocked); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Acquire Streaming Blocked', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let filter2 = "hello/world"; + + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'unsubscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + let streamingSubscriptionEstablishedPromise = once(subscriptionManager, subscription_manager.SubscriptionManager.STREAMING_SUBSCRIPTION_ESTABLISHED); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + adapter.completeSubscribe(filter1); + + let streamingSubscriptionEstablished = (await streamingSubscriptionEstablishedPromise)[0]; + expect(streamingSubscriptionEstablished.topicFilter).toEqual(filter1); + expect(streamingSubscriptionEstablished.operationId).toEqual(1); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 1)); + + let subscriptionOrphanedPromise = once(subscriptionManager, subscription_manager.SubscriptionManager.SUBSCRIPTION_ORPHANED); + subscriptionManager.releaseSubscription({ + operationId: 1, + topicFilters: [filter1] + }); + + let subscriptionOrphaned = (await subscriptionOrphanedPromise)[0]; + expect(subscriptionOrphaned.topicFilter).toEqual(filter1); + + subscriptionManager.purge(); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Blocked); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Acquire Multi Streaming Blocked', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + let config = createBasicSubscriptionManagerConfig(); + config.maxStreamingSubscriptions = 2; + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, config); + + let filter1 = "a/b/+"; + let filter2 = "hello/world"; + let filter3 = "foo/bar"; + + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'unsubscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + let streamingSubscriptionEstablishedPromise = once(subscriptionManager, subscription_manager.SubscriptionManager.STREAMING_SUBSCRIPTION_ESTABLISHED); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + adapter.completeSubscribe(filter1); + + let streamingSubscriptionEstablished = (await streamingSubscriptionEstablishedPromise)[0]; + expect(streamingSubscriptionEstablished.topicFilter).toEqual(filter1); + expect(streamingSubscriptionEstablished.operationId).toEqual(1); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 1)); + + let subscriptionOrphanedPromise = once(subscriptionManager, subscription_manager.SubscriptionManager.SUBSCRIPTION_ORPHANED); + subscriptionManager.releaseSubscription({ + operationId: 1, + topicFilters: [filter1] + }); + + let subscriptionOrphaned = (await subscriptionOrphanedPromise)[0]; + expect(subscriptionOrphaned.topicFilter).toEqual(filter1); + + subscriptionManager.purge(); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter2, filter3] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Blocked); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Acquire Streaming NoCapacity, None Allowed', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + let config = createBasicSubscriptionManagerConfig(); + config.maxStreamingSubscriptions = 0; + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, config); + + let filter1 = "a/b/+"; + + let expectedApiCalls : Array = new Array(); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.NoCapacity); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Acquire Streaming NoCapacity, Too Many', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + let config = createBasicSubscriptionManagerConfig(); + config.maxStreamingSubscriptions = 4; + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, config); + + for (let i = 0; i < 4; i++) { + let filter = `a/b/${i}`; + expect(subscriptionManager.acquireSubscription({ + operationId: i + 1, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + } + + let filter1 = "hello/world"; + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.NoCapacity); +}); + +test('Subscription Manager - Acquire Multi Streaming NoCapacity', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + let config = createBasicSubscriptionManagerConfig(); + config.maxStreamingSubscriptions = 2; + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, config); + + let filter1 = "a/b/+"; + let filter2 = "hello/world"; + let filter3 = "foo/bar"; + + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + let streamingSubscriptionEstablishedPromise = once(subscriptionManager, subscription_manager.SubscriptionManager.STREAMING_SUBSCRIPTION_ESTABLISHED); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + adapter.completeSubscribe(filter1); + + let streamingSubscriptionEstablished = (await streamingSubscriptionEstablishedPromise)[0]; + expect(streamingSubscriptionEstablished.topicFilter).toEqual(filter1); + expect(streamingSubscriptionEstablished.operationId).toEqual(1); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter2, filter3] + })).toEqual(subscription_manager.AcquireSubscriptionResult.NoCapacity); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Acquire Failure Mixed Subscription Types', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + let config = createBasicSubscriptionManagerConfig(); + config.maxStreamingSubscriptions = 2; + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, config); + + let filter1 = "a/b/+"; + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Failure); +}); + +test('Subscription Manager - Acquire Multi Failure Mixed Subscription Types', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + let config = createBasicSubscriptionManagerConfig(); + config.maxStreamingSubscriptions = 2; + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, config); + + let filter1 = "a/b/+"; + let filter2 = "c/d"; + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1, filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Failure); +}); + +test('Subscription Manager - Acquire Failure Poisoned', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + let config = createBasicSubscriptionManagerConfig(); + config.maxStreamingSubscriptions = 2; + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, config); + + let filter1 = "a/b/+"; + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + let subscriptionHaltedPromise = once(subscriptionManager, subscription_manager.SubscriptionManager.STREAMING_SUBSCRIPTION_HALTED); + + adapter.completeSubscribe(filter1, new CrtError("Unrecoverable Error")); + + let subscriptionHalted = (await subscriptionHaltedPromise)[0]; + expect(subscriptionHalted.topicFilter).toEqual(filter1); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Failure); +}); + + + + +test('Subscription Manager - RequestResponse Multi Acquire/Release triggers Unsubscribe', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/accepted"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'unsubscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + let allPromise = newLiftedPromise(); + let events = new Array(); + subscriptionManager.addListener(subscription_manager.SubscriptionManager.SUBSCRIBE_SUCCESS, (event) => { + events.push({ + type: subscription_manager.SubscriptionEventType.SubscribeSuccess, + data: event + }); + if (events.length == 2) { + allPromise.resolve(); + } + }); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + adapter.completeSubscribe(filter1); + + await allPromise.promise; + + let expectedSubscribeSuccesses : protocol_adapter_mock.SubscriptionManagerEvent[] = [ + { + type: subscription_manager.SubscriptionEventType.SubscribeSuccess, + data: { + topicFilter: filter1, + operationId: 1, + } + }, + { + type: subscription_manager.SubscriptionEventType.SubscribeSuccess, + data: { + topicFilter: filter1, + operationId: 2, + } + } + ]; + + expect(protocol_adapter_mock.subscriptionManagerEventSequenceContainsEvents(events, expectedSubscribeSuccesses)).toBeTruthy(); + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 1)); + + subscriptionManager.releaseSubscription({ + operationId: 1, + topicFilters: [filter1] + }); + + subscriptionManager.purge(); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 1)); + + subscriptionManager.releaseSubscription({ + operationId: 2, + topicFilters: [filter1] + }); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 1)); + + subscriptionManager.purge(); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Multi Acquire/Release Multi triggers Unsubscribes', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/accepted"; + let filter2 = "a/b/rejected"; + let expectedSubscribes : protocol_adapter_mock.ProtocolAdapterApiCall[] = [ + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'subscribe', + args: { + topicFilter: filter2, + timeoutInSeconds: 30 + } + }, + ]; + + let expectedUnsubscribes : protocol_adapter_mock.ProtocolAdapterApiCall[] = [ + { + methodName: 'unsubscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'unsubscribe', + args: { + topicFilter: filter2, + timeoutInSeconds: 30 + } + }, + ]; + + let allSubscribedPromise = newLiftedPromise(); + let events = new Array(); + subscriptionManager.addListener(subscription_manager.SubscriptionManager.SUBSCRIBE_SUCCESS, (event) => { + events.push({ + type: subscription_manager.SubscriptionEventType.SubscribeSuccess, + data: event + }); + if (events.length == 4) { + allSubscribedPromise.resolve(); + } + }); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1, filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1, filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + adapter.completeSubscribe(filter1); + adapter.completeSubscribe(filter2); + + await allSubscribedPromise.promise; + + let expectedSubscribeSuccesses : protocol_adapter_mock.SubscriptionManagerEvent[] = [ + { + type: subscription_manager.SubscriptionEventType.SubscribeSuccess, + data: { + topicFilter: filter1, + operationId: 1, + } + }, + { + type: subscription_manager.SubscriptionEventType.SubscribeSuccess, + data: { + topicFilter: filter1, + operationId: 2, + } + }, + { + type: subscription_manager.SubscriptionEventType.SubscribeSuccess, + data: { + topicFilter: filter2, + operationId: 1, + } + }, + { + type: subscription_manager.SubscriptionEventType.SubscribeSuccess, + data: { + topicFilter: filter2, + operationId: 2, + } + }, + ]; + + expect(protocol_adapter_mock.subscriptionManagerEventSequenceContainsEvents(events, expectedSubscribeSuccesses)).toBeTruthy(); + expect(protocol_adapter_mock.protocolAdapterApiCallSequenceContainsApiCalls(adapter.getApiCalls(), expectedSubscribes)).toBeTruthy(); + + subscriptionManager.releaseSubscription({ + operationId: 1, + topicFilters: [filter1, filter2] + }); + + subscriptionManager.purge(); + + expect(protocol_adapter_mock.protocolAdapterApiCallSequenceContainsApiCalls(adapter.getApiCalls(), expectedUnsubscribes)).toBeFalsy(); + + subscriptionManager.releaseSubscription({ + operationId: 2, + topicFilters: [filter1, filter2] + }); + + expect(protocol_adapter_mock.protocolAdapterApiCallSequenceContainsApiCalls(adapter.getApiCalls(), expectedUnsubscribes)).toBeFalsy(); + + subscriptionManager.purge(); + + expect(protocol_adapter_mock.protocolAdapterApiCallSequenceContainsApiCalls(adapter.getApiCalls(), expectedUnsubscribes)).toBeTruthy(); +}); + +test('Subscription Manager - Streaming Multi Acquire/Release triggers Unsubscribe', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/accepted"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'unsubscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + let allPromise = newLiftedPromise(); + let events = new Array(); + subscriptionManager.addListener(subscription_manager.SubscriptionManager.STREAMING_SUBSCRIPTION_ESTABLISHED, (event) => { + events.push({ + type: subscription_manager.SubscriptionEventType.StreamingSubscriptionEstablished, + data: event + }); + if (events.length == 2) { + allPromise.resolve(); + } + }); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + adapter.completeSubscribe(filter1); + + await allPromise.promise; + + let expectedStreamingSubscriptionEstablishments : protocol_adapter_mock.SubscriptionManagerEvent[] = [ + { + type: subscription_manager.SubscriptionEventType.StreamingSubscriptionEstablished, + data: { + topicFilter: filter1, + operationId: 1, + } + }, + { + type: subscription_manager.SubscriptionEventType.StreamingSubscriptionEstablished, + data: { + topicFilter: filter1, + operationId: 2, + } + } + ]; + + expect(protocol_adapter_mock.subscriptionManagerEventSequenceContainsEvents(events, expectedStreamingSubscriptionEstablishments)).toBeTruthy(); + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 1)); + + subscriptionManager.releaseSubscription({ + operationId: 1, + topicFilters: [filter1] + }); + + subscriptionManager.purge(); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 1)); + + subscriptionManager.releaseSubscription({ + operationId: 2, + topicFilters: [filter1] + }); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 1)); + + subscriptionManager.purge(); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +async function doUnsubscribeMakesRoomTest(shouldUnsubscribeSucceed: boolean) { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/accepted"; + let filter2 = "a/b/rejected"; + let filter3 = "hello/world"; + let expectedSubscribes : protocol_adapter_mock.ProtocolAdapterApiCall[] = [ + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'subscribe', + args: { + topicFilter: filter2, + timeoutInSeconds: 30 + } + }, + ]; + + let blockedSubscribe = { + methodName: 'subscribe', + args: { + topicFilter: filter3, + timeoutInSeconds: 30 + } + }; + + let expectedUnsubscribes : protocol_adapter_mock.ProtocolAdapterApiCall[] = [ + { + methodName: 'unsubscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ]; + + let allSubscribedPromise = newLiftedPromise(); + let events = new Array(); + subscriptionManager.addListener(subscription_manager.SubscriptionManager.SUBSCRIBE_SUCCESS, (event) => { + events.push({ + type: subscription_manager.SubscriptionEventType.SubscribeSuccess, + data: event + }); + if (events.length == 2) { + allSubscribedPromise.resolve(); + } + }); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(subscriptionManager.acquireSubscription({ + operationId: 3, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter3] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Blocked); + + adapter.completeSubscribe(filter1); + adapter.completeSubscribe(filter2); + + await allSubscribedPromise.promise; + + let expectedSubscribeSuccesses : protocol_adapter_mock.SubscriptionManagerEvent[] = [ + { + type: subscription_manager.SubscriptionEventType.SubscribeSuccess, + data: { + topicFilter: filter1, + operationId: 1, + } + }, + { + type: subscription_manager.SubscriptionEventType.SubscribeSuccess, + data: { + topicFilter: filter2, + operationId: 2, + } + }, + ]; + + expect(protocol_adapter_mock.subscriptionManagerEventSequenceContainsEvents(events, expectedSubscribeSuccesses)).toBeTruthy(); + expect(protocol_adapter_mock.protocolAdapterApiCallSequenceContainsApiCalls(adapter.getApiCalls(), expectedSubscribes)).toBeTruthy(); + + expect(subscriptionManager.acquireSubscription({ + operationId: 3, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter3] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Blocked); + + subscriptionManager.releaseSubscription({ + operationId: 1, + topicFilters: [filter1] + }); + + expect(subscriptionManager.acquireSubscription({ + operationId: 3, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter3] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Blocked); + + expect(protocol_adapter_mock.protocolAdapterApiCallSequenceContainsApiCalls(adapter.getApiCalls(), expectedUnsubscribes)).toBeFalsy(); + + subscriptionManager.purge(); + + expect(subscriptionManager.acquireSubscription({ + operationId: 3, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter3] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Blocked); + + expect(protocol_adapter_mock.protocolAdapterApiCallSequenceContainsApiCalls(adapter.getApiCalls(), expectedUnsubscribes)).toBeTruthy(); + + if (shouldUnsubscribeSucceed) { + adapter.completeUnsubscribe(filter1); + } else { + adapter.completeUnsubscribe(filter1, new CrtError("Help")); + } + + expect(protocol_adapter_mock.protocolAdapterApiCallSequenceContainsApiCall(adapter.getApiCalls(), blockedSubscribe)).toBeFalsy(); + + subscriptionManager.purge(); + + let expectedAcquireResult = shouldUnsubscribeSucceed ? subscription_manager.AcquireSubscriptionResult.Subscribing : subscription_manager.AcquireSubscriptionResult.Blocked; + expect(subscriptionManager.acquireSubscription({ + operationId: 3, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter3] + })).toEqual(expectedAcquireResult); + + expect(protocol_adapter_mock.protocolAdapterApiCallSequenceContainsApiCall(adapter.getApiCalls(), blockedSubscribe)).toEqual(shouldUnsubscribeSucceed); +} + +test('Subscription Manager - Successful Unsubscribe Frees Subscription Space', async () => { + await doUnsubscribeMakesRoomTest(true); +}); + +test('Subscription Manager - Unsuccessful Unsubscribe Does Not Free Subscription Space', async () => { + await doUnsubscribeMakesRoomTest(false); +}); + +test('Subscription Manager - Synchronous RequestResponse Subscribe Failure causes acquire failure', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter({ + subscribeHandler: (subscribeOptions) => { throw new CrtError("Bad"); } + }); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Failure); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Synchronous Streaming Subscribe Failure causes acquire failure and poisons future acquires', async () => { + let attemptNumber = 0; + + let adapter = new protocol_adapter_mock.MockProtocolAdapter({ + subscribeHandler: (subscribeOptions) => { + attemptNumber++; + if (attemptNumber == 1) { + throw new CrtError("Bad"); + } + } + }); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Failure); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); + + subscriptionManager.purge(); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Failure); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - RequestResponse Acquire Subscribe with error emits SubscribeFailed', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + let subscribeFailedPromise = once(subscriptionManager, subscription_manager.SubscriptionManager.SUBSCRIBE_FAILURE); + adapter.completeSubscribe(filter1, new CrtError("Derp")); + + let subscribeFailed = (await subscribeFailedPromise)[0]; + expect(subscribeFailed.topicFilter).toEqual(filter1); + expect(subscribeFailed.operationId).toEqual(1); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +test('Subscription Manager - Streaming Acquire Subscribe with retryable error triggers resubscribe', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0, 1)); + + adapter.completeSubscribe(filter1, new CrtError("Derp"), true); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +function getExpectedEventTypeForOfflineAcquireOnlineTest(subscriptionType: subscription_manager.SubscriptionType, shouldSubscribeSucceed: boolean) : subscription_manager.SubscriptionEventType { + if (subscriptionType == subscription_manager.SubscriptionType.RequestResponse) { + if (shouldSubscribeSucceed) { + return subscription_manager.SubscriptionEventType.SubscribeSuccess; + } else { + return subscription_manager.SubscriptionEventType.SubscribeFailure; + } + } else { + if (shouldSubscribeSucceed) { + return subscription_manager.SubscriptionEventType.StreamingSubscriptionEstablished; + } else { + return subscription_manager.SubscriptionEventType.StreamingSubscriptionHalted; + } + } +} + +async function offlineAcquireOnlineTest(subscriptionType: subscription_manager.SubscriptionType, shouldSubscribeSucceed: boolean) { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscriptionType, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual([]); + + adapter.connect(); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); + + let anyPromise = newLiftedPromise(); + let events = new Array(); + subscriptionManager.addListener(subscription_manager.SubscriptionManager.SUBSCRIBE_SUCCESS, (event) => { + events.push({ + type: subscription_manager.SubscriptionEventType.SubscribeSuccess, + data: event + }); + anyPromise.resolve(); + }); + subscriptionManager.addListener(subscription_manager.SubscriptionManager.SUBSCRIBE_FAILURE, (event) => { + events.push({ + type: subscription_manager.SubscriptionEventType.SubscribeFailure, + data: event + }); + anyPromise.resolve(); + }); + subscriptionManager.addListener(subscription_manager.SubscriptionManager.STREAMING_SUBSCRIPTION_ESTABLISHED, (event) => { + events.push({ + type: subscription_manager.SubscriptionEventType.StreamingSubscriptionEstablished, + data: event + }); + anyPromise.resolve(); + }); + subscriptionManager.addListener(subscription_manager.SubscriptionManager.STREAMING_SUBSCRIPTION_HALTED, (event) => { + events.push({ + type: subscription_manager.SubscriptionEventType.StreamingSubscriptionHalted, + data: event + }); + anyPromise.resolve(); + }); + + if (shouldSubscribeSucceed) { + adapter.completeSubscribe(filter1); + } else { + adapter.completeSubscribe(filter1, new CrtError("Argh")); + } + + await anyPromise.promise; + + expect(events.length).toEqual(1); + let event = events[0]; + expect(event.type).toEqual(getExpectedEventTypeForOfflineAcquireOnlineTest(subscriptionType, shouldSubscribeSucceed)); + expect(event.data.topicFilter).toEqual(filter1); +} + +test('Subscription Manager - RequestResponse Acquire While Offline, Going online triggers Subscribe, Subscribe Success', async () => { + await offlineAcquireOnlineTest(subscription_manager.SubscriptionType.RequestResponse, true); +}); + +test('Subscription Manager - RequestResponse Acquire While Offline, Going online triggers Subscribe, Subscribe Failure', async () => { + await offlineAcquireOnlineTest(subscription_manager.SubscriptionType.RequestResponse, false); +}); + +test('Subscription Manager - Streaming Acquire While Offline, Going online triggers Subscribe, Subscribe Success', async () => { + await offlineAcquireOnlineTest(subscription_manager.SubscriptionType.EventStream, true); +}); + +test('Subscription Manager - Streaming Acquire While Offline, Going online triggers Subscribe, Subscribe Failure', async () => { + await offlineAcquireOnlineTest(subscription_manager.SubscriptionType.EventStream, false); +}); + +async function offlineAcquireReleaseOnlineTest(subscriptionType: subscription_manager.SubscriptionType) { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let filter2 = "hello/world" + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter2, + timeoutInSeconds: 30 + } + }, + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscriptionType, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual([]); + + subscriptionManager.releaseSubscription({ + operationId: 1, + topicFilters: [filter1], + }); + + expect(adapter.getApiCalls()).toEqual([]); + + adapter.connect(); + + expect(adapter.getApiCalls()).toEqual([]); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscriptionType, + topicFilters: [filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +} + +test('Subscription Manager - RequestResponse Acquire-Release While Offline, Going online triggers nothing', async () => { + await offlineAcquireReleaseOnlineTest(subscription_manager.SubscriptionType.RequestResponse); +}); + +test('Subscription Manager - Streaming Acquire-Release While Offline, Going online triggers nothing', async () => { + await offlineAcquireReleaseOnlineTest(subscription_manager.SubscriptionType.EventStream); +}); + +async function acquireOfflineReleaseAcquireOnlineTest(subscriptionType: subscription_manager.SubscriptionType) { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + let config = createBasicSubscriptionManagerConfig(); + config.maxStreamingSubscriptions = 2; + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, config); + + let filter1 = "a/b/+"; + let filter2 = "hello/world"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'subscribe', + args: { + topicFilter: filter2, + timeoutInSeconds: 30 + } + }, + { + methodName: 'unsubscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscriptionType, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0,1)); + + adapter.completeSubscribe(filter1); + + adapter.disconnect(); + + subscriptionManager.releaseSubscription({ + operationId: 1, + topicFilters: [filter1], + }); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0,1)); + + expect(subscriptionManager.acquireSubscription({ + operationId: 2, + type: subscriptionType, + topicFilters: [filter2] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(protocol_adapter_mock.protocolAdapterApiCallSequenceContainsApiCalls(adapter.getApiCalls(), expectedApiCalls)).toBeFalsy(); + + adapter.connect(true); + + expect(protocol_adapter_mock.protocolAdapterApiCallSequenceContainsApiCalls(adapter.getApiCalls(), expectedApiCalls)).toBeTruthy(); +} + +test('Subscription Manager - RequestResponse Release-Acquire2 while offline, Going online triggers Unsubscribe and Subscribe', async () => { + await acquireOfflineReleaseAcquireOnlineTest(subscription_manager.SubscriptionType.RequestResponse); +}); + +test('Subscription Manager - Streaming Release-Acquire2 while offline, Going online triggers Unsubscribe and Subscribe', async () => { + await acquireOfflineReleaseAcquireOnlineTest(subscription_manager.SubscriptionType.EventStream); +}); + +async function closeTest(subscriptionType: subscription_manager.SubscriptionType, completeSubscribe: boolean, closeWhileConnected: boolean) { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'unsubscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscriptionType, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0,1)); + + if (completeSubscribe) { + adapter.completeSubscribe(filter1); + } + + if (!closeWhileConnected) { + adapter.disconnect(); + } + + subscriptionManager.close(); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +} + +test('Subscription Manager - Close while request-response subscribed and online triggers unsubscribe', async () => { + await closeTest(subscription_manager.SubscriptionType.RequestResponse, true, true); +}); + +test('Subscription Manager - Close while streaming subscribed and online triggers unsubscribe', async () => { + await closeTest(subscription_manager.SubscriptionType.EventStream, true, true); +}); + +test('Subscription Manager - Close while request-response subscribing and online triggers unsubscribe', async () => { + await closeTest(subscription_manager.SubscriptionType.RequestResponse, false, true); +}); + +test('Subscription Manager - Close while streaming subscribing and online triggers unsubscribe', async () => { + await closeTest(subscription_manager.SubscriptionType.EventStream, false, true); +}); + +test('Subscription Manager - Close while request-response subscribing and offline triggers unsubscribe', async () => { + await closeTest(subscription_manager.SubscriptionType.RequestResponse, false, false); +}); + +test('Subscription Manager - Close while streaming subscribing and offline triggers unsubscribe', async () => { + await closeTest(subscription_manager.SubscriptionType.EventStream, false, false); +}); + +async function noSessionSubscriptionEndedTest(offlineWhileUnsubscribing: boolean) { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'unsubscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0,1)); + + adapter.completeSubscribe(filter1); + + if (offlineWhileUnsubscribing) { + subscriptionManager.releaseSubscription({ + operationId: 1, + topicFilters: [filter1] + }); + + subscriptionManager.purge(); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); + } + + let subscriptionEndedPromise = once(subscriptionManager, subscription_manager.SubscriptionManager.SUBSCRIPTION_ENDED); + + adapter.disconnect(); + adapter.connect(); + + if (!offlineWhileUnsubscribing) { + let subscriptionEnded = (await subscriptionEndedPromise)[0]; + expect(subscriptionEnded.topicFilter).toEqual(filter1); + } + + let reaquire: subscription_manager.AcquireSubscriptionConfig = { + operationId: 2, + type: subscription_manager.SubscriptionType.RequestResponse, + topicFilters : [filter1], + }; + + if (offlineWhileUnsubscribing) { + expect(subscriptionManager.acquireSubscription(reaquire)).toEqual(subscription_manager.AcquireSubscriptionResult.Blocked); + + adapter.completeUnsubscribe(filter1, new CrtError("timeout")); + } + + expect(subscriptionManager.acquireSubscription(reaquire)).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); +} + +test('Subscription Manager - Subscribed Session Rejoin Failure triggers subscription ended', async () => { + await noSessionSubscriptionEndedTest(false); +}); + +test('Subscription Manager - Subscribed Session Rejoin Failure while unsubscribing triggers subscription ended', async () => { + await noSessionSubscriptionEndedTest(true); +}); + +test('Subscription Manager - Subscribed Streaming Session Rejoin Failure triggers resubscribe and emits SubscriptionLost', async () => { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0,1)); + + adapter.completeSubscribe(filter1); + + let subscriptionLostPromise = once(subscriptionManager, subscription_manager.SubscriptionManager.STREAMING_SUBSCRIPTION_LOST); + + adapter.disconnect(); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0,1)); + + adapter.connect(); + + let subscriptionLost = (await subscriptionLostPromise)[0]; + expect(subscriptionLost.topicFilter).toEqual(filter1); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +}); + +async function doPurgeTest(subscriptionType: subscription_manager.SubscriptionType) { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(); + adapter.connect(); + + // @ts-ignore + let subscriptionManager = new subscription_manager.SubscriptionManager(adapter, createBasicSubscriptionManagerConfig()); + + let filter1 = "a/b/+"; + let expectedApiCalls : Array = new Array( + { + methodName: 'subscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + { + methodName: 'unsubscribe', + args: { + topicFilter: filter1, + timeoutInSeconds: 30 + } + }, + ); + + expect(subscriptionManager.acquireSubscription({ + operationId: 1, + type: subscription_manager.SubscriptionType.EventStream, + topicFilters: [filter1] + })).toEqual(subscription_manager.AcquireSubscriptionResult.Subscribing); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0,1)); + + adapter.completeSubscribe(filter1); + + let subscriptionOrphanedPromise = once(subscriptionManager, subscription_manager.SubscriptionManager.SUBSCRIPTION_ORPHANED); + + subscriptionManager.releaseSubscription({ + operationId: 1, + topicFilters: [filter1], + }); + + let subscriptionOrphaned = (await subscriptionOrphanedPromise)[0]; + expect(subscriptionOrphaned.topicFilter).toEqual(filter1); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls.slice(0,1)); + + subscriptionManager.purge(); + + expect(adapter.getApiCalls()).toEqual(expectedApiCalls); +} + +test('Subscription Manager - Subscribed RequestResponse emits orphaned event on release', async () => { + await doPurgeTest(subscription_manager.SubscriptionType.RequestResponse); +}); + +test('Subscription Manager - Subscribed Streaming emits orphaned event on release', async () => { + await doPurgeTest(subscription_manager.SubscriptionType.EventStream); +}); \ No newline at end of file diff --git a/lib/browser/mqtt_request_response/subscription_manager.ts b/lib/browser/mqtt_request_response/subscription_manager.ts new file mode 100644 index 000000000..07e662bba --- /dev/null +++ b/lib/browser/mqtt_request_response/subscription_manager.ts @@ -0,0 +1,653 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +import {BufferedEventEmitter} from "../../common/event"; +import * as protocol_adapter from "./protocol_adapter"; +import * as io from "../../common/io"; + +/** + * + * @packageDocumentation + * @module mqtt_request_response + * + */ + +// exported for tests only +export enum SubscriptionEventType { + SubscribeSuccess, + SubscribeFailure, + SubscriptionEnded, + StreamingSubscriptionEstablished, + StreamingSubscriptionLost, + StreamingSubscriptionHalted, + SubscriptionOrphaned, + UnsubscribeComplete +} + +function subscriptionEventTypeToString(eventType: SubscriptionEventType) : string { + switch (eventType) { + case SubscriptionEventType.SubscribeSuccess: + return "SubscribeSuccess"; + case SubscriptionEventType.SubscribeFailure: + return "SubscribeFailure"; + case SubscriptionEventType.SubscriptionEnded: + return "SubscriptionEnded"; + case SubscriptionEventType.StreamingSubscriptionEstablished: + return "StreamingSubscriptionEstablished"; + case SubscriptionEventType.StreamingSubscriptionLost: + return "StreamingSubscriptionLost"; + case SubscriptionEventType.StreamingSubscriptionHalted: + return "StreamingSubscriptionHalted"; + case SubscriptionEventType.SubscriptionOrphaned: + return "SubscriptionOrphaned"; + case SubscriptionEventType.UnsubscribeComplete: + return "UnsubscribeComplete"; + default: + return "Unknown"; + } +} + +export interface SubscribeSuccessEvent { + topicFilter: string, + operationId: number, +} + +export type SubscribeSuccessEventListener = (event: SubscribeSuccessEvent) => void; + +export interface SubscribeFailureEvent { + topicFilter: string, + operationId: number, +} + +export type SubscribeFailureEventListener = (event: SubscribeFailureEvent) => void; + +export interface SubscriptionEndedEvent { + topicFilter: string, + operationId: number, +} + +export type SubscriptionEndedEventListener = (event: SubscriptionEndedEvent) => void; + +export interface StreamingSubscriptionEstablishedEvent { + topicFilter: string, + operationId: number, +} + +export type StreamingSubscriptionEstablishedEventListener = (event: StreamingSubscriptionEstablishedEvent) => void; + +export interface StreamingSubscriptionLostEvent { + topicFilter: string, + operationId: number, +} + +export type StreamingSubscriptionLostEventListener = (event: StreamingSubscriptionLostEvent) => void; + +export interface StreamingSubscriptionHaltedEvent { + topicFilter: string, + operationId: number, +} + +export type StreamingSubscriptionHaltedEventListener = (event: StreamingSubscriptionHaltedEvent) => void; + +export interface SubscriptionOrphanedEvent { + topicFilter: string, +} + +export type SubscriptionOrphanedEventListener = (event: SubscriptionOrphanedEvent) => void; + +export interface UnsubscribeCompleteEvent { + topicFilter: string, +} + +export type UnsubscribeCompleteEventListener = (event: UnsubscribeCompleteEvent) => void; + +export enum SubscriptionType { + EventStream, + RequestResponse, +} + +export interface AcquireSubscriptionConfig { + topicFilters: Array, + operationId: number, + type: SubscriptionType, +} + +export interface ReleaseSubscriptionsConfig { + topicFilters: Array, + operationId: number, +} + +export enum AcquireSubscriptionResult { + Subscribed, + Subscribing, + Blocked, + NoCapacity, + Failure, +} + +export function acquireSubscriptionResultToString(result: AcquireSubscriptionResult) : string { + switch (result) { + case AcquireSubscriptionResult.Subscribed: + return "Subscribed"; + case AcquireSubscriptionResult.Subscribing: + return "Subscribing"; + case AcquireSubscriptionResult.Blocked: + return "Blocked"; + case AcquireSubscriptionResult.NoCapacity: + return "NoCapacity"; + case AcquireSubscriptionResult.Failure: + return "Failure"; + default: + return "Unknown"; + } +} + +export interface SubscriptionManagerConfig { + maxRequestResponseSubscriptions: number, + maxStreamingSubscriptions: number, + operationTimeoutInSeconds: number, +} + +enum SubscriptionStatus { + Subscribed, + NotSubscribed, +} + +enum SubscriptionPendingAction { + Nothing, + Subscribing, + Unsubscribing, +} + +interface SubscriptionRecord { + topicFilter: string, + listeners: Set, + status: SubscriptionStatus, + pendingAction: SubscriptionPendingAction, + type: SubscriptionType, + + /* + * A poisoned record represents a subscription that we will never try to subscribe to because a previous + * attempt resulted in a failure that we judge to be "terminal." Terminal failures include permission failures + * and validation failures. To remove a poisoned record, all listeners must be removed. For request-response + * operations this will happen naturally. For streaming operations, the operation must be closed by the user (in + * response to the user-facing event we emit on the streaming operation when the failure that poisons the + * record occurs). + */ + poisoned: boolean, +} + +interface SubscriptionStats { + requestResponseCount: number, + streamingCount: number, + unsubscribingStreamingCount: number, +} + +export class SubscriptionManager extends BufferedEventEmitter { + private static logSubject : string = "SubscriptionManager"; + + private closed: boolean = false; + private records: Map; + + constructor(private adapter: protocol_adapter.ProtocolClientAdapter, private options: SubscriptionManagerConfig) { + super(); + + this.records = new Map(); + + this.adapter.addListener(protocol_adapter.ProtocolClientAdapter.SUBSCRIBE_COMPLETION, this.handleSubscribeCompletionEvent.bind(this)); + this.adapter.addListener(protocol_adapter.ProtocolClientAdapter.UNSUBSCRIBE_COMPLETION, this.handleUnsubscribeCompletionEvent.bind(this)); + this.adapter.addListener(protocol_adapter.ProtocolClientAdapter.CONNECTION_STATUS, this.handleConnectionStatusEvent.bind(this)); + } + + close() { + this.closed = true; + + this.unsubscribeAll(); + } + + acquireSubscription(options: AcquireSubscriptionConfig) : AcquireSubscriptionResult { + if (this.closed) { + return AcquireSubscriptionResult.Failure; + } + + if (options.topicFilters.length == 0) { + return AcquireSubscriptionResult.Failure; + } + + for (let topicFilter of options.topicFilters) { + let existingRecord = this.records.get(topicFilter); + if (!existingRecord) { + continue; + } + + if (existingRecord.poisoned) { + io.logError(SubscriptionManager.logSubject, `acquire subscription for '${topicFilter}' via operation '${options.operationId}' failed - existing subscription is poisoned and has not been released`); + return AcquireSubscriptionResult.Failure; + } + if (existingRecord.type != options.type) { + io.logError(SubscriptionManager.logSubject, `acquire subscription for '${topicFilter}' via operation '${options.operationId}' failed - conflicts with subscription type of existing subscription`); + return AcquireSubscriptionResult.Failure; + } + } + + let subscriptionsNeeded : number = 0; + for (let topicFilter of options.topicFilters) { + let existingRecord = this.records.get(topicFilter); + if (existingRecord) { + if (existingRecord.pendingAction == SubscriptionPendingAction.Unsubscribing) { + io.logDebug(SubscriptionManager.logSubject, `acquire subscription for '${topicFilter}' via operation '${options.operationId}' blocked - existing subscription is unsubscribing`); + return AcquireSubscriptionResult.Blocked; + } + } else { + subscriptionsNeeded++; + } + } + + if (subscriptionsNeeded > 0) { + let stats = this.getStats(); + if (options.type == SubscriptionType.RequestResponse) { + if (subscriptionsNeeded > this.options.maxRequestResponseSubscriptions - stats.requestResponseCount) { + io.logDebug(SubscriptionManager.logSubject, `acquire subscription for request operation '${options.operationId}' blocked - insufficient room`); + return AcquireSubscriptionResult.Blocked; + } + } else { + if (subscriptionsNeeded + stats.streamingCount > this.options.maxStreamingSubscriptions) { + if (subscriptionsNeeded + stats.streamingCount <= this.options.maxStreamingSubscriptions + stats.unsubscribingStreamingCount) { + io.logDebug(SubscriptionManager.logSubject, `acquire subscription for streaming operation '${options.operationId}' blocked - insufficient room`); + return AcquireSubscriptionResult.Blocked; + } else { + io.logError(SubscriptionManager.logSubject, `acquire subscription for streaming operation '${options.operationId}' failed - insufficient room`); + return AcquireSubscriptionResult.NoCapacity; + } + } + } + } + + let isFullySubscribed = true; + for (let topicFilter of options.topicFilters) { + let existingRecord = this.records.get(topicFilter); + if (!existingRecord) { + existingRecord = { + topicFilter: topicFilter, + listeners: new Set(), + status: SubscriptionStatus.NotSubscribed, + pendingAction: SubscriptionPendingAction.Nothing, + type: options.type, + poisoned: false, + }; + + this.records.set(topicFilter, existingRecord); + } + + existingRecord.listeners.add(options.operationId); + io.logDebug(SubscriptionManager.logSubject, `added listener '${options.operationId}' to subscription '${topicFilter}', ${existingRecord.listeners.size} listeners total`); + + if (existingRecord.status != SubscriptionStatus.Subscribed) { + isFullySubscribed = false; + } + } + + if (isFullySubscribed) { + io.logDebug(SubscriptionManager.logSubject, `acquire subscription for operation '${options.operationId}' fully subscribed - all required subscriptions are active`); + return AcquireSubscriptionResult.Subscribed; + } + + for (let topicFilter of options.topicFilters) { + let existingRecord = this.records.get(topicFilter); + try { + // @ts-ignore + this.activateSubscription(existingRecord); + } catch (err) { + io.logError(SubscriptionManager.logSubject, `acquire subscription for operation '${options.operationId}' failed subscription activation: ${(err as Error).toString()}`); + return AcquireSubscriptionResult.Failure; + } + } + + io.logDebug(SubscriptionManager.logSubject, `acquire subscription for operation '${options.operationId}' subscribing - waiting on one or more subscriptions to complete`); + return AcquireSubscriptionResult.Subscribing; + } + + releaseSubscription(options: ReleaseSubscriptionsConfig) { + if (this.closed) { + return; + } + + for (let topicFilter of options.topicFilters) { + this.removeSubscriptionListener(topicFilter, options.operationId); + } + } + + purge() { + if (this.closed) { + return; + } + + io.logDebug(SubscriptionManager.logSubject, `purging unused subscriptions`); + let toRemove : Array = new Array(); + for (let [_, record] of this.records) { + if (record.listeners.size > 0) { + continue; + } + + io.logDebug(SubscriptionManager.logSubject, `subscription '${record.topicFilter}' has zero listeners and is a candidate for removal`); + + if (this.adapter.getConnectionState() == protocol_adapter.ConnectionState.Connected) { + this.unsubscribe(record, false); + } + + if (record.status == SubscriptionStatus.NotSubscribed && record.pendingAction == SubscriptionPendingAction.Nothing) { + toRemove.push(record.topicFilter); + } + } + + for (let topicFilter of toRemove) { + io.logDebug(SubscriptionManager.logSubject, `deleting subscription '${topicFilter}'`); + this.records.delete(topicFilter); + } + } + + static SUBSCRIBE_SUCCESS : string = 'subscribeSuccess'; + static SUBSCRIBE_FAILURE : string = 'subscribeFailure'; + static SUBSCRIPTION_ENDED : string = 'subscriptionEnded'; + static STREAMING_SUBSCRIPTION_ESTABLISHED : string = "streamingSubscriptionEstablished"; + static STREAMING_SUBSCRIPTION_LOST : string = "streamingSubscriptionLost"; + static STREAMING_SUBSCRIPTION_HALTED : string = "streamingSubscriptionHalted"; + static SUBSCRIPTION_ORPHANED : string = "subscriptionOrphaned"; + static UNSUBSCRIBE_COMPLETE : string = "unsubscribeComplete"; + + on(event: 'subscribeSuccess', listener: SubscribeSuccessEventListener): this; + on(event: 'subscribeFailure', listener: SubscribeFailureEventListener): this; + on(event: 'subscriptionEnded', listener: SubscriptionEndedEventListener): this; + on(event: 'streamingSubscriptionEstablished', listener: StreamingSubscriptionEstablishedEventListener): this; + on(event: 'streamingSubscriptionLost', listener: StreamingSubscriptionLostEventListener): this; + on(event: 'streamingSubscriptionHalted', listener: StreamingSubscriptionHaltedEventListener): this; + on(event: 'subscriptionOrphaned', listener: SubscriptionOrphanedEventListener): this; + on(event: 'unsubscribeComplete', listener: UnsubscribeCompleteEventListener): this; + + on(event: string | symbol, listener: (...args: any[]) => void): this { + super.on(event, listener); + return this; + } + + private getStats() : SubscriptionStats { + let stats : SubscriptionStats = { + requestResponseCount: 0, + streamingCount: 0, + unsubscribingStreamingCount: 0, + }; + + for (let [_, value] of this.records) { + if (value.type == SubscriptionType.RequestResponse) { + stats.requestResponseCount++; + } else if (value.type == SubscriptionType.EventStream) { + stats.streamingCount++; + if (value.pendingAction == SubscriptionPendingAction.Unsubscribing) { + stats.unsubscribingStreamingCount++; + } + } + } + + io.logDebug(SubscriptionManager.logSubject, `Current stats -- ${stats.requestResponseCount} request-response subscription records, ${stats.streamingCount} event stream subscription records, ${stats.unsubscribingStreamingCount} unsubscribing event stream subscriptions`); + + return stats; + } + + private unsubscribe(record: SubscriptionRecord, isShutdown: boolean) { + const currentlySubscribed = record.status == SubscriptionStatus.Subscribed; + const currentlySubscribing = record.pendingAction == SubscriptionPendingAction.Subscribing; + const currentlyUnsubscribing = record.pendingAction == SubscriptionPendingAction.Unsubscribing; + + let shouldUnsubscribe = currentlySubscribed && !currentlyUnsubscribing; + if (isShutdown) { + shouldUnsubscribe = shouldUnsubscribe || currentlySubscribing; + } + + if (!shouldUnsubscribe) { + io.logDebug(SubscriptionManager.logSubject, `subscription '${record.topicFilter}' has no listeners but is not in a state that allows unsubscribe yet`); + return; + } + + try { + this.adapter.unsubscribe({ + topicFilter: record.topicFilter, + timeoutInSeconds: this.options.operationTimeoutInSeconds + }); + } catch (err) { + io.logError(SubscriptionManager.logSubject, `synchronous unsubscribe failure for '${record.topicFilter}': ${(err as Error).toString()}`); + return; + } + + io.logDebug(SubscriptionManager.logSubject, `unsubscribe submitted for '${record.topicFilter}'`); + + record.pendingAction = SubscriptionPendingAction.Unsubscribing; + } + + private unsubscribeAll() { + for (let [_, value] of this.records) { + this.unsubscribe(value, true); + } + } + + private removeSubscriptionListener(topicFilter: string, operationId: number) { + let record = this.records.get(topicFilter); + if (!record) { + return; + } + + record.listeners.delete(operationId); + + let remainingListenerCount: number = record.listeners.size; + io.logDebug(SubscriptionManager.logSubject, `removed listener '${operationId}' from '${record.topicFilter}', ${remainingListenerCount} listeners left`); + if (remainingListenerCount > 0) { + return; + } + + setImmediate(() => { + this.emit(SubscriptionManager.SUBSCRIPTION_ORPHANED, { + topicFilter: topicFilter + }); + }); + } + + private emitEvents(record: SubscriptionRecord, eventType: SubscriptionEventType) { + for (let id of record.listeners) { + let event = { + topicFilter: record.topicFilter, + operationId: id, + }; + + io.logDebug(SubscriptionManager.logSubject, `emitting ${subscriptionEventTypeToString(eventType)} subscription event for '${record.topicFilter}' with id ${id}`); + + setImmediate(() => { + switch (eventType) { + case SubscriptionEventType.SubscribeSuccess: + this.emit(SubscriptionManager.SUBSCRIBE_SUCCESS, event); + break; + + case SubscriptionEventType.SubscribeFailure: + this.emit(SubscriptionManager.SUBSCRIBE_FAILURE, event); + break; + + case SubscriptionEventType.SubscriptionEnded: + this.emit(SubscriptionManager.SUBSCRIPTION_ENDED, event); + break; + + case SubscriptionEventType.StreamingSubscriptionEstablished: + this.emit(SubscriptionManager.STREAMING_SUBSCRIPTION_ESTABLISHED, event); + break; + + case SubscriptionEventType.StreamingSubscriptionLost: + this.emit(SubscriptionManager.STREAMING_SUBSCRIPTION_LOST, event); + break; + + case SubscriptionEventType.StreamingSubscriptionHalted: + this.emit(SubscriptionManager.STREAMING_SUBSCRIPTION_HALTED, event); + break; + + default: + break; + } + }); + } + } + + // this method re-throws dependent errors + private activateSubscription(record: SubscriptionRecord) { + if (record.poisoned) { + return; + } + + if (this.adapter.getConnectionState() != protocol_adapter.ConnectionState.Connected || record.listeners.size == 0) { + return; + } + + if (record.status != SubscriptionStatus.NotSubscribed || record.pendingAction != SubscriptionPendingAction.Nothing) { + return; + } + + try { + this.adapter.subscribe({ + topicFilter: record.topicFilter, + timeoutInSeconds: this.options.operationTimeoutInSeconds + }); + + io.logDebug(SubscriptionManager.logSubject, `initiated subscribe operation for '${record.topicFilter}'`); + + record.pendingAction = SubscriptionPendingAction.Subscribing; + } catch (err) { + io.logError(SubscriptionManager.logSubject, `synchronous failure subscribing to '${record.topicFilter}': ${(err as Error).toString()}`); + + if (record.type == SubscriptionType.RequestResponse) { + this.emitEvents(record, SubscriptionEventType.SubscribeFailure); + } else { + record.poisoned = true; + this.emitEvents(record, SubscriptionEventType.StreamingSubscriptionHalted); + } + + throw err; + } + } + + private handleRequestSubscribeCompletionEvent(record: SubscriptionRecord, event: protocol_adapter.SubscribeCompletionEvent) { + record.pendingAction = SubscriptionPendingAction.Nothing; + if (!event.err) { + record.status = SubscriptionStatus.Subscribed; + this.emitEvents(record, SubscriptionEventType.SubscribeSuccess); + } else { + this.emitEvents(record, SubscriptionEventType.SubscribeFailure); + } + } + + private handleStreamingSubscribeCompletionEvent(record: SubscriptionRecord, event: protocol_adapter.SubscribeCompletionEvent) { + record.pendingAction = SubscriptionPendingAction.Nothing; + if (!event.err) { + record.status = SubscriptionStatus.Subscribed; + this.emitEvents(record, SubscriptionEventType.StreamingSubscriptionEstablished); + } else { + if (event.retryable && !this.closed) { + this.activateSubscription(record); + } else { + record.poisoned = true; + this.emitEvents(record, SubscriptionEventType.StreamingSubscriptionHalted); + } + } + } + + private handleSubscribeCompletionEvent(event: protocol_adapter.SubscribeCompletionEvent) { + io.logDebug(SubscriptionManager.logSubject, ` received a protocol adapter subscribe completion event: ${JSON.stringify(event)}`); + + let record = this.records.get(event.topicFilter); + if (!record) { + return; + } + + if (record.pendingAction != SubscriptionPendingAction.Subscribing) { + return; + } + + if (record.type == SubscriptionType.RequestResponse) { + this.handleRequestSubscribeCompletionEvent(record, event); + } else { + this.handleStreamingSubscribeCompletionEvent(record, event); + } + } + + private handleUnsubscribeCompletionEvent(event: protocol_adapter.UnsubscribeCompletionEvent) { + io.logDebug(SubscriptionManager.logSubject, ` received a protocol adapter unsubscribe completion event: ${JSON.stringify(event)}`); + + let record = this.records.get(event.topicFilter); + if (!record) { + return; + } + + if (record.pendingAction != SubscriptionPendingAction.Unsubscribing) { + return; + } + + record.pendingAction = SubscriptionPendingAction.Nothing; + if (!event.err) { + record.status = SubscriptionStatus.NotSubscribed; + let topicFilter = record.topicFilter; + + setImmediate(() => { + this.emit(SubscriptionManager.UNSUBSCRIBE_COMPLETE, { + topicFilter: topicFilter + }); + }); + } + } + + private handleSessionLost() { + let toRemove = new Array(); + for (let [_, record] of this.records) { + if (record.status != SubscriptionStatus.Subscribed) { + continue; + } + + record.status = SubscriptionStatus.NotSubscribed; + if (record.type == SubscriptionType.RequestResponse) { + this.emitEvents(record, SubscriptionEventType.SubscriptionEnded); + if (record.pendingAction != SubscriptionPendingAction.Unsubscribing) { + toRemove.push(record.topicFilter); + } + } else { + this.emitEvents(record, SubscriptionEventType.StreamingSubscriptionLost); + } + } + + for (let topicFilter in toRemove) { + this.records.delete(topicFilter); + } + + for (let [_, record] of this.records) { + if (record.type == SubscriptionType.EventStream) { + this.activateSubscription(record); + } + } + } + + private activateIdleSubscriptions() { + for (let [_, record] of this.records) { + this.activateSubscription(record); + } + } + + private handleConnectionStatusEvent(event: protocol_adapter.ConnectionStatusEvent) { + io.logDebug(SubscriptionManager.logSubject, ` received a protocol adapter connection status event: ${JSON.stringify(event)}`); + + if (event.status != protocol_adapter.ConnectionState.Connected) { + return; + } + + if (!event.joinedSession) { + this.handleSessionLost(); + } + + this.purge(); + this.activateIdleSubscriptions(); + } + +} + diff --git a/lib/browser/mqtt_request_response_impl.spec.ts b/lib/browser/mqtt_request_response_impl.spec.ts new file mode 100644 index 000000000..98fa02aa2 --- /dev/null +++ b/lib/browser/mqtt_request_response_impl.spec.ts @@ -0,0 +1,1643 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +import * as protocol_adapter_mock from "./mqtt_request_response/protocol_adapter_mock"; +import * as mqtt_request_response from "./mqtt_request_response"; +import * as protocol_adapter from "./mqtt_request_response/protocol_adapter"; +import { CrtError } from "./error"; +import {MockProtocolAdapter} from "./mqtt_request_response/protocol_adapter_mock"; +import {once} from "events"; +import {LiftedPromise, newLiftedPromise} from "../common/promise"; +import {SubscriptionStatusEventType} from "./mqtt_request_response"; +import {v4 as uuid} from "uuid"; + +jest.setTimeout(10000); + +interface TestContextOptions { + clientOptions?: mqtt_request_response.RequestResponseClientOptions, + adapterOptions?: protocol_adapter_mock.MockProtocolAdapterOptions +} + +interface TestContext { + client : mqtt_request_response.RequestResponseClient, + adapter: protocol_adapter_mock.MockProtocolAdapter +} + +function createTestContext(options? : TestContextOptions) : TestContext { + let adapter = new protocol_adapter_mock.MockProtocolAdapter(options?.adapterOptions); + + var clientOptions : mqtt_request_response.RequestResponseClientOptions = options?.clientOptions ?? { + maxRequestResponseSubscriptions: 4, + maxStreamingSubscriptions: 2, + operationTimeoutInSeconds: 600, + }; + + // @ts-ignore + let client = new mqtt_request_response.RequestResponseClient(adapter, clientOptions); + + return { + client: client, + adapter: adapter + }; +} + +function cleanupTestContext(context: TestContext) { + context.client.close(); +} + +test('create/destroy', async () => { + let context = createTestContext(); + cleanupTestContext(context); +}); + +async function doRequestResponseValidationFailureTest(request: mqtt_request_response.RequestResponseOperationOptions, errorSubstring: string) { + let context = createTestContext(); + + context.adapter.connect(); + + try { + await context.client.submitRequest(request); + expect(false); + } catch (err: any) { + expect(err.message).toContain(errorSubstring); + } + + cleanupTestContext(context); +} + +const DEFAULT_ACCEPTED_PATH = "a/b/accepted"; +const DEFAULT_REJECTED_PATH = "a/b/rejected"; +const DEFAULT_CORRELATION_TOKEN_PATH = "token"; +const DEFAULT_CORRELATION_TOKEN = "abcd"; + +function makeGoodRequest() : mqtt_request_response.RequestResponseOperationOptions { + var encoder = new TextEncoder(); + + return { + subscriptionTopicFilters : new Array("a/b/+"), + responsePaths: new Array({ + topic: DEFAULT_ACCEPTED_PATH, + correlationTokenJsonPath: DEFAULT_CORRELATION_TOKEN_PATH + }, { + topic: DEFAULT_REJECTED_PATH, + correlationTokenJsonPath: DEFAULT_CORRELATION_TOKEN_PATH + }), + publishTopic: "a/b/derp", + payload: encoder.encode(JSON.stringify({ + token: DEFAULT_CORRELATION_TOKEN + })), + correlationToken: DEFAULT_CORRELATION_TOKEN + }; +} + +test('request-response validation failure - null options', async () => { + // @ts-ignore + let requestOptions : mqtt_request_response.RequestResponseOperationOptions = null; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - null response paths', async () => { + let requestOptions = makeGoodRequest(); + + // @ts-ignore + requestOptions.responsePaths = null; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - no response paths', async () => { + let requestOptions = makeGoodRequest(); + + requestOptions.responsePaths = new Array(); + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - null response topic', async () => { + let requestOptions = makeGoodRequest(); + + // @ts-ignore + requestOptions.responsePaths[0].topic = null; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - response topic bad type', async () => { + let requestOptions = makeGoodRequest(); + + // @ts-ignore + requestOptions.responsePaths[0].topic = 5; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - empty response topic', async () => { + let requestOptions = makeGoodRequest(); + + requestOptions.responsePaths[0].topic = ""; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - invalid response topic', async () => { + let requestOptions = makeGoodRequest(); + + requestOptions.responsePaths[0].topic = "a/#/b"; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - correlation token path bad type', async () => { + let requestOptions = makeGoodRequest(); + + // @ts-ignore + requestOptions.responsePaths[0].correlationTokenJsonPath = 5; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - null publish topic', async () => { + let requestOptions = makeGoodRequest(); + + // @ts-ignore + requestOptions.publishTopic = null; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - publish topic bad type', async () => { + let requestOptions = makeGoodRequest(); + + // @ts-ignore + requestOptions.publishTopic = 5; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - empty publish topic', async () => { + let requestOptions = makeGoodRequest(); + + requestOptions.publishTopic = ""; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - invalid publish topic', async () => { + let requestOptions = makeGoodRequest(); + + requestOptions.publishTopic = "a/+"; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - null subscription topic filters', async () => { + let requestOptions = makeGoodRequest(); + + // @ts-ignore + requestOptions.subscriptionTopicFilters = null; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - no subscription topic filters', async () => { + let requestOptions = makeGoodRequest(); + + requestOptions.subscriptionTopicFilters = new Array(); + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - null subscription topic filter', async () => { + let requestOptions = makeGoodRequest(); + + // @ts-ignore + requestOptions.subscriptionTopicFilters[0] = null; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - subscription topic filter bad type', async () => { + let requestOptions = makeGoodRequest(); + + // @ts-ignore + requestOptions.subscriptionTopicFilters[0] = 5; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - empty subscription topic filter', async () => { + let requestOptions = makeGoodRequest(); + + requestOptions.subscriptionTopicFilters[0] = ""; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - invalid subscription topic filter', async () => { + let requestOptions = makeGoodRequest(); + + requestOptions.subscriptionTopicFilters[0] = "#/a/b"; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - null payload', async () => { + let requestOptions = makeGoodRequest(); + + // @ts-ignore + requestOptions.payload = null; + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response validation failure - empty payload', async () => { + let requestOptions = makeGoodRequest(); + + let encoder = new TextEncoder(); + requestOptions.payload = encoder.encode(""); + + await doRequestResponseValidationFailureTest(requestOptions, "Invalid request options"); +}); + +test('request-response failure - interrupted by close', async () => { + let context = createTestContext(); + + context.adapter.connect(); + + let responsePromise = context.client.submitRequest(makeGoodRequest()); + + context.client.close(); + + try { + await responsePromise; + expect(false); + } catch (err: any) { + expect(err.message).toContain("client closed"); + } + + cleanupTestContext(context); +}); + +test('request-response failure - client closed', async () => { + let context = createTestContext(); + + context.adapter.connect(); + context.client.close(); + + try { + await context.client.submitRequest(makeGoodRequest()); + expect(false); + } catch (err: any) { + expect(err.message).toContain("already been closed"); + } + + cleanupTestContext(context); +}); + +test('request-response failure - timeout', async () => { + let clientOptions = { + maxRequestResponseSubscriptions: 4, + maxStreamingSubscriptions: 2, + operationTimeoutInSeconds: 2 + }; + + let context = createTestContext({ + clientOptions: clientOptions + }); + + context.adapter.connect(); + + try { + await context.client.submitRequest(makeGoodRequest()); + expect(false); + } catch (err: any) { + expect(err.message).toContain("timeout"); + } + + cleanupTestContext(context); +}); + +function mockSubscribeSuccessHandler(adapter: protocol_adapter_mock.MockProtocolAdapter, subscribeOptions: protocol_adapter.SubscribeOptions, context?: any) { + setImmediate(() => { adapter.completeSubscribe(subscribeOptions.topicFilter); }); +} + +function mockUnsubscribeSuccessHandler(adapter: protocol_adapter_mock.MockProtocolAdapter, unsubscribeOptions: protocol_adapter.UnsubscribeOptions, context?: any) { + setImmediate(() => { adapter.completeUnsubscribe(unsubscribeOptions.topicFilter); }); +} + +interface PublishHandlerContext { + responseTopic: string, + responsePayload: any +} + +function mockPublishSuccessHandler(adapter: protocol_adapter_mock.MockProtocolAdapter, publishOptions: protocol_adapter.PublishOptions, context?: any) { + let publishHandlerContext = context as PublishHandlerContext; + setImmediate(() => { + adapter.completePublish(publishOptions.completionData); + + let decoder = new TextDecoder(); + let payloadAsString = decoder.decode(publishOptions.payload); + let payloadAsObject: any = JSON.parse(payloadAsString); + + publishHandlerContext.responsePayload[DEFAULT_CORRELATION_TOKEN_PATH] = payloadAsObject[DEFAULT_CORRELATION_TOKEN_PATH]; + + let encoder = new TextEncoder(); + let responsePayloadAsString = JSON.stringify(publishHandlerContext.responsePayload); + adapter.triggerIncomingPublish(publishHandlerContext.responseTopic, encoder.encode(responsePayloadAsString)); + }); +} + +async function do_request_response_single_success_test(responsePath: string, multiSubscribe: boolean) { + let publishHandlerContext : PublishHandlerContext = { + responseTopic: responsePath, + responsePayload: {} + } + + let adapterOptions : protocol_adapter_mock.MockProtocolAdapterOptions = { + subscribeHandler: mockSubscribeSuccessHandler, + unsubscribeHandler: mockUnsubscribeSuccessHandler, + publishHandler: mockPublishSuccessHandler, + publishHandlerContext: publishHandlerContext + }; + + let context = createTestContext({ + adapterOptions: adapterOptions, + }); + + context.adapter.connect(); + + let request = makeGoodRequest(); + if (multiSubscribe) { + request.subscriptionTopicFilters = new Array(DEFAULT_ACCEPTED_PATH, DEFAULT_REJECTED_PATH); + } + + let responsePromise = context.client.submitRequest(request); + let response = await responsePromise; + + expect(response.topic).toEqual(responsePath); + + let decoder = new TextDecoder(); + expect(decoder.decode(response.payload)).toEqual(JSON.stringify({token:DEFAULT_CORRELATION_TOKEN})); + + cleanupTestContext(context); +} + +test('request-response success - accepted response path', async () => { + await do_request_response_single_success_test(DEFAULT_ACCEPTED_PATH, false); +}); + +test('request-response success - multi-sub accepted response path', async () => { + await do_request_response_single_success_test(DEFAULT_ACCEPTED_PATH, true); +}); + +test('request-response success - rejected response path', async () => { + await do_request_response_single_success_test(DEFAULT_REJECTED_PATH, false); +}); + +test('request-response success - multi-sub rejected response path', async () => { + await do_request_response_single_success_test(DEFAULT_REJECTED_PATH, true); +}); + +function mockPublishSuccessHandlerNoToken(responseTopic: string, responsePayload: any, adapter: protocol_adapter_mock.MockProtocolAdapter, publishOptions: protocol_adapter.PublishOptions, context?: any) { + setImmediate(() => { + adapter.completePublish(publishOptions.completionData); + adapter.triggerIncomingPublish(responseTopic, publishOptions.payload); + }); +} + +async function do_request_response_success_empty_correlation_token(responsePath: string, count: number) { + let adapterOptions : protocol_adapter_mock.MockProtocolAdapterOptions = { + subscribeHandler: mockSubscribeSuccessHandler, + unsubscribeHandler: mockUnsubscribeSuccessHandler, + publishHandler: (adapter, publishOptions, context) => { mockPublishSuccessHandlerNoToken(responsePath, {}, adapter, publishOptions, context); }, + }; + + let context = createTestContext({ + adapterOptions: adapterOptions, + }); + + context.adapter.connect(); + + let encoder = new TextEncoder(); + + let promises = new Array>(); + for (let i = 0; i < count; i++) { + let request = makeGoodRequest(); + delete request.correlationToken; + delete request.responsePaths[0].correlationTokenJsonPath; + delete request.responsePaths[1].correlationTokenJsonPath; + + request.payload = encoder.encode(JSON.stringify({ + requestNumber: `${i}` + })); + + promises.push(context.client.submitRequest(request)); + } + + for (const [i, promise] of promises.entries()) { + let response = await promise; + + expect(response.topic).toEqual(responsePath); + + let decoder = new TextDecoder(); + expect(decoder.decode(response.payload)).toEqual(JSON.stringify({requestNumber:`${i}`})); + } + + cleanupTestContext(context); +} + +test('request-response success - accepted response path no correlation token', async () => { + await do_request_response_success_empty_correlation_token(DEFAULT_ACCEPTED_PATH, 1); +}); + +test('request-response success - accepted response path no correlation token sequence', async () => { + await do_request_response_success_empty_correlation_token(DEFAULT_ACCEPTED_PATH, 5); +}); + +test('request-response success - rejected response path no correlation token', async () => { + await do_request_response_success_empty_correlation_token(DEFAULT_REJECTED_PATH, 1); +}); + +test('request-response success - rejected response path no correlation token sequence', async () => { + await do_request_response_success_empty_correlation_token(DEFAULT_REJECTED_PATH, 5); +}); + +interface FailingSubscribeContext { + startFailingIndex: number, + subscribesSeen: number +} + +function mockSubscribeFailureHandler(adapter: protocol_adapter_mock.MockProtocolAdapter, subscribeOptions: protocol_adapter.SubscribeOptions, context?: any) { + let subscribeContext = context as FailingSubscribeContext; + + if (subscribeContext.subscribesSeen >= subscribeContext.startFailingIndex) { + setImmediate(() => { + adapter.completeSubscribe(subscribeOptions.topicFilter, new CrtError("Nope")); + }); + } else { + setImmediate(() => { + adapter.completeSubscribe(subscribeOptions.topicFilter); + }); + } + + subscribeContext.subscribesSeen++; +} + +async function do_request_response_failure_subscribe(failSecondSubscribe: boolean) { + + let subscribeContext : FailingSubscribeContext = { + startFailingIndex : failSecondSubscribe ? 1 : 0, + subscribesSeen : 0, + }; + + let adapterOptions: protocol_adapter_mock.MockProtocolAdapterOptions = { + subscribeHandler: mockSubscribeFailureHandler, + subscribeHandlerContext: subscribeContext, + unsubscribeHandler: mockUnsubscribeSuccessHandler, + }; + + let context = createTestContext({ + adapterOptions: adapterOptions, + }); + + context.adapter.connect(); + + let request = makeGoodRequest(); + if (failSecondSubscribe) { + request.subscriptionTopicFilters = new Array(DEFAULT_ACCEPTED_PATH, DEFAULT_REJECTED_PATH); + } + + try { + await context.client.submitRequest(request); + expect(false); + } catch (e) { + let err = e as Error; + expect(err.message).toContain("Subscribe failure"); + } + + cleanupTestContext(context); +} + + +test('request-response failure - subscribe failure', async () => { + await do_request_response_failure_subscribe(false); +}); + +test('request-response failure - second subscribe failure', async () => { + await do_request_response_failure_subscribe(true); +}); + +function mockPublishFailureHandlerAck(adapter: protocol_adapter_mock.MockProtocolAdapter, publishOptions: protocol_adapter.PublishOptions, context?: any) { + setImmediate(() => { + adapter.completePublish(publishOptions.completionData, new CrtError("Publish failure - No can do")); + }); +} + +test('request-response failure - publish failure', async () => { + let adapterOptions: protocol_adapter_mock.MockProtocolAdapterOptions = { + subscribeHandler: mockSubscribeSuccessHandler, + unsubscribeHandler: mockUnsubscribeSuccessHandler, + publishHandler: mockPublishFailureHandlerAck, + }; + + let context = createTestContext({ + adapterOptions: adapterOptions, + }); + + context.adapter.connect(); + + let request = makeGoodRequest(); + + try { + await context.client.submitRequest(request); + expect(false); + } catch (e) { + let err = e as Error; + expect(err.message).toContain("Publish failure"); + } + + cleanupTestContext(context); +}); + +async function doRequestResponseFailureByTimeoutDueToResponseTest(publishHandler: (adapter: MockProtocolAdapter, publishOptions: protocol_adapter.PublishOptions, context?: any) => void) { + let publishHandlerContext : PublishHandlerContext = { + responseTopic: DEFAULT_ACCEPTED_PATH, + responsePayload: {} + } + + let adapterOptions: protocol_adapter_mock.MockProtocolAdapterOptions = { + subscribeHandler: mockSubscribeSuccessHandler, + unsubscribeHandler: mockUnsubscribeSuccessHandler, + publishHandler: publishHandler, + publishHandlerContext: publishHandlerContext + }; + + let context = createTestContext({ + adapterOptions: adapterOptions, + clientOptions: { + maxRequestResponseSubscriptions: 4, + maxStreamingSubscriptions: 2, + operationTimeoutInSeconds: 2, // need a quick timeout + } + }); + + context.adapter.connect(); + + let request = makeGoodRequest(); + + try { + await context.client.submitRequest(request); + expect(false); + } catch (e) { + let err = e as Error; + expect(err.message).toContain("timeout"); + } + + cleanupTestContext(context); +} + +function mockPublishFailureHandlerInvalidResponse(adapter: protocol_adapter_mock.MockProtocolAdapter, publishOptions: protocol_adapter.PublishOptions, context?: any) { + let publishHandlerContext = context as PublishHandlerContext; + setImmediate(() => { + adapter.completePublish(publishOptions.completionData); + + let decoder = new TextDecoder(); + let payloadAsString = decoder.decode(publishOptions.payload); + let payloadAsObject: any = JSON.parse(payloadAsString); + + publishHandlerContext.responsePayload[DEFAULT_CORRELATION_TOKEN_PATH] = payloadAsObject[DEFAULT_CORRELATION_TOKEN_PATH]; + + let encoder = new TextEncoder(); + let responsePayloadAsString = JSON.stringify(publishHandlerContext.responsePayload); + // drop the closing bracket to create a JSON deserialization error + adapter.triggerIncomingPublish(publishHandlerContext.responseTopic, encoder.encode(responsePayloadAsString.slice(0, responsePayloadAsString.length - 1))); + }); +} + +test('request-response failure - invalid response payload', async () => { + await doRequestResponseFailureByTimeoutDueToResponseTest(mockPublishFailureHandlerInvalidResponse); +}); + +function mockPublishFailureHandlerMissingCorrelationToken(adapter: protocol_adapter_mock.MockProtocolAdapter, publishOptions: protocol_adapter.PublishOptions, context?: any) { + let publishHandlerContext = context as PublishHandlerContext; + setImmediate(() => { + adapter.completePublish(publishOptions.completionData); + + let encoder = new TextEncoder(); + let responsePayloadAsString = JSON.stringify(publishHandlerContext.responsePayload); + adapter.triggerIncomingPublish(publishHandlerContext.responseTopic, encoder.encode(responsePayloadAsString)); + }); +} + +test('request-response failure - missing correlation token', async () => { + await doRequestResponseFailureByTimeoutDueToResponseTest(mockPublishFailureHandlerMissingCorrelationToken); +}); + +function mockPublishFailureHandlerInvalidCorrelationTokenType(adapter: protocol_adapter_mock.MockProtocolAdapter, publishOptions: protocol_adapter.PublishOptions, context?: any) { + let publishHandlerContext = context as PublishHandlerContext; + setImmediate(() => { + adapter.completePublish(publishOptions.completionData); + + let decoder = new TextDecoder(); + let payloadAsString = decoder.decode(publishOptions.payload); + let payloadAsObject: any = JSON.parse(payloadAsString); + let tokenAsString = payloadAsObject[DEFAULT_CORRELATION_TOKEN_PATH] as string; + publishHandlerContext.responsePayload[DEFAULT_CORRELATION_TOKEN_PATH] = parseInt(tokenAsString, 10); + + let encoder = new TextEncoder(); + let responsePayloadAsString = JSON.stringify(publishHandlerContext.responsePayload); + adapter.triggerIncomingPublish(publishHandlerContext.responseTopic, encoder.encode(responsePayloadAsString)); + }); +} + +test('request-response failure - invalid correlation token type', async () => { + await doRequestResponseFailureByTimeoutDueToResponseTest(mockPublishFailureHandlerInvalidCorrelationTokenType); +}); + +function mockPublishFailureHandlerNonMatchingCorrelationToken(adapter: protocol_adapter_mock.MockProtocolAdapter, publishOptions: protocol_adapter.PublishOptions, context?: any) { + let publishHandlerContext = context as PublishHandlerContext; + setImmediate(() => { + adapter.completePublish(publishOptions.completionData); + + let decoder = new TextDecoder(); + let payloadAsString = decoder.decode(publishOptions.payload); + let payloadAsObject: any = JSON.parse(payloadAsString); + let token = payloadAsObject[DEFAULT_CORRELATION_TOKEN_PATH] as string; + publishHandlerContext.responsePayload[DEFAULT_CORRELATION_TOKEN_PATH] = token.substring(1); // skip the first character + + let encoder = new TextEncoder(); + let responsePayloadAsString = JSON.stringify(publishHandlerContext.responsePayload); + adapter.triggerIncomingPublish(publishHandlerContext.responseTopic, encoder.encode(responsePayloadAsString)); + }); +} + +test('request-response failure - non-matching correlation token', async () => { + await doRequestResponseFailureByTimeoutDueToResponseTest(mockPublishFailureHandlerNonMatchingCorrelationToken); +}); + +interface TestOperationDefinition { + topicPrefix: string, + uniqueRequestPayload: string, + correlationToken?: string, +} + +interface RequestSequenceContext { + responseMap: Map +} + +function makeTestRequest(definition: TestOperationDefinition): mqtt_request_response.RequestResponseOperationOptions { + let encoder = new TextEncoder(); + + let baseResponseAsObject : any = {}; + baseResponseAsObject["requestPayload"] = definition.uniqueRequestPayload; + if (definition.correlationToken) { + baseResponseAsObject[DEFAULT_CORRELATION_TOKEN_PATH] = definition.correlationToken; + } + + let options : mqtt_request_response.RequestResponseOperationOptions = { + subscriptionTopicFilters : new Array(`${definition.topicPrefix}/+`), + responsePaths: new Array({ + topic: `${definition.topicPrefix}/accepted` + }, { + topic: `${definition.topicPrefix}/rejected` + }), + publishTopic: `${definition.topicPrefix}/operation`, + payload: encoder.encode(JSON.stringify(baseResponseAsObject)), + }; + + if (definition.correlationToken) { + options.responsePaths[0].correlationTokenJsonPath = DEFAULT_CORRELATION_TOKEN_PATH; + options.responsePaths[1].correlationTokenJsonPath = DEFAULT_CORRELATION_TOKEN_PATH; + options.correlationToken = definition.correlationToken; + } + + return options; +} + +function mockPublishSuccessHandlerSequence(adapter: protocol_adapter_mock.MockProtocolAdapter, publishOptions: protocol_adapter.PublishOptions, context?: any) { + let publishHandlerContext = context as RequestSequenceContext; + setImmediate(() => { + adapter.completePublish(publishOptions.completionData); + + let decoder = new TextDecoder(); + let payloadAsString = decoder.decode(publishOptions.payload); + + let payloadAsObject: any = JSON.parse(payloadAsString); + let token : string | undefined = payloadAsObject[DEFAULT_CORRELATION_TOKEN_PATH]; + + let uniquenessValue = payloadAsObject["requestPayload"] as string; + let definition = publishHandlerContext.responseMap.get(uniquenessValue); + if (!definition) { + return; + } + + let responsePayload : any = { + requestPayload: uniquenessValue + }; + if (token) { + responsePayload[DEFAULT_CORRELATION_TOKEN_PATH] = token; // skip the first character + } + + let encoder = new TextEncoder(); + let responsePayloadAsString = JSON.stringify(responsePayload); + adapter.triggerIncomingPublish(`${definition.topicPrefix}/accepted`, encoder.encode(responsePayloadAsString)); + }); +} + +test('request-response success - multi operation sequence', async () => { + let operations : Array = new Array( + { + topicPrefix: "test", + uniqueRequestPayload: "1", + correlationToken: "token1", + }, + { + topicPrefix: "test", + uniqueRequestPayload: "2", + correlationToken: "token2", + }, + { + topicPrefix: "test2", + uniqueRequestPayload: "3", + correlationToken: "token3", + }, + { + topicPrefix: "interrupting/cow", + uniqueRequestPayload: "4", + correlationToken: "moo", + }, + { + topicPrefix: "test", + uniqueRequestPayload: "5", + correlationToken: "token4", + }, + { + topicPrefix: "test2", + uniqueRequestPayload: "6", + correlationToken: "token5", + }, + { + topicPrefix: "provision", + uniqueRequestPayload: "7", + }, + { + topicPrefix: "provision", + uniqueRequestPayload: "8", + }, + { + topicPrefix: "create-keys-and-cert", + uniqueRequestPayload: "9", + }, + { + topicPrefix: "test", + uniqueRequestPayload: "10", + correlationToken: "token6", + }, + { + topicPrefix: "test2", + uniqueRequestPayload: "11", + correlationToken: "token7", + }, + { + topicPrefix: "provision", + uniqueRequestPayload: "12", + }, + ); + + let responseMap = operations.reduce(function(map, def) { + map.set(def.uniqueRequestPayload, def); + return map; + }, new Map()); + + let publishHandlerContext : RequestSequenceContext = { + responseMap: responseMap + } + + let adapterOptions: protocol_adapter_mock.MockProtocolAdapterOptions = { + subscribeHandler: mockSubscribeSuccessHandler, + unsubscribeHandler: mockUnsubscribeSuccessHandler, + publishHandler: mockPublishSuccessHandlerSequence, + publishHandlerContext: publishHandlerContext + }; + + let context = createTestContext({ + adapterOptions: adapterOptions + }); + + context.adapter.connect(); + + let promises = new Array>(); + for (let operation of operations) { + let request = makeTestRequest(operation); + promises.push(context.client.submitRequest(request)); + } + + for (const [i, promise] of promises.entries()) { + let definition = operations[i]; + let response = await promise; + + expect(response.topic).toEqual(`${definition.topicPrefix}/accepted`); + + let decoder = new TextDecoder(); + let payloadAsString = decoder.decode(response.payload); + let payloadAsObject = JSON.parse(payloadAsString); + let originalRequestPayload = payloadAsObject["requestPayload"] as string; + + expect(definition.uniqueRequestPayload).toEqual(originalRequestPayload); + } + + cleanupTestContext(context); +}); + +test('streaming operation validation failure - null options', async () => { + let context = createTestContext(); + + try { + // @ts-ignore + let operation = context.client.createStream(null); + operation.close(); + expect(false); + } catch (e) { + let err = e as Error; + expect(err.message).toContain("Invalid streaming options"); + } + + cleanupTestContext(context); +}); + +test('streaming operation validation failure - subscription topic filter null', async () => { + let context = createTestContext(); + + try { + let operation = context.client.createStream({ + // @ts-ignore + subscriptionTopicFilter: null + }); + operation.close(); + expect(false); + } catch (e) { + let err = e as Error; + expect(err.message).toContain("Invalid streaming options"); + } + + cleanupTestContext(context); +}); + +test('streaming operation validation failure - subscription topic filter wrong type', async () => { + let context = createTestContext(); + + try { + let operation = context.client.createStream({ + // @ts-ignore + subscriptionTopicFilter: 5 + }); + operation.close(); + expect(false); + } catch (e) { + let err = e as Error; + expect(err.message).toContain("Invalid streaming options"); + } + + cleanupTestContext(context); +}); + +test('streaming operation validation failure - subscription topic filter invalid', async () => { + let context = createTestContext(); + + try { + let operation = context.client.createStream({ + subscriptionTopicFilter: "" + }); + operation.close(); + expect(false); + } catch (e) { + let err = e as Error; + expect(err.message).toContain("Invalid streaming options"); + } + + cleanupTestContext(context); +}); + +test('streaming operation create failure - client closed', async () => { + let context = createTestContext(); + + context.client.close(); + + try { + let operation = context.client.createStream({ + subscriptionTopicFilter: "" + }); + operation.close(); + expect(false); + } catch (e) { + let err = e as Error; + expect(err.message).toContain("already been closed"); + } + + cleanupTestContext(context); +}); + + +test('streaming operation - close client before open', async () => { + let context = createTestContext(); + + + let operation = context.client.createStream({ + subscriptionTopicFilter: "a/b" + }); + + context.client.close(); + + try { + operation.open(); + expect(false); + } catch (e) { + let err = e as Error; + expect(err.message).toContain("already closed"); + } + + cleanupTestContext(context); +}); + +test('streaming operation - close client after open', async () => { + let context = createTestContext({ + adapterOptions: { + subscribeHandler: mockSubscribeSuccessHandler, + unsubscribeHandler: mockUnsubscribeSuccessHandler + }, + clientOptions: { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions: 2, + } + }); + + context.adapter.connect(); + + let operation = context.client.createStream({ + subscriptionTopicFilter: "a/b" + }); + + let subscriptionStatusPromise1 = once(operation, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + operation.open(); + + let subscriptionStatus1 : mqtt_request_response.SubscriptionStatusEvent = (await subscriptionStatusPromise1)[0]; + expect(subscriptionStatus1.type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionEstablished); + expect(subscriptionStatus1.error).toBeFalsy(); + + let subscriptionStatusPromise2 = once(operation, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + context.client.close(); + + let subscriptionStatus2 : mqtt_request_response.SubscriptionStatusEvent = (await subscriptionStatusPromise2)[0]; + expect(subscriptionStatus2.type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionHalted); + expect(subscriptionStatus2.error).toBeTruthy(); + + let error : CrtError = subscriptionStatus2.error as CrtError; + expect(error.message).toContain("client closed"); + + cleanupTestContext(context); +}); + +test('streaming operation - success single', async () => { + let context = createTestContext({ + adapterOptions: { + subscribeHandler: mockSubscribeSuccessHandler, + unsubscribeHandler: mockUnsubscribeSuccessHandler + }, + clientOptions: { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions: 2, + } + }); + + context.adapter.connect(); + + let operation = context.client.createStream({ + subscriptionTopicFilter: "a/b" + }); + + let subscriptionStatusPromise1 = once(operation, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + operation.open(); + + let subscriptionStatus1 : mqtt_request_response.SubscriptionStatusEvent = (await subscriptionStatusPromise1)[0]; + expect(subscriptionStatus1.type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionEstablished); + expect(subscriptionStatus1.error).toBeFalsy(); + + let allReceived : LiftedPromise = newLiftedPromise(); + let incomingPublishes : mqtt_request_response.IncomingPublishEvent[] = new Array(); + operation.addListener(mqtt_request_response.StreamingOperationBase.INCOMING_PUBLISH, (event) => { + incomingPublishes.push(event); + allReceived.resolve(); + }); + + let payload : Buffer = Buffer.from("IncomingPublish", "utf-8"); + context.adapter.triggerIncomingPublish("a/b", payload); + await allReceived.promise; + + expect(incomingPublishes.length).toEqual(1); + + let incomingPublish1 = incomingPublishes[0]; + expect(Buffer.from(incomingPublish1.payload as ArrayBuffer)).toEqual(payload); + + cleanupTestContext(context); +}); + +test('streaming operation - success overlapping', async () => { + let context = createTestContext({ + adapterOptions: { + subscribeHandler: mockSubscribeSuccessHandler, + unsubscribeHandler: mockUnsubscribeSuccessHandler + }, + clientOptions: { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions: 2, + } + }); + + context.adapter.connect(); + + let streamOptions : mqtt_request_response.StreamingOperationOptions = { + subscriptionTopicFilter: "a/b" + }; + + let operation1 = context.client.createStream(streamOptions); + let subscriptionStatusPromise1 = once(operation1, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + let operation2 = context.client.createStream(streamOptions); + let subscriptionStatusPromise2 = once(operation2, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + operation1.open(); + operation2.open(); + + let subscriptionStatus1 : mqtt_request_response.SubscriptionStatusEvent = (await subscriptionStatusPromise1)[0]; + expect(subscriptionStatus1.type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionEstablished); + expect(subscriptionStatus1.error).toBeFalsy(); + + let subscriptionStatus2 : mqtt_request_response.SubscriptionStatusEvent = (await subscriptionStatusPromise2)[0]; + expect(subscriptionStatus2.type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionEstablished); + expect(subscriptionStatus2.error).toBeFalsy(); + + // operation 1 should receive both publishes + let allReceived1 : LiftedPromise = newLiftedPromise(); + let incomingPublishes1 : mqtt_request_response.IncomingPublishEvent[] = new Array(); + operation1.addListener(mqtt_request_response.StreamingOperationBase.INCOMING_PUBLISH, (event) => { + incomingPublishes1.push(event); + if (incomingPublishes1.length == 2) { + allReceived1.resolve(); + } + }); + + // operation 2 should only receive one publish because we close it before triggering the second one + let allReceived2 : LiftedPromise = newLiftedPromise(); + let incomingPublishes2 : mqtt_request_response.IncomingPublishEvent[] = new Array(); + operation2.addListener(mqtt_request_response.StreamingOperationBase.INCOMING_PUBLISH, (event) => { + incomingPublishes2.push(event); + allReceived2.resolve(); + }); + + let payload1 : Buffer = Buffer.from("IncomingPublish1", "utf-8"); + context.adapter.triggerIncomingPublish("a/b", payload1); + + await allReceived2.promise; + + expect(incomingPublishes2.length).toEqual(1); + expect(Buffer.from(incomingPublishes2[0].payload as ArrayBuffer)).toEqual(payload1); + + let subscriptionStatus2HaltedPromise = once(operation2, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + operation2.close(); + + let subscriptionStatus2Halted : mqtt_request_response.SubscriptionStatusEvent = (await subscriptionStatus2HaltedPromise)[0]; + expect(subscriptionStatus2Halted.type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionHalted); + expect(subscriptionStatus2Halted.error).toBeTruthy(); + + let payload2 : Buffer = Buffer.from("IncomingPublish2", "utf-8"); + context.adapter.triggerIncomingPublish("a/b", payload2); + + await allReceived1.promise; + + expect(incomingPublishes1.length).toEqual(2); + expect(Buffer.from(incomingPublishes1[0].payload as ArrayBuffer)).toEqual(payload1); + expect(Buffer.from(incomingPublishes1[1].payload as ArrayBuffer)).toEqual(payload2); + + cleanupTestContext(context); + + // nothing arrived in the meantime + expect(incomingPublishes2.length).toEqual(1); +}); + +test('streaming operation - success single starting offline', async () => { + let context = createTestContext({ + adapterOptions: { + subscribeHandler: mockSubscribeSuccessHandler, + unsubscribeHandler: mockUnsubscribeSuccessHandler + }, + clientOptions: { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions: 2, + } + }); + + let operation = context.client.createStream({ + subscriptionTopicFilter: "a/b" + }); + + let subscriptionEstablished : mqtt_request_response.SubscriptionStatusEvent | undefined = undefined; + + let subscriptionEstablishedPromise : LiftedPromise = newLiftedPromise(); + operation.addListener(mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS, (event) => { + if (event.type == SubscriptionStatusEventType.SubscriptionEstablished) { + subscriptionEstablished = event; + subscriptionEstablishedPromise.resolve(); + } + }); + + operation.open(); + + // wait a second, nothing should happen + await new Promise((resolve) => setTimeout(resolve, 1000)); + expect(subscriptionEstablished).toBeFalsy(); + + // connecting should kick off the subscribe and successful establishment + context.adapter.connect(); + + await subscriptionEstablishedPromise.promise; + expect(subscriptionEstablished).toBeTruthy(); + // @ts-ignore + expect(subscriptionEstablished.type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionEstablished); + // @ts-ignore + expect(subscriptionEstablished.error).toBeFalsy(); + + let allReceived : LiftedPromise = newLiftedPromise(); + let incomingPublishes : mqtt_request_response.IncomingPublishEvent[] = new Array(); + operation.addListener(mqtt_request_response.StreamingOperationBase.INCOMING_PUBLISH, (event) => { + incomingPublishes.push(event); + allReceived.resolve(); + }); + + let payload : Buffer = Buffer.from("IncomingPublish", "utf-8"); + context.adapter.triggerIncomingPublish("a/b", payload); + await allReceived.promise; + + expect(incomingPublishes.length).toEqual(1); + + let incomingPublish1 = incomingPublishes[0]; + expect(Buffer.from(incomingPublish1.payload as ArrayBuffer)).toEqual(payload); + + cleanupTestContext(context); +}); + +async function doStreamingSessionTest(resumeSession: boolean) { + let context = createTestContext({ + adapterOptions: { + subscribeHandler: mockSubscribeSuccessHandler, + unsubscribeHandler: mockUnsubscribeSuccessHandler + }, + clientOptions: { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions: 2, + } + }); + + context.adapter.connect(); + + let operation = context.client.createStream({ + subscriptionTopicFilter: "a/b" + }); + + let statusEvents : mqtt_request_response.SubscriptionStatusEvent[] = new Array(); + let established1Promise : LiftedPromise = newLiftedPromise(); + let established2Promise : LiftedPromise = newLiftedPromise(); + + operation.addListener(mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS, (event) => { + statusEvents.push(event); + if (event.type == SubscriptionStatusEventType.SubscriptionEstablished) { + if (statusEvents.length == 1) { + established1Promise.resolve(); + } else { + established2Promise.resolve(); + } + } + }); + + operation.open(); + + await established1Promise.promise; + + expect(statusEvents.length).toEqual(1); + let subscriptionStatus1 : mqtt_request_response.SubscriptionStatusEvent = statusEvents[0]; + expect(subscriptionStatus1.type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionEstablished); + expect(subscriptionStatus1.error).toBeFalsy(); + + let received1 : LiftedPromise = newLiftedPromise(); + let received2 : LiftedPromise = newLiftedPromise(); + let incomingPublishes : mqtt_request_response.IncomingPublishEvent[] = new Array(); + operation.addListener(mqtt_request_response.StreamingOperationBase.INCOMING_PUBLISH, (event) => { + incomingPublishes.push(event); + if (incomingPublishes.length == 1) { + received1.resolve(); + } else if (incomingPublishes.length == 2) { + received2.resolve(); + } + }); + + let payload1 : Buffer = Buffer.from("IncomingPublish1", "utf-8"); + context.adapter.triggerIncomingPublish("a/b", payload1); + await received1.promise; + + expect(incomingPublishes.length).toEqual(1); + + let incomingPublish1 = incomingPublishes[0]; + expect(Buffer.from(incomingPublish1.payload as ArrayBuffer)).toEqual(payload1); + + // expect to see a single subscribe on the mock protocol adapter + let apiCalls1 = context.adapter.getApiCalls(); + expect(apiCalls1.length).toEqual(1); + expect(apiCalls1[0].methodName).toEqual("subscribe"); + + // "disconnect" and "reconnect" + context.adapter.disconnect(); + context.adapter.connect(resumeSession); + + if (resumeSession) { + // wait a second, nothing should happen + await new Promise((resolve) => setTimeout(resolve, 1000)); + + expect(statusEvents.length).toEqual(1); + expect(context.adapter.getApiCalls().length).toEqual(1); + } else { + // expect subscription lost event followed by established event + await established2Promise.promise; + expect(statusEvents.length).toEqual(3); + + expect(statusEvents[1].type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionLost); + expect(statusEvents[2].type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionEstablished); + + // expect to see a second subscribe on the mock protocol adapter + let apiCalls2 = context.adapter.getApiCalls(); + expect(apiCalls2.length).toEqual(2); + expect(apiCalls1[1].methodName).toEqual("subscribe"); + } + + // trigger an incoming publish, expect it to arrive + let payload2 : Buffer = Buffer.from("IncomingPublish2", "utf-8"); + context.adapter.triggerIncomingPublish("a/b", payload2); + await received2.promise; + + expect(incomingPublishes.length).toEqual(2); + + let incomingPublish2 = incomingPublishes[1]; + expect(Buffer.from(incomingPublish2.payload as ArrayBuffer)).toEqual(payload2); + + cleanupTestContext(context); +} + +test('streaming operation - successfully reestablish subscription on clean session resumption', async () => { + await doStreamingSessionTest(false); +}); + +test('streaming operation - success with session resumption', async () => { + await doStreamingSessionTest(true); +}); + +interface FirstSubscribeContext { + count: number +} + +function mockSubscribeFailFirstHandler(adapter: protocol_adapter_mock.MockProtocolAdapter, subscribeOptions: protocol_adapter.SubscribeOptions, context?: any) { + let subscribeContext = context as FirstSubscribeContext; + subscribeContext.count++; + + if (subscribeContext.count == 1) { + setImmediate(() => { + adapter.completeSubscribe(subscribeOptions.topicFilter, new CrtError("Mock Failure"), true); + }); + } else { + setImmediate(() => { + adapter.completeSubscribe(subscribeOptions.topicFilter); + }); + } +} + +/* + * Variant of the basic success test where the first subscribe is failed. Verify the + * client sends a second subscribe (which succeeds) after which everything is fine. + */ +test('streaming operation - success despite first subscribe failure', async () => { + let subscribeContext = { + count: 0 + }; + + let context = createTestContext({ + adapterOptions: { + subscribeHandler: mockSubscribeFailFirstHandler, + subscribeHandlerContext: subscribeContext, + unsubscribeHandler: mockUnsubscribeSuccessHandler + }, + clientOptions: { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions: 2, + operationTimeoutInSeconds: 2, + } + }); + + context.adapter.connect(); + + let operation = context.client.createStream({ + subscriptionTopicFilter: "a/b" + }); + + let subscriptionStatusPromise1 = once(operation, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + operation.open(); + + let subscriptionStatus1 : mqtt_request_response.SubscriptionStatusEvent = (await subscriptionStatusPromise1)[0]; + expect(subscriptionStatus1.type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionEstablished); + expect(subscriptionStatus1.error).toBeFalsy(); + + let allReceived : LiftedPromise = newLiftedPromise(); + let incomingPublishes : mqtt_request_response.IncomingPublishEvent[] = new Array(); + operation.addListener(mqtt_request_response.StreamingOperationBase.INCOMING_PUBLISH, (event) => { + incomingPublishes.push(event); + allReceived.resolve(); + }); + + let payload : Buffer = Buffer.from("IncomingPublish", "utf-8"); + context.adapter.triggerIncomingPublish("a/b", payload); + await allReceived.promise; + + expect(incomingPublishes.length).toEqual(1); + + let incomingPublish1 = incomingPublishes[0]; + expect(Buffer.from(incomingPublish1.payload as ArrayBuffer)).toEqual(payload); + + // verify two subscribes sent + let apiCalls = context.adapter.getApiCalls(); + expect(apiCalls.length).toEqual(2); + expect(apiCalls[0].methodName).toEqual("subscribe"); + expect(apiCalls[1].methodName).toEqual("subscribe"); + + cleanupTestContext(context); +}); + +/* + * Failure variant where the subscribe triggers a non-retryable suback failure. Verify the + * operation gets halted. + */ +test('streaming operation - halt after unretryable subscribe failure', async () => { + let subscribeContext : FailingSubscribeContext = { + startFailingIndex: 0, + subscribesSeen: 0 + }; + + let context = createTestContext({ + adapterOptions: { + subscribeHandler: mockSubscribeFailureHandler, + subscribeHandlerContext: subscribeContext, + unsubscribeHandler: mockUnsubscribeSuccessHandler + }, + clientOptions: { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions: 2, + operationTimeoutInSeconds: 2, + } + }); + + context.adapter.connect(); + + let operation = context.client.createStream({ + subscriptionTopicFilter: "a/b" + }); + + let subscriptionStatusPromise1 = once(operation, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + operation.open(); + + let subscriptionStatus1 : mqtt_request_response.SubscriptionStatusEvent = (await subscriptionStatusPromise1)[0]; + expect(subscriptionStatus1.type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionHalted); + expect(subscriptionStatus1.error).toBeTruthy(); + + let error = subscriptionStatus1.error as CrtError; + expect(error.message).toContain("Subscription Failure") + + cleanupTestContext(context); +}); + +async function openOperationAndVerifyPublishes(operation: mqtt_request_response.StreamingOperationBase, testContext: TestContext, topic: string) { + let subscriptionStatusPromise1 = once(operation, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + operation.open(); + + let subscriptionStatus1 : mqtt_request_response.SubscriptionStatusEvent = (await subscriptionStatusPromise1)[0]; + expect(subscriptionStatus1.type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionEstablished); + expect(subscriptionStatus1.error).toBeFalsy(); + + let allReceived : LiftedPromise = newLiftedPromise(); + let incomingPublishes : mqtt_request_response.IncomingPublishEvent[] = new Array(); + let publishListener = (event : mqtt_request_response.IncomingPublishEvent) => { + incomingPublishes.push(event); + allReceived.resolve(); + }; + + operation.addListener(mqtt_request_response.StreamingOperationBase.INCOMING_PUBLISH, publishListener); + + let payload : Buffer = Buffer.from("IncomingPublish-" + uuid(), "utf-8"); + testContext.adapter.triggerIncomingPublish(topic, payload); + await allReceived.promise; + + expect(incomingPublishes.length).toEqual(1); + + let incomingPublish1 = incomingPublishes[0]; + expect(Buffer.from(incomingPublish1.payload as ArrayBuffer)).toEqual(payload); +} + +async function closeOperation(operation: mqtt_request_response.StreamingOperationBase) { + + let subscriptionStatusPromise = once(operation, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + operation.close(); + + let subscriptionStatus : mqtt_request_response.SubscriptionStatusEvent = (await subscriptionStatusPromise)[0]; + expect(subscriptionStatus.type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionHalted); + expect(subscriptionStatus.error).toBeTruthy(); +} + +/* + * Multi-operation variant where we exceed the streaming subscription budget, release everything and then verify + * we can successfully establish a new streaming operation after everything cleans up. + */ +test('streaming operation - failure exceed streaming budget', async () => { + let context = createTestContext({ + adapterOptions: { + subscribeHandler: mockSubscribeSuccessHandler, + unsubscribeHandler: mockUnsubscribeSuccessHandler + }, + clientOptions: { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions: 1, + } + }); + + context.adapter.connect(); + + let operation1 = context.client.createStream({ + subscriptionTopicFilter: "a/b" + }); + await openOperationAndVerifyPublishes(operation1, context, "a/b"); + + // we can make a new one that shares the subscription + let operation2 = context.client.createStream({ + subscriptionTopicFilter: "a/b" + }); + await openOperationAndVerifyPublishes(operation2, context, "a/b"); + + // but we can't make a new one that uses a new subscription + let operation3 = context.client.createStream({ + subscriptionTopicFilter: "b/c" + }); + + let subscriptionStatusPromise3 = once(operation3, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + operation3.open(); + let subscriptionStatus3 : mqtt_request_response.SubscriptionStatusEvent = (await subscriptionStatusPromise3)[0]; + expect(subscriptionStatus3.type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionHalted); + expect(subscriptionStatus3.error).toBeTruthy(); + + let error = subscriptionStatus3.error as CrtError; + expect(error.message).toContain("NoCapacity"); + + // close all the existing streams + await closeOperation(operation1); + await closeOperation(operation2); + + // now we can make new one that uses a different subscription + let operation4 = context.client.createStream({ + subscriptionTopicFilter: "b/c" + }); + await openOperationAndVerifyPublishes(operation4, context, "b/c"); + + cleanupTestContext(context); +}); + +const STREAMING_TOPIC : string = "streaming/topic"; + +function mockSubscribeStreamingSuccessHandler(adapter: protocol_adapter_mock.MockProtocolAdapter, subscribeOptions: protocol_adapter.SubscribeOptions, context?: any) { + if (subscribeOptions.topicFilter === STREAMING_TOPIC) { + setImmediate(() => { + adapter.completeSubscribe(subscribeOptions.topicFilter); + }); + } +} + +async function verifyRequestResponseFailure(promise: Promise) { + try { + await promise; + expect(false); + } catch (err) { + let error = err as CrtError; + expect(error.message).toContain("timeout"); + } +} + +/* + * Configure server to only respond to subscribes that match a streaming filter. Submit a couple of + * request-response operations ahead of a streaming operation. Verify they both time out and that the streaming + * operation successfully subscribes and receives publishes. + */ +test('streaming operation - success delayed by request-response timeouts', async () => { + let context = createTestContext({ + adapterOptions: { + subscribeHandler: mockSubscribeStreamingSuccessHandler, + unsubscribeHandler: mockUnsubscribeSuccessHandler + }, + clientOptions: { + maxRequestResponseSubscriptions: 2, // will cause the requests to be performed serially, blocking the streaming operation temporarily + maxStreamingSubscriptions: 1, + operationTimeoutInSeconds: 2, + } + }); + + context.adapter.connect(); + + let request1 = makeGoodRequest(); + request1.subscriptionTopicFilters = new Array("a/accepted", "a/rejected"); + + let request2 = makeGoodRequest(); + request2.subscriptionTopicFilters = new Array("b/accepted", "b/rejected"); + + let requestPromise1 = context.client.submitRequest(request1); + let requestPromise2 = context.client.submitRequest(request2); + + let operation1 = context.client.createStream({ + subscriptionTopicFilter: STREAMING_TOPIC + }); + + setImmediate(async () => { await verifyRequestResponseFailure(requestPromise1); } ); + setImmediate(async () => { await verifyRequestResponseFailure(requestPromise2); } ); + + await openOperationAndVerifyPublishes(operation1, context, STREAMING_TOPIC); + + operation1.close(); + + cleanupTestContext(context); +}); + +/* + * Variant of previous test where we sandwich the streaming operation by multiple request response operations and + * verify all request-response operations fail with a timeout. + */ +test('streaming operation - success sandwiched by request-response timeouts', async () => { + let context = createTestContext({ + adapterOptions: { + subscribeHandler: mockSubscribeStreamingSuccessHandler, + unsubscribeHandler: mockUnsubscribeSuccessHandler + }, + clientOptions: { + maxRequestResponseSubscriptions: 2, // will cause the requests to be performed serially, blocking the streaming operation temporarily + maxStreamingSubscriptions: 1, + operationTimeoutInSeconds: 2, + } + }); + + context.adapter.connect(); + + let request1 = makeGoodRequest(); + request1.subscriptionTopicFilters = new Array("a/accepted", "a/rejected"); + + let request2 = makeGoodRequest(); + request2.subscriptionTopicFilters = new Array("b/accepted", "b/rejected"); + + let requestPromise1 = context.client.submitRequest(request1); + let requestPromise2 = context.client.submitRequest(request2); + + let operation1 = context.client.createStream({ + subscriptionTopicFilter: STREAMING_TOPIC + }); + + setImmediate(async () => { await verifyRequestResponseFailure(requestPromise1); } ); + setImmediate(async () => { await verifyRequestResponseFailure(requestPromise2); } ); + + let streamingCheckPromise = openOperationAndVerifyPublishes(operation1, context, STREAMING_TOPIC); + + let request3 = makeGoodRequest(); + request3.subscriptionTopicFilters = new Array("c/accepted", "c/rejected"); + + let request4 = makeGoodRequest(); + request4.subscriptionTopicFilters = new Array("d/accepted", "d/rejected"); + + let requestPromise3 = context.client.submitRequest(request3); + let requestPromise4 = context.client.submitRequest(request4); + + await verifyRequestResponseFailure(requestPromise3); + await verifyRequestResponseFailure(requestPromise4); + + await streamingCheckPromise; + + operation1.close(); + + cleanupTestContext(context); +}); diff --git a/lib/common/io.ts b/lib/common/io.ts index d2d104ef9..594671130 100644 --- a/lib/common/io.ts +++ b/lib/common/io.ts @@ -63,3 +63,95 @@ export enum SocketDomain { /** UNIX domain socket/Windows named pipes */ LOCAL = 2, } + +/** + * The amount of detail that will be logged + * @category Logging + */ +export enum LogLevel { + /** No logging whatsoever. */ + NONE = 0, + /** Only fatals. In practice, this will not do much, as the process will log and then crash (intentionally) if a fatal condition occurs */ + FATAL = 1, + /** Only errors */ + ERROR = 2, + /** Only warnings and errors */ + WARN = 3, + /** Information about connection/stream creation/destruction events */ + INFO = 4, + /** Enough information to debug the chain of events a given network connection encounters */ + DEBUG = 5, + /** Everything. Only use this if you really need to know EVERY single call */ + TRACE = 6 +} + +let logLevel : LogLevel = LogLevel.NONE; + +/** + * Sets the amount of detail that will be logged + * @param level - maximum level of logging detail. Log invocations at a higher level of detail will be ignored. + * + * @category Logging + */ +export function setLogLevel(level: LogLevel) { + logLevel = level; +} + +/* + * The logging API is exported to library-internal, but stays private beyond the package boundary, so the following API + * decisions are not binding. + */ + +export function logFatal(subject: string, logLine: string) { + if (logLevel < LogLevel.FATAL) { + return; + } + + let currentTime = new Date().toISOString(); + console.log(`[FATAL] [${currentTime}] [${subject}] - ${logLine}`); +} + +export function logError(subject: string, logLine: string) { + if (logLevel < LogLevel.ERROR) { + return; + } + + let currentTime = new Date().toISOString(); + console.log(`[ERROR] [${currentTime}] [${subject}] - ${logLine}`); +} + +export function logWarn(subject: string, logLine: string) { + if (logLevel < LogLevel.WARN) { + return; + } + + let currentTime = new Date().toISOString(); + console.log(`[WARN] [${currentTime}] [${subject}] - ${logLine}`); +} + +export function logInfo(subject: string, logLine: string) { + if (logLevel < LogLevel.INFO) { + return; + } + + let currentTime = new Date().toISOString(); + console.log(`[INFO] [${currentTime}] [${subject}] - ${logLine}`); +} + +export function logDebug(subject: string, logLine: string) { + if (logLevel < LogLevel.DEBUG) { + return; + } + + let currentTime = new Date().toISOString(); + console.log(`[DEBUG] [${currentTime}] [${subject}] - ${logLine}`); +} + +export function logTrace(subject: string, logLine: string) { + if (logLevel < LogLevel.TRACE) { + return; + } + + let currentTime = new Date().toISOString(); + console.log(`[TRACE] [${currentTime}] [${subject}] - ${logLine}`); +} \ No newline at end of file diff --git a/lib/common/mqtt.spec.ts b/lib/common/mqtt.spec.ts index 0a296b6db..3a002cda8 100644 --- a/lib/common/mqtt.spec.ts +++ b/lib/common/mqtt.spec.ts @@ -199,6 +199,7 @@ test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt311_is_valid_iot_cred())('MQT }); test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt311_is_valid_iot_cred())('MQTT payload types', async () => { + const connection = await makeConnection(); let onDisconnect = once(connection, 'disconnect'); diff --git a/lib/common/mqtt_request_response.ts b/lib/common/mqtt_request_response.ts new file mode 100644 index 000000000..1f3c88328 --- /dev/null +++ b/lib/common/mqtt_request_response.ts @@ -0,0 +1,238 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +import {ICrtError} from "./error"; + +/** + * @packageDocumentation + * @module mqtt_request_response + */ + +export type RequestPayload = ArrayBuffer; +export type ResponsePayload = ArrayBuffer; +export type StreamingPayload = ArrayBuffer; + +/** + * The type of change to the state of a streaming operation subscription + */ +export enum SubscriptionStatusEventType { + + /** + * The streaming operation is successfully subscribed to its topic (filter) + */ + SubscriptionEstablished = 0, + + /** + * The streaming operation has temporarily lost its subscription to its topic (filter) + */ + SubscriptionLost = 1, + + /** + * The streaming operation has entered a terminal state where it has given up trying to subscribe + * to its topic (filter). This is always due to user error (bad topic filter or IoT Core permission policy). + */ + SubscriptionHalted = 2, +} + +/** + * An event that describes a change in subscription status for a streaming operation. + */ +export interface SubscriptionStatusEvent { + + /** + * The type of status change represented by the event + */ + type: SubscriptionStatusEventType, + + /** + * Describes an underlying reason for the event. Only set for SubscriptionLost and SubscriptionHalted. + */ + error?: ICrtError, +} + +/** + * An event that describes an incoming message on a streaming operation. + */ +export interface IncomingPublishEvent { + + /** + * The payload of the incoming message. + */ + payload: StreamingPayload +} + +/** + * Signature for a handler that listens to subscription status events. + */ +export type SubscriptionStatusListener = (eventData: SubscriptionStatusEvent) => void; + +/** + * Signature for a handler that listens to incoming publish events. + */ +export type IncomingPublishListener = (eventData: IncomingPublishEvent) => void; + +/** + * Encapsulates a response to an AWS IoT Core MQTT-based service request + */ +export interface Response { + + /** + * Payload of the response that correlates to a submitted request. + */ + payload: ResponsePayload, + + /** + * MQTT Topic that the response was received on. Different topics map to different types within the + * service model, so we need this value in order to know what to deserialize the payload into. + */ + topic: string +} + +/** + * A response path is a pair of values - MQTT topic and a JSON path - that describe how a response to + * an MQTT-based request may arrive. For a given request type, there may be multiple response paths and each + * one is associated with a separate JSON schema for the response body. + */ +export interface ResponsePath { + + /** + * MQTT topic that a response may arrive on. + */ + topic: string, + + /** + * JSON path for finding correlation tokens within payloads that arrive on this path's topic. + */ + correlationTokenJsonPath?: string +} + +/** + * Configuration options for an MQTT-based request-response operation. + */ +export interface RequestResponseOperationOptions { + + /** + * Set of topic filters that should be subscribed to in order to cover all possible response paths. Sometimes + * using wildcards can cut down on the subscriptions needed; other times that isn't valid. + */ + subscriptionTopicFilters : Array, + + /** + * Set of all possible response paths associated with this request type. + */ + responsePaths: Array, + + /** + * Topic to publish the request to once response subscriptions have been established. + */ + publishTopic: string, + + /** + * Payload to publish to 'publishTopic' in order to initiate the request + */ + payload: RequestPayload, + + /** + * Correlation token embedded in the request that must be found in a response message. This can be null + * to support certain services which don't use correlation tokens. In that case, the client + * only allows one token-less request at a time. + */ + correlationToken?: string +} + +/** + * Configuration options for an MQTT-based streaming operation. + */ +export interface StreamingOperationOptions { + + /** + * Topic filter that the streaming operation should listen on + */ + subscriptionTopicFilter: string, +} + +/** + * Shared interface for an AWS MQTT service streaming operation. A streaming operation listens to messages on + * a particular topic, deserializes them using a service model, and emits the modeled data as Javascript events. + */ +export interface IStreamingOperation { + + /** + * Triggers the streaming operation to start listening to the configured stream of events. It is an error + * to open a streaming operation more than once. You cannot re-open a closed streaming operation. + */ + open() : void; + + /** + * Stops a streaming operation from listening to the configured stream of events. It is an error to attempt to + * use the stream for anything further after calling close(). + */ + close(): void; +} + +/** + * MQTT-based request-response client configuration options + */ +export interface RequestResponseClientOptions { + + /** + * Maximum number of subscriptions that the client will concurrently use for request-response operations + */ + maxRequestResponseSubscriptions: number, + + /** + * Maximum number of subscriptions that the client will concurrently use for streaming operations + */ + maxStreamingSubscriptions: number, + + /** + * Duration, in seconds, that a request-response operation will wait for completion before giving up + */ + operationTimeoutInSeconds?: number, +} + +/** + * Shared interface for MQTT-based request-response clients tuned for AWS MQTT services. + * + * Supports streaming operations (listen to a stream of modeled events from an MQTT topic) and request-response + * operations (performs the subscribes, publish, and incoming publish correlation and error checking needed to + * perform simple request-response operations over MQTT). + */ +export interface IRequestResponseClient { + + /** + * Shuts down the request-response client. Closing a client will fail all incomplete requests and close all + * outstanding streaming operations. + * + * It is not valid to invoke any further operations on the client after close() has been called. + */ + close(): void; + + /** + * Creates a new streaming operation from a set of configuration options. A streaming operation provides a + * mechanism for listening to a specific event stream from an AWS MQTT-based service. + * + * @param streamOptions configuration options for the streaming operation + * + * browser/node implementers are covariant by returning an implementation of IStreamingOperation. This split + * is necessary because event listening (which streaming operations need) cannot be modeled on an interface. + */ + createStream(streamOptions: StreamingOperationOptions) : IStreamingOperation; + + /** + * Submits a request to the request-response client. + * + * @param requestOptions description of the request to perform + * + * Returns a promise that resolves to a response to the request or an error describing how the request attempt + * failed. + * + * A "successful" request-response execution flow is defined as "the service sent a response payload that + * correlates with the request payload." Upon deserialization (which is the responsibility of the service model + * client, one layer up), such a payload may actually indicate a failure. + */ + submitRequest(requestOptions: RequestResponseOperationOptions): Promise; +} + diff --git a/lib/common/mqtt_request_response_internal.ts b/lib/common/mqtt_request_response_internal.ts new file mode 100644 index 000000000..45a1080af --- /dev/null +++ b/lib/common/mqtt_request_response_internal.ts @@ -0,0 +1,20 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +/** + * @packageDocumentation + * @module mqtt_request_response + */ + +export enum StreamingOperationState { + None, + Open, + Closed, +} + +export enum RequestResponseClientState { + Ready, + Closed +} \ No newline at end of file diff --git a/lib/common/mqtt_shared.ts b/lib/common/mqtt_shared.ts index f10e11919..30e995521 100644 --- a/lib/common/mqtt_shared.ts +++ b/lib/common/mqtt_shared.ts @@ -60,3 +60,65 @@ export function normalize_payload_to_buffer(payload: any): Buffer { /** @internal */ export const DEFAULT_KEEP_ALIVE : number = 1200; + + +function isValidTopicInternal(topic: string, isFilter: boolean) : boolean { + if (topic.length === 0 || topic.length > 65535) { + return false; + } + + let sawHash : boolean = false; + for (let segment of topic.split('/')) { + if (sawHash) { + return false; + } + + if (segment.length === 0) { + continue; + } + + if (segment.includes("+")) { + if (!isFilter) { + return false; + } + + if (segment.length > 1) { + return false; + } + } + + if (segment.includes("#")) { + if (!isFilter) { + return false; + } + + if (segment.length > 1) { + return false; + } + + sawHash = true; + } + } + + return true; +} + +export function isValidTopicFilter(topicFilter: any) : boolean { + if (typeof(topicFilter) !== 'string') { + return false; + } + + let topicFilterAsString = topicFilter as string; + + return isValidTopicInternal(topicFilterAsString, true); +} + +export function isValidTopic(topic: any) : boolean { + if (typeof(topic) !== 'string') { + return false; + } + + let topicAsString = topic as string; + + return isValidTopicInternal(topicAsString, false); +} \ No newline at end of file diff --git a/lib/index.ts b/lib/index.ts index 395d3cb38..8e1a0907e 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -22,6 +22,7 @@ import * as io from './native/io'; import * as iot from './native/iot'; import * as mqtt from './native/mqtt'; import * as mqtt5 from './native/mqtt5'; +import * as mqtt_request_response from './native/mqtt_request_response'; import { ICrtError, CrtError } from './native/error'; export { @@ -36,6 +37,7 @@ export { iot, mqtt, mqtt5, + mqtt_request_response, platform, promise, resource_safety, diff --git a/lib/native/binding.d.ts b/lib/native/binding.d.ts index ca86f0ced..8ab7663c5 100644 --- a/lib/native/binding.d.ts +++ b/lib/native/binding.d.ts @@ -19,6 +19,7 @@ import * as mqtt5_packet from "../common/mqtt5_packet"; import { PublishCompletionResult } from "../common/mqtt5"; import * as eventstream from "./eventstream"; import { ConnectionStatistics } from "./mqtt"; +import * as mqtt_request_response from "../native/mqtt_request_response"; /** @@ -145,6 +146,43 @@ export function checksums_crc32c(data: StringLike, previous?: number): number; /** @internal */ export function checksums_crc64nvme(data: StringLike, previous?: DataView): DataView; +/* MQTT Request-Response Client */ + +/** @internal */ +export function mqtt_request_response_client_new_from_5( + client: mqtt_request_response.RequestResponseClient, + protocolClient: NativeHandle, + options: mqtt_request_response.RequestResponseClientOptions +): NativeHandle; + +/** @internal */ +export function mqtt_request_response_client_new_from_311( + client: mqtt_request_response.RequestResponseClient, + protocolClient: NativeHandle, + options: mqtt_request_response.RequestResponseClientOptions +): NativeHandle; + +/** @internal */ +export function mqtt_request_response_client_close(client: NativeHandle) : void; + +/** @internal */ +export function mqtt_request_response_client_submit_request(client: NativeHandle, request_options: mqtt_request_response.RequestResponseOperationOptions, on_completion: (errorCode: number, topic?: string, response?: ArrayBuffer) => void) : void; + +/** @internal */ +export function mqtt_streaming_operation_new( + operation: mqtt_request_response.StreamingOperationBase, + client: NativeHandle, + options: mqtt_request_response.StreamingOperationOptions, + on_subscription_status_update_handler: (streamingOperation: mqtt_request_response.StreamingOperationBase, type: mqtt_request_response.SubscriptionStatusEventType, error_code: number) => void, + on_incoming_publish_handler: (streamingOperation: mqtt_request_response.StreamingOperationBase, publishEvent: mqtt_request_response.IncomingPublishEvent) => void, +): NativeHandle; + +/** @internal */ +export function mqtt_streaming_operation_open(operation: NativeHandle) : void; + +/** @internal */ +export function mqtt_streaming_operation_close(operation: NativeHandle) : void; + /* MQTT5 Client */ /** @internal */ diff --git a/lib/native/io.ts b/lib/native/io.ts index d77c25afe..5cc70de01 100644 --- a/lib/native/io.ts +++ b/lib/native/io.ts @@ -21,9 +21,10 @@ import crt_native from './binding'; import { NativeResource } from "./native_resource"; -import { TlsVersion, SocketType, SocketDomain } from '../common/io'; +import { setLogLevel, LogLevel, TlsVersion, SocketType, SocketDomain } from '../common/io'; import { Readable } from 'stream'; -export { TlsVersion, SocketType, SocketDomain } from '../common/io'; +// Do not re-export the logging functions in common; they are package-private +export { setLogLevel, LogLevel, TlsVersion, SocketType, SocketDomain } from '../common/io'; import { CrtError } from './error'; /** @@ -56,27 +57,6 @@ export function error_code_to_name(error_code: number): string { return crt_native.error_code_to_name(error_code); } -/** - * The amount of detail that will be logged - * @category Logging - */ -export enum LogLevel { - /** No logging whatsoever. Equivalent to never calling {@link enable_logging}. */ - NONE = 0, - /** Only fatals. In practice, this will not do much, as the process will log and then crash (intentionally) if a fatal condition occurs */ - FATAL = 1, - /** Only errors */ - ERROR = 2, - /** Only warnings and errors */ - WARN = 3, - /** Information about connection/stream creation/destruction events */ - INFO = 4, - /** Enough information to debug the chain of events a given network connection encounters */ - DEBUG = 5, - /** Everything. Only use this if you really need to know EVERY single call */ - TRACE = 6 -} - /** * Enables logging of the native AWS CRT libraries. * @param level - The logging level to filter to. It is not possible to log less than WARN. @@ -86,6 +66,7 @@ export enum LogLevel { */ export function enable_logging(level: LogLevel) { crt_native.io_logging_enable(level); + setLogLevel(level); } /** diff --git a/lib/native/mqtt_request_response.spec.ts b/lib/native/mqtt_request_response.spec.ts new file mode 100644 index 000000000..059b93d95 --- /dev/null +++ b/lib/native/mqtt_request_response.spec.ts @@ -0,0 +1,656 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + + +import * as test_env from "@test/test_env" +import * as mqtt5 from "./mqtt5"; +import * as mqtt_request_response from "./mqtt_request_response"; +import {v4 as uuid} from "uuid"; +import {once} from "events"; +import * as mrr_test from "@test/mqtt_request_response"; +import * as aws_iot_5 from "./aws_iot_mqtt5"; +import * as aws_iot_311 from "./aws_iot"; +import * as iot from "./iot"; + +jest.setTimeout(10000); + +function createClientBuilder5() : aws_iot_5.AwsIotMqtt5ClientConfigBuilder { + let builder = iot.AwsIotMqtt5ClientConfigBuilder.newDirectMqttBuilderWithMtlsFromPath( + test_env.AWS_IOT_ENV.MQTT5_HOST, + test_env.AWS_IOT_ENV.MQTT5_RSA_CERT, + test_env.AWS_IOT_ENV.MQTT5_RSA_KEY + ); + + return builder; +} + +function createClientBuilder311() : aws_iot_311.AwsIotMqttConnectionConfigBuilder { + let builder = iot.AwsIotMqttConnectionConfigBuilder.new_mtls_builder_from_path(test_env.AWS_IOT_ENV.MQTT5_RSA_CERT, test_env.AWS_IOT_ENV.MQTT5_RSA_KEY); + builder.with_endpoint(test_env.AWS_IOT_ENV.MQTT5_HOST); // yes, 5 not 3 + + return builder; +} + +function initClientBuilderFactories() { + // @ts-ignore + mrr_test.setClientBuilderFactories(createClientBuilder5, createClientBuilder311); +} + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Create Destroy Mqtt5', async () => { + initClientBuilderFactories(); + let context = new mrr_test.TestingContext({ + version: mrr_test.ProtocolVersion.Mqtt5 + }); + await context.open(); + + await context.close(); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Create Destroy Mqtt311', async () => { + initClientBuilderFactories(); + let context = new mrr_test.TestingContext({ + version: mrr_test.ProtocolVersion.Mqtt311 + }); + await context.open(); + + await context.close(); +}); + + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Success Rejected Mqtt5', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_success_rejected_test(mrr_test.ProtocolVersion.Mqtt5, true); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Success Rejected Mqtt311', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_success_rejected_test(mrr_test.ProtocolVersion.Mqtt311, true); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Success Rejected No CorrelationToken Mqtt5', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_success_rejected_test(mrr_test.ProtocolVersion.Mqtt5, false); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Success Rejected No CorrelationToken Mqtt311', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_success_rejected_test(mrr_test.ProtocolVersion.Mqtt311, false); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('UpdateNamedShadow Success Accepted Mqtt5', async () => { + initClientBuilderFactories(); + await mrr_test.do_update_named_shadow_success_accepted_test(mrr_test.ProtocolVersion.Mqtt5, true); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('UpdateNamedShadow Success Accepted Mqtt311', async () => { + initClientBuilderFactories(); + await mrr_test.do_update_named_shadow_success_accepted_test(mrr_test.ProtocolVersion.Mqtt311, true); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('UpdateNamedShadow Success Accepted No CorrelationToken Mqtt5', async () => { + initClientBuilderFactories(); + await mrr_test.do_update_named_shadow_success_accepted_test(mrr_test.ProtocolVersion.Mqtt5, false); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('UpdateNamedShadow Success Accepted No CorrelationToken Mqtt311', async () => { + initClientBuilderFactories(); + await mrr_test.do_update_named_shadow_success_accepted_test(mrr_test.ProtocolVersion.Mqtt311, false); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Timeout Mqtt5', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_timeout_test(mrr_test.ProtocolVersion.Mqtt5, true); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Timeout Mqtt311', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_timeout_test(mrr_test.ProtocolVersion.Mqtt311, true); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Timeout No CorrelationToken Mqtt5', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_timeout_test(mrr_test.ProtocolVersion.Mqtt5, false); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Timeout No CorrelationToken Mqtt311', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_timeout_test(mrr_test.ProtocolVersion.Mqtt311, false); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure On Close Mqtt5', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_on_close_test(mrr_test.ProtocolVersion.Mqtt5, "timeout"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure On Close Mqtt311', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_on_close_test(mrr_test.ProtocolVersion.Mqtt311, "timeout"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure zero max request response subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_no_max_request_response_subscriptions, "An invalid argument was passed to a function"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure zero max request response subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_no_max_request_response_subscriptions, "An invalid argument was passed to a function"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure invalid max request response subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_invalid_max_request_response_subscriptions, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure invalid max request response subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_invalid_max_request_response_subscriptions, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure undefined config mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_undefined_config, "required configuration parameter is null"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure undefined config mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_undefined_config, "required configuration parameter is null"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure undefined max request response subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_undefined_max_request_response_subscriptions, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure undefined max request response subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_undefined_max_request_response_subscriptions, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure null max request response subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_null_max_request_response_subscriptions, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure null max request response subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_null_max_request_response_subscriptions, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure missing max request response subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_missing_max_request_response_subscriptions, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure missing max request response subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_missing_max_request_response_subscriptions, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure undefined max streaming subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_undefined_max_streaming_subscriptions, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure undefined max streaming subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_undefined_max_streaming_subscriptions, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure null max streaming subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_null_max_streaming_subscriptions, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure null max streaming subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_null_max_streaming_subscriptions, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure missing max streaming subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_missing_max_streaming_subscriptions, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure missing max streaming subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_missing_max_streaming_subscriptions, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure missing max streaming subscriptions mqtt5', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt5, mrr_test.create_bad_config_invalid_operation_timeout, "invalid configuration options"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Client creation failure missing max streaming subscriptions mqtt311', async() => { + initClientBuilderFactories(); + mrr_test.do_client_creation_failure_test(mrr_test.ProtocolVersion.Mqtt311, mrr_test.create_bad_config_invalid_operation_timeout, "invalid configuration options"); +}); + +test('Client creation failure null protocol client mqtt311', async() => { + initClientBuilderFactories(); + let config : mqtt_request_response.RequestResponseClientOptions = { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions : 2, + operationTimeoutInSeconds : 5, + }; + + // @ts-ignore + expect(() => {mqtt_request_response.RequestResponseClient.newFromMqtt311(null, config)}).toThrow("protocol client is null"); +}); + +test('Client creation failure null protocol client mqtt5', async() => { + initClientBuilderFactories(); + let config : mqtt_request_response.RequestResponseClientOptions = { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions : 2, + operationTimeoutInSeconds : 5, + }; + + // @ts-ignore + expect(() => {mqtt_request_response.RequestResponseClient.newFromMqtt5(null, config)}).toThrow("protocol client is null"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure No Subscription Topic Filters', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + delete new_options.subscriptionTopicFilters; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Null Subscription Topic Filters', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.subscriptionTopicFilters = null; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Subscription Topic Filters Not An Array', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.subscriptionTopicFilters = "null"; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Subscription Topic Filters Empty', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.subscriptionTopicFilters = []; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure No Response Paths', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + delete new_options.responsePaths; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Null Response Paths', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.responsePaths = null; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Response Paths Not An Array', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.responsePaths = "null"; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Response Paths Empty', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.responsePaths = []; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Response Path No Topic', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + delete new_options.responsePaths[0].topic; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Response Path Null Topic', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.responsePaths[0].topic = null; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Response Path Bad Topic Type', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.responsePaths[0].topic = 5; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Response Path Null Correlation Token Json Path', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.responsePaths[0].correlationTokenJsonPath = null; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Response Path Bad Correlation Token Json Path Type', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.responsePaths[0].correlationTokenJsonPath = {}; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure No Publish Topic', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + delete new_options.publishTopic; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Null Publish Topic', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.publishTopic = null; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Bad Publish Topic Type', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.publishTopic = {someValue: null}; + + return new_options; + }); +}); + + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure No Payload', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + delete new_options.payload; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Null Payload', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.payload = null; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Bad Payload Type', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.payload = {notAStringOrBuffer: 21}; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Null Correlation Token', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.correlationToken = null; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Bad Correlation Token Type', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "invalid request options", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + // @ts-ignore + new_options.correlationToken = ["something"]; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Protocol Invalid Topic', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "failure invoking native client submit_request", (options : mqtt_request_response.RequestResponseOperationOptions) => { + let new_options = options; + new_options.publishTopic = "#/illegal/#/topic"; + + return new_options; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Null Options', async () => { + initClientBuilderFactories(); + await mrr_test.do_get_named_shadow_failure_invalid_test(true, "null request options", + // @ts-ignore + (options : mqtt_request_response.RequestResponseOperationOptions) => { + return null; + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('GetNamedShadow Failure Submit After Close', async () => { + initClientBuilderFactories(); + let context = new mrr_test.TestingContext({ + version: mrr_test.ProtocolVersion.Mqtt5 + }); + + await context.open(); + await context.close(); + + let requestOptions = mrr_test.createRejectedGetNamedShadowRequest(true); + try { + await context.client.submitRequest(requestOptions); + expect(false); + } catch (err: any) { + expect(err.message).toContain("already been closed"); + } +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('ShadowUpdated Streaming Operation Success Open/Close MQTT5', async () => { + initClientBuilderFactories(); + await mrr_test.do_streaming_operation_new_open_close_test(mrr_test.ProtocolVersion.Mqtt5); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('ShadowUpdated Streaming Operation Success Open/Close MQTT311', async () => { + initClientBuilderFactories(); + await mrr_test.do_streaming_operation_new_open_close_test(mrr_test.ProtocolVersion.Mqtt311); +}); + + + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('ShadowUpdated Streaming Operation Success Incoming Publish MQTT5', async () => { + initClientBuilderFactories(); + await mrr_test.do_streaming_operation_incoming_publish_test(mrr_test.ProtocolVersion.Mqtt5); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('ShadowUpdated Streaming Operation Success Incoming Publish MQTT311', async () => { + initClientBuilderFactories(); + await mrr_test.do_streaming_operation_incoming_publish_test(mrr_test.ProtocolVersion.Mqtt311); +}); + +// We only have a 5-based test because there's no way to stop the 311 client without destroying it in the process. +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('ShadowUpdated Streaming Operation Success Subscription Events MQTT5', async () => { + initClientBuilderFactories(); + + await mrr_test.do_streaming_operation_subscription_events_test({ + version: mrr_test.ProtocolVersion.Mqtt5, + builder_mutator5: (builder) => { + builder.withSessionBehavior(mqtt5.ClientSessionBehavior.Clean); + return builder; + } + }); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Streaming Operation Failure Reopen', async () => { + initClientBuilderFactories(); + let context = new mrr_test.TestingContext({ + version: mrr_test.ProtocolVersion.Mqtt5 + }); + + await context.open(); + + let topic_filter = `not/a/real/shadow/${uuid()}`; + let streaming_options : mqtt_request_response.StreamingOperationOptions = { + subscriptionTopicFilter : topic_filter, + } + + let stream = context.client.createStream(streaming_options); + + let initialSubscriptionComplete = once(stream, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + stream.open(); + + await initialSubscriptionComplete; + + stream.open(); + + stream.close(); + + // multi-opening or multi-closing are fine, but opening after a close is not + expect(() => {stream.open()}).toThrow(); + + stream.close(); + + await context.close(); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Streaming Operation Auto Close', async () => { + initClientBuilderFactories(); + let context = new mrr_test.TestingContext({ + version: mrr_test.ProtocolVersion.Mqtt5 + }); + + await context.open(); + + let topic_filter = `not/a/real/shadow/${uuid()}`; + let streaming_options : mqtt_request_response.StreamingOperationOptions = { + subscriptionTopicFilter : topic_filter, + } + + let stream = context.client.createStream(streaming_options); + + let initialSubscriptionComplete = once(stream, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + stream.open(); + + await initialSubscriptionComplete; + + stream.open(); + + await context.close(); + + // Closing the client should close the operation automatically; verify that by verifying that open now generates + // an exception + expect(() => {stream.open()}).toThrow(); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Streaming Operation Creation Failure Null Options', async () => { + initClientBuilderFactories(); + // @ts-ignore + await mrr_test.do_invalid_streaming_operation_config_test(null, "invalid configuration"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Streaming Operation Creation Failure Undefined Options', async () => { + initClientBuilderFactories(); + // @ts-ignore + await mrr_test.do_invalid_streaming_operation_config_test(undefined, "invalid configuration"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Streaming Operation Creation Failure Null Filter', async () => { + initClientBuilderFactories(); + await mrr_test.do_invalid_streaming_operation_config_test({ + // @ts-ignore + subscriptionTopicFilter : null, + }, "invalid configuration"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Streaming Operation Creation Failure Invalid Filter Type', async () => { + initClientBuilderFactories(); + await mrr_test.do_invalid_streaming_operation_config_test({ + // @ts-ignore + subscriptionTopicFilter : 5, + }, "invalid configuration"); +}); + +test_env.conditional_test(test_env.AWS_IOT_ENV.mqtt5_is_valid_mtls_rsa())('Streaming Operation Creation Failure Invalid Filter Value', async () => { + initClientBuilderFactories(); + await mrr_test.do_invalid_streaming_operation_config_test({ + subscriptionTopicFilter : "#/hello/#", + }, "Failed to create"); +}); diff --git a/lib/native/mqtt_request_response.ts b/lib/native/mqtt_request_response.ts new file mode 100644 index 000000000..f1bba5220 --- /dev/null +++ b/lib/native/mqtt_request_response.ts @@ -0,0 +1,306 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +/** + * + * @packageDocumentation + * @module mqtt_request_response + * @mergeTarget + * + */ + +import {CrtError} from "./error"; +import {MqttClientConnection} from "./mqtt"; +import {Mqtt5Client} from "./mqtt5"; +import * as mqtt_request_response from "../common/mqtt_request_response"; +import * as mqtt_request_response_internal from "../common/mqtt_request_response_internal"; +import {NativeResourceMixin} from "./native_resource"; +import {BufferedEventEmitter} from "../common/event"; +import crt_native from './binding'; +import { error_code_to_string } from "./io"; + +export * from "../common/mqtt_request_response"; + + + +/** + * An AWS MQTT service streaming operation. A streaming operation listens to messages on + * a particular topic, deserializes them using a service model, and emits the modeled data as Javascript events. + */ +export class StreamingOperationBase extends NativeResourceMixin(BufferedEventEmitter) implements mqtt_request_response.IStreamingOperation { + + private client: RequestResponseClient; + private state = mqtt_request_response_internal.StreamingOperationState.None; + + static new(options: mqtt_request_response.StreamingOperationOptions, client: RequestResponseClient) : StreamingOperationBase { + if (!options) { + throw new CrtError("invalid configuration for streaming operation"); + } + + let operation = new StreamingOperationBase(client); + operation._super(crt_native.mqtt_streaming_operation_new( + operation, + client.native_handle(), + options, + (streamingOperation: StreamingOperationBase, type: mqtt_request_response.SubscriptionStatusEventType, error_code: number) => { + StreamingOperationBase._s_on_subscription_status_update(operation, type, error_code); + }, + (streamingOperation: StreamingOperationBase, publishEvent: mqtt_request_response.IncomingPublishEvent) => { + StreamingOperationBase._s_on_incoming_publish(operation, publishEvent); + })); + + client.registerUnclosedStreamingOperation(operation); + + return operation; + } + + private constructor(client: RequestResponseClient) { + super(); + this.client = client; + } + + /** + * Triggers the streaming operation to start listening to the configured stream of events. Has no effect on an + * already-open operation. It is an error to attempt to re-open a closed streaming operation. + */ + open() : void { + if (this.state == mqtt_request_response_internal.StreamingOperationState.None) { + this.state = mqtt_request_response_internal.StreamingOperationState.Open; + crt_native.mqtt_streaming_operation_open(this.native_handle()); + } else if (this.state == mqtt_request_response_internal.StreamingOperationState.Closed) { + throw new CrtError("MQTT streaming operation already closed"); + } + } + + /** + * Stops a streaming operation from listening to the configured stream of events and releases all native + * resources associated with the stream. + */ + close(): void { + if (this.state != mqtt_request_response_internal.StreamingOperationState.Closed) { + this.client.unregisterUnclosedStreamingOperation(this); + this.state = mqtt_request_response_internal.StreamingOperationState.Closed; + crt_native.mqtt_streaming_operation_close(this.native_handle()); + } + } + + /** + * Event emitted when the stream's subscription status changes. + * + * Listener type: {@link SubscriptionStatusListener} + * + * @event + */ + static SUBSCRIPTION_STATUS : string = 'subscriptionStatus'; + + /** + * Event emitted when a stream message is received + * + * Listener type: {@link IncomingPublishListener} + * + * @event + */ + static INCOMING_PUBLISH : string = 'incomingPublish'; + + on(event: 'subscriptionStatus', listener: mqtt_request_response.SubscriptionStatusListener): this; + + on(event: 'incomingPublish', listener: mqtt_request_response.IncomingPublishListener): this; + + on(event: string | symbol, listener: (...args: any[]) => void): this { + super.on(event, listener); + return this; + } + + private static _s_on_subscription_status_update(streamingOperation: StreamingOperationBase, type: mqtt_request_response.SubscriptionStatusEventType, error_code: number) : void { + let statusEvent : mqtt_request_response.SubscriptionStatusEvent = { + type: type + }; + + if (error_code != 0) { + statusEvent.error = new CrtError(error_code) + } + + process.nextTick(() => { + streamingOperation.emit(StreamingOperationBase.SUBSCRIPTION_STATUS, statusEvent); + }); + } + + private static _s_on_incoming_publish(streamingOperation: StreamingOperationBase, publishEvent: mqtt_request_response.IncomingPublishEvent) : void { + process.nextTick(() => { + streamingOperation.emit(StreamingOperationBase.INCOMING_PUBLISH, publishEvent); + }); + } +} + + + +/** + * Native implementation of an MQTT-based request-response client tuned for AWS MQTT services. + * + * Supports streaming operations (listen to a stream of modeled events from an MQTT topic) and request-response + * operations (performs the subscribes, publish, and incoming publish correlation and error checking needed to + * perform simple request-response operations over MQTT). + */ +export class RequestResponseClient extends NativeResourceMixin(BufferedEventEmitter) implements mqtt_request_response.IRequestResponseClient { + + private state: mqtt_request_response_internal.RequestResponseClientState = mqtt_request_response_internal.RequestResponseClientState.Ready; + private unclosedOperations? : Set = new Set(); + + private constructor() { + super(); + } + + /** + * Creates a new MQTT service request-response client that uses an MQTT5 client as the protocol implementation. + * + * @param protocolClient protocol client to use for all operations + * @param options configuration options for the desired request-response client + */ + static newFromMqtt5(protocolClient: Mqtt5Client, options: mqtt_request_response.RequestResponseClientOptions): RequestResponseClient { + if (!protocolClient) { + throw new CrtError("protocol client is null"); + } + + let client = new RequestResponseClient(); + client._super(crt_native.mqtt_request_response_client_new_from_5(client, protocolClient.native_handle(), options)); + + return client; + } + + /** + * Creates a new MQTT service request-response client that uses an MQTT311 client as the protocol implementation. + * + * @param protocolClient protocol client to use for all operations + * @param options configuration options for the desired request-response client + */ + static newFromMqtt311(protocolClient: MqttClientConnection, options: mqtt_request_response.RequestResponseClientOptions) : RequestResponseClient { + if (!protocolClient) { + throw new CrtError("protocol client is null"); + } + + let client = new RequestResponseClient(); + client._super(crt_native.mqtt_request_response_client_new_from_311(client, protocolClient.native_handle(), options)); + + return client; + } + + /** + * Triggers cleanup of native resources associated with the request-response client. Closing a client will fail + * all incomplete requests and close all outstanding streaming operations. + * + * This must be called when finished with a client; otherwise, native resources will leak. + */ + close(): void { + if (this.state != mqtt_request_response_internal.RequestResponseClientState.Closed) { + this.state = mqtt_request_response_internal.RequestResponseClientState.Closed; + this.closeStreamingOperations(); + crt_native.mqtt_request_response_client_close(this.native_handle()); + } + } + + /** + * Creates a new streaming operation from a set of configuration options. A streaming operation provides a + * mechanism for listening to a specific event stream from an AWS MQTT-based service. + * + * @param streamOptions configuration options for the streaming operation + */ + createStream(streamOptions: mqtt_request_response.StreamingOperationOptions) : StreamingOperationBase { + if (this.state == mqtt_request_response_internal.RequestResponseClientState.Closed) { + throw new CrtError("MQTT request-response client has already been closed"); + } + + return StreamingOperationBase.new(streamOptions, this); + } + + /** + * Submits a request to the request-response client. + * + * @param requestOptions description of the request to perform + * + * Returns a promise that resolves to a response to the request or an error describing how the request attempt + * failed. + * + * A "successful" request-response execution flow is defined as "the service sent a response payload that + * correlates with the request payload." Upon deserialization (which is the responsibility of the service model + * client, one layer up), such a payload may actually indicate a failure. + */ + async submitRequest(requestOptions: mqtt_request_response.RequestResponseOperationOptions): Promise { + return new Promise((resolve, reject) => { + if (this.state == mqtt_request_response_internal.RequestResponseClientState.Closed) { + reject(new CrtError("MQTT request-response client has already been closed")); + return; + } + + if (!requestOptions) { + reject(new CrtError("null request options")); + return; + } + + function curriedPromiseCallback(errorCode: number, topic?: string, response?: ArrayBuffer){ + return RequestResponseClient._s_on_request_completion(resolve, reject, errorCode, topic, response); + } + + try { + crt_native.mqtt_request_response_client_submit_request(this.native_handle(), requestOptions, curriedPromiseCallback); + } catch (e) { + reject(e); + } + }); + } + + /** + * + * Adds a streaming operation to the set of operations that will be closed automatically when the + * client is closed. + * + * @internal + * + * @param operation streaming operation to add + */ + registerUnclosedStreamingOperation(operation: StreamingOperationBase) : void { + if (this.unclosedOperations) { + this.unclosedOperations.add(operation); + } + } + + /** + * + * Removes a streaming operation from the set of operations that will be closed automatically when the + * client is closed. + * + * @internal + * + * @param operation streaming operation to remove + */ + unregisterUnclosedStreamingOperation(operation: StreamingOperationBase) : void { + if (this.unclosedOperations) { + this.unclosedOperations.delete(operation); + } + } + + private closeStreamingOperations() : void { + if (this.unclosedOperations) { + // swap out the set so that calls to unregisterUnclosedStreamingOperation do not mess with things mid-iteration + let unclosedOperations = this.unclosedOperations; + this.unclosedOperations = undefined; + + for (const operation of unclosedOperations) { + operation.close(); + } + } + } + + private static _s_on_request_completion(resolve : (value: (mqtt_request_response.Response | PromiseLike)) => void, reject : (reason?: any) => void, errorCode: number, topic?: string, payload?: ArrayBuffer) { + if (errorCode == 0 && topic !== undefined && payload !== undefined) { + let response : mqtt_request_response.Response = { + payload : payload, + topic: topic, + } + resolve(response); + } else { + reject(new CrtError(error_code_to_string(errorCode))); + } + } +} diff --git a/source/event_stream.c b/source/event_stream.c index 8f4e11966..ca25e7e2b 100644 --- a/source/event_stream.c +++ b/source/event_stream.c @@ -1845,7 +1845,6 @@ napi_value aws_napi_event_stream_client_stream_new(napi_env env, napi_callback_i aws_ref_count_init(&binding->ref_count, binding, s_aws_event_stream_client_stream_binding_on_zero); AWS_NAPI_CALL(env, napi_create_external(env, binding, NULL, NULL, &node_external), { - aws_mem_release(allocator, binding); napi_throw_error(env, NULL, "aws_napi_event_stream_client_stream_new - Failed to create n-api external"); s_aws_event_stream_client_stream_binding_release(binding); goto done; diff --git a/source/module.c b/source/module.c index ba7e4603a..ebb0b7515 100644 --- a/source/module.c +++ b/source/module.c @@ -17,6 +17,7 @@ #include "mqtt5_client.h" #include "mqtt_client.h" #include "mqtt_client_connection.h" +#include "mqtt_request_response.h" #include @@ -596,6 +597,35 @@ enum aws_napi_get_named_property_result aws_napi_get_named_property_buffer_lengt return result; } +static int s_typed_array_element_type_to_byte_length(napi_typedarray_type type, size_t *element_length) { + switch (type) { + case napi_int8_array: + case napi_uint8_array: + case napi_uint8_clamped_array: + *element_length = 1; + return AWS_OP_SUCCESS; + + case napi_int16_array: + case napi_uint16_array: + *element_length = 2; + return AWS_OP_SUCCESS; + + case napi_int32_array: + case napi_uint32_array: + case napi_float32_array: + *element_length = 4; + return AWS_OP_SUCCESS; + + case napi_float64_array: + case 9: /*napi_bigint64_array */ + case 10: /*napi_biguint64_array*/ + *element_length = 8; + return AWS_OP_SUCCESS; + } + + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); +} + napi_status aws_byte_buf_init_from_napi(struct aws_byte_buf *buf, napi_env env, napi_value node_str) { AWS_ASSERT(buf); @@ -650,33 +680,10 @@ napi_status aws_byte_buf_init_from_napi(struct aws_byte_buf *buf, napi_env env, }); size_t element_size = 0; - - /* whoever added napi_bigint64_array to the node api deserves a good thrashing!!!! */ - int type_hack = array_type; - switch (type_hack) { - case napi_int8_array: - case napi_uint8_array: - case napi_uint8_clamped_array: - element_size = 1; - break; - - case napi_int16_array: - case napi_uint16_array: - element_size = 2; - break; - - case napi_int32_array: - case napi_uint32_array: - case napi_float32_array: - element_size = 4; - break; - - case napi_float64_array: - case 9: /*napi_bigint64_array */ - case 10: /*napi_biguint64_array*/ - element_size = 8; - break; + if (s_typed_array_element_type_to_byte_length(array_type, &element_size)) { + return napi_invalid_arg; } + buf->len = length * element_size; buf->capacity = buf->len; @@ -687,6 +694,162 @@ napi_status aws_byte_buf_init_from_napi(struct aws_byte_buf *buf, napi_env env, return napi_invalid_arg; } +int aws_napi_value_get_storage_length(napi_env env, napi_value value, size_t *storage_length) { + + napi_valuetype type = napi_undefined; + AWS_NAPI_CALL(env, napi_typeof(env, value, &type), { return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); }); + + if (type == napi_string) { + AWS_NAPI_CALL(env, napi_get_value_string_utf8(env, value, NULL, 0, storage_length), { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + + return AWS_OP_SUCCESS; + } else if (type == napi_object) { + /* Try ArrayBuffer */ + bool is_array_buffer = false; + AWS_NAPI_CALL(env, napi_is_arraybuffer(env, value, &is_array_buffer), { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + if (is_array_buffer) { + void *buffer = NULL; + AWS_NAPI_CALL(env, napi_get_arraybuffer_info(env, value, &buffer, storage_length), { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + + return AWS_OP_SUCCESS; + } + + /* Try DataView */ + bool is_data_view = false; + AWS_NAPI_CALL(env, napi_is_dataview(env, value, &is_data_view), { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + if (is_data_view) { + AWS_NAPI_CALL(env, napi_get_dataview_info(env, value, storage_length, NULL, NULL, NULL), { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + + return AWS_OP_SUCCESS; + } + + /* Try TypedArray */ + bool is_typed_array = false; + AWS_NAPI_CALL(env, napi_is_typedarray(env, value, &is_typed_array), { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + if (is_typed_array) { + napi_typedarray_type array_type = napi_uint8_array; + size_t length = 0; + AWS_NAPI_CALL(env, napi_get_typedarray_info(env, value, &array_type, &length, NULL, NULL, NULL), { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + + size_t element_size = 0; + if (s_typed_array_element_type_to_byte_length(array_type, &element_size)) { + return napi_invalid_arg; + } + + *storage_length = length * element_size; + + return AWS_OP_SUCCESS; + } + } + + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); +} + +int aws_napi_value_bytebuf_append( + napi_env env, + napi_value value, + struct aws_byte_buf *output_buffer, + struct aws_byte_cursor *bytes_written_cursor) { + + AWS_ZERO_STRUCT(*bytes_written_cursor); + + napi_valuetype type = napi_undefined; + AWS_NAPI_CALL(env, napi_typeof(env, value, &type), { return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); }); + + size_t open_bytes = output_buffer->capacity - output_buffer->len; + uint8_t *open_space = output_buffer->buffer + output_buffer->len; + + if (type == napi_string) { + size_t bytes_written = 0; + AWS_NAPI_CALL(env, napi_get_value_string_utf8(env, value, (char *)open_space, open_bytes, &bytes_written), { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + + /* technically, we might have been truncated, but there's no way to tell */ + bytes_written_cursor->ptr = open_space; + bytes_written_cursor->len = bytes_written; + output_buffer->len += bytes_written; + + return AWS_OP_SUCCESS; + } else if (type == napi_object) { + /* Try ArrayBuffer */ + bool is_array_buffer = false; + AWS_NAPI_CALL(env, napi_is_arraybuffer(env, value, &is_array_buffer), { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + if (is_array_buffer) { + void *buffer = NULL; + size_t buffer_length = 0; + AWS_NAPI_CALL(env, napi_get_arraybuffer_info(env, value, &buffer, &buffer_length), { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + + bytes_written_cursor->ptr = buffer; + bytes_written_cursor->len = buffer_length; + + return aws_byte_buf_append_and_update(output_buffer, bytes_written_cursor); + } + + /* Try DataView */ + bool is_data_view = false; + AWS_NAPI_CALL(env, napi_is_dataview(env, value, &is_data_view), { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + if (is_data_view) { + void *buffer = NULL; + size_t buffer_length = 0; + AWS_NAPI_CALL(env, napi_get_dataview_info(env, value, &buffer_length, &buffer, NULL, NULL), { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + + bytes_written_cursor->ptr = buffer; + bytes_written_cursor->len = buffer_length; + + return aws_byte_buf_append_and_update(output_buffer, bytes_written_cursor); + } + + bool is_typed_array = false; + AWS_NAPI_CALL(env, napi_is_typedarray(env, value, &is_typed_array), { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + if (is_typed_array) { + napi_typedarray_type array_type = napi_uint8_array; + size_t length = 0; + uint8_t *buffer = NULL; + AWS_NAPI_CALL( + env, napi_get_typedarray_info(env, value, &array_type, &length, (void **)&buffer, NULL, NULL), { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + + size_t element_size = 0; + if (s_typed_array_element_type_to_byte_length(array_type, &element_size)) { + return napi_invalid_arg; + } + + bytes_written_cursor->ptr = buffer; + bytes_written_cursor->len = element_size * length; + + return aws_byte_buf_append_and_update(output_buffer, bytes_written_cursor); + } + } + + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); +} + struct aws_string *aws_string_new_from_napi(napi_env env, napi_value node_str) { struct aws_byte_buf temp_buf; @@ -1368,6 +1531,15 @@ static bool s_create_and_register_function( CREATE_AND_REGISTER_FN(io_pkcs11_lib_new) CREATE_AND_REGISTER_FN(io_pkcs11_lib_close) + /* MQTT Request Response */ + CREATE_AND_REGISTER_FN(mqtt_request_response_client_new_from_5) + CREATE_AND_REGISTER_FN(mqtt_request_response_client_new_from_311) + CREATE_AND_REGISTER_FN(mqtt_request_response_client_close) + CREATE_AND_REGISTER_FN(mqtt_request_response_client_submit_request) + CREATE_AND_REGISTER_FN(mqtt_streaming_operation_new) + CREATE_AND_REGISTER_FN(mqtt_streaming_operation_open) + CREATE_AND_REGISTER_FN(mqtt_streaming_operation_close) + /* MQTT5 Client */ CREATE_AND_REGISTER_FN(mqtt5_client_new) CREATE_AND_REGISTER_FN(mqtt5_client_start) diff --git a/source/module.h b/source/module.h index 581ddd49e..4fe30569d 100644 --- a/source/module.h +++ b/source/module.h @@ -205,6 +205,14 @@ int aws_napi_get_property_array_size( const char *property_name, size_t *array_size_out); +int aws_napi_value_get_storage_length(napi_env env, napi_value value, size_t *storage_length); + +int aws_napi_value_bytebuf_append( + napi_env env, + napi_value value, + struct aws_byte_buf *output_buffer, + struct aws_byte_cursor *bytes_written_cursor); + napi_status aws_byte_buf_init_from_napi(struct aws_byte_buf *buf, napi_env env, napi_value node_str); struct aws_string *aws_string_new_from_napi(napi_env env, napi_value node_str); /** Copies data from cur into a new ArrayBuffer, then returns a DataView to the buffer. */ @@ -381,4 +389,47 @@ struct aws_napi_context { binding_name->function_name = NULL; \ } +#define EXTRACT_REQUIRED_NAPI_PROPERTY(property_name, function_name, call_expression, success_block, void_handle) \ + { \ + enum aws_napi_get_named_property_result gpr = call_expression; \ + if (gpr == AWS_NGNPR_VALID_VALUE) { \ + success_block; \ + } else if (gpr == AWS_NGNPR_INVALID_VALUE) { \ + AWS_LOGF_ERROR( \ + AWS_LS_NODEJS_CRT_GENERAL, \ + "id=%p %s - %s: %s", \ + ((void *)void_handle), \ + function_name, \ + "invalid value for property", \ + property_name); \ + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); \ + } else { \ + AWS_LOGF_ERROR( \ + AWS_LS_NODEJS_CRT_GENERAL, \ + "id=%p %s - %s: %s", \ + ((void *)void_handle), \ + function_name, \ + "failed to extract required property", \ + property_name); \ + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); \ + } \ + } + +#define EXTRACT_OPTIONAL_NAPI_PROPERTY(property_name, function_name, call_expression, success_block, void_handle) \ + { \ + enum aws_napi_get_named_property_result gpr = call_expression; \ + if (gpr == AWS_NGNPR_VALID_VALUE) { \ + success_block; \ + } else if (gpr == AWS_NGNPR_INVALID_VALUE) { \ + AWS_LOGF_ERROR( \ + AWS_LS_NODEJS_CRT_GENERAL, \ + "id=%p %s - %s: %s", \ + ((void *)void_handle), \ + function_name, \ + "invalid value for property", \ + property_name); \ + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); \ + } \ + } + #endif /* AWS_CRT_NODEJS_MODULE_H */ diff --git a/source/mqtt5_client.c b/source/mqtt5_client.c index c76a9668a..65d824dba 100644 --- a/source/mqtt5_client.c +++ b/source/mqtt5_client.c @@ -141,6 +141,14 @@ struct aws_mqtt5_client_binding { napi_threadsafe_function transform_websocket; }; +struct aws_mqtt5_client *aws_napi_get_mqtt5_client_from_binding(struct aws_mqtt5_client_binding *binding) { + if (binding == NULL) { + return NULL; + } + + return binding->client; +} + static void s_aws_mqtt5_client_binding_destroy(struct aws_mqtt5_client_binding *binding) { if (binding == NULL) { return; @@ -2155,7 +2163,7 @@ napi_value aws_napi_mqtt5_client_new(napi_env env, napi_callback_info info) { napi_value node_client_config = *arg++; if (aws_napi_is_null_or_undefined(env, node_client_config)) { napi_throw_error(env, NULL, "mqtt5_client_new - Required configuration parameter is null"); - goto cleanup; + goto post_ref_error; } if (s_init_client_configuration_from_js_client_configuration( @@ -2171,27 +2179,27 @@ napi_value aws_napi_mqtt5_client_new(napi_env env, napi_callback_info info) { env, NULL, "mqtt5_client_new - failed to initialize native client configuration from js client configuration"); - goto cleanup; + goto post_ref_error; } /* Arg #3: on stopped event */ napi_value on_stopped_event_handler = *arg++; if (aws_napi_is_null_or_undefined(env, on_stopped_event_handler)) { napi_throw_error(env, NULL, "mqtt5_client_new - required on_stopped event handler is null"); - goto cleanup; + goto post_ref_error; } if (s_init_event_handler_threadsafe_function( env, on_stopped_event_handler, "aws_mqtt5_client_on_stopped", s_napi_on_stopped, &binding->on_stopped)) { napi_throw_error(env, NULL, "mqtt5_client_new - failed to initialize on_stopped event handler"); - goto cleanup; + goto post_ref_error; } /* Arg #4: on attempting connect event */ napi_value on_attempting_connect_event_handler = *arg++; if (aws_napi_is_null_or_undefined(env, on_attempting_connect_event_handler)) { napi_throw_error(env, NULL, "mqtt5_client_new - required on_attempting_connect event handler is null"); - goto cleanup; + goto post_ref_error; } if (s_init_event_handler_threadsafe_function( @@ -2201,14 +2209,14 @@ napi_value aws_napi_mqtt5_client_new(napi_env env, napi_callback_info info) { s_napi_on_attempting_connect, &binding->on_attempting_connect)) { napi_throw_error(env, NULL, "mqtt5_client_new - failed to initialize on_attempting_connect event handler"); - goto cleanup; + goto post_ref_error; } /* Arg #5: on connection success event */ napi_value on_connection_success_event_handler = *arg++; if (aws_napi_is_null_or_undefined(env, on_connection_success_event_handler)) { napi_throw_error(env, NULL, "mqtt5_client_new - required on_connection_success event handler is null"); - goto cleanup; + goto post_ref_error; } if (s_init_event_handler_threadsafe_function( @@ -2218,14 +2226,14 @@ napi_value aws_napi_mqtt5_client_new(napi_env env, napi_callback_info info) { s_napi_on_connection_success, &binding->on_connection_success)) { napi_throw_error(env, NULL, "mqtt5_client_new - failed to initialize on_connection_success event handler"); - goto cleanup; + goto post_ref_error; } /* Arg #6: on connection failure event */ napi_value on_connection_failure_event_handler = *arg++; if (aws_napi_is_null_or_undefined(env, on_connection_failure_event_handler)) { napi_throw_error(env, NULL, "mqtt5_client_new - required on_connection_failure event handler is null"); - goto cleanup; + goto post_ref_error; } if (s_init_event_handler_threadsafe_function( @@ -2235,14 +2243,14 @@ napi_value aws_napi_mqtt5_client_new(napi_env env, napi_callback_info info) { s_napi_on_connection_failure, &binding->on_connection_failure)) { napi_throw_error(env, NULL, "mqtt5_client_new - failed to initialize on_connection_failure event handler"); - goto cleanup; + goto post_ref_error; } /* Arg #7: on disconnection event */ napi_value on_disconnection_event_handler = *arg++; if (aws_napi_is_null_or_undefined(env, on_disconnection_event_handler)) { napi_throw_error(env, NULL, "mqtt5_client_new - required on_disconnection event handler is null"); - goto cleanup; + goto post_ref_error; } if (s_init_event_handler_threadsafe_function( @@ -2252,14 +2260,14 @@ napi_value aws_napi_mqtt5_client_new(napi_env env, napi_callback_info info) { s_napi_on_disconnection, &binding->on_disconnection)) { napi_throw_error(env, NULL, "mqtt5_client_new - failed to initialize on_disconnection event handler"); - goto cleanup; + goto post_ref_error; } /* Arg #8: on message received event */ napi_value on_message_received_event_handler = *arg++; if (aws_napi_is_null_or_undefined(env, on_message_received_event_handler)) { napi_throw_error(env, NULL, "mqtt5_client_new - required on_message_received event handler is null"); - goto cleanup; + goto post_ref_error; } if (s_init_event_handler_threadsafe_function( @@ -2269,7 +2277,7 @@ napi_value aws_napi_mqtt5_client_new(napi_env env, napi_callback_info info) { s_napi_on_message_received, &binding->on_message_received)) { napi_throw_error(env, NULL, "mqtt5_client_new - failed to initialize on_message_received event handler"); - goto cleanup; + goto post_ref_error; } /* Arg #9: client bootstrap */ @@ -2290,7 +2298,7 @@ napi_value aws_napi_mqtt5_client_new(napi_env env, napi_callback_info info) { if (!aws_napi_is_null_or_undefined(env, node_socket_options)) { AWS_NAPI_CALL(env, napi_get_value_external(env, node_socket_options, (void **)&client_options.socket_options), { napi_throw_error(env, NULL, "mqtt5_client_new - Unable to extract socket_options from external"); - goto cleanup; + goto post_ref_error; }); } @@ -2300,7 +2308,7 @@ napi_value aws_napi_mqtt5_client_new(napi_env env, napi_callback_info info) { struct aws_tls_ctx *tls_ctx; AWS_NAPI_CALL(env, napi_get_value_external(env, node_tls, (void **)&tls_ctx), { napi_throw_error(env, NULL, "mqtt5_client_new - Failed to extract tls_ctx from external"); - goto cleanup; + goto post_ref_error; }); aws_tls_connection_options_init_from_ctx(&binding->tls_connection_options, tls_ctx); @@ -2314,7 +2322,7 @@ napi_value aws_napi_mqtt5_client_new(napi_env env, napi_callback_info info) { struct http_proxy_options_binding *proxy_binding = NULL; AWS_NAPI_CALL(env, napi_get_value_external(env, node_proxy_options, (void **)&proxy_binding), { napi_throw_type_error(env, NULL, "mqtt5_client_new - failed to extract http proxy options from external"); - goto cleanup; + goto post_ref_error; }); /* proxy_options are copied internally, no need to go nuts on copies */ client_options.http_proxy_options = aws_napi_get_http_proxy_options(proxy_binding); @@ -2332,16 +2340,25 @@ napi_value aws_napi_mqtt5_client_new(napi_env env, napi_callback_info info) { binding->client = aws_mqtt5_client_new(allocator, &client_options); if (binding->client == NULL) { aws_napi_throw_last_error_with_context(env, "mqtt5_client_new - failed to create client"); - goto cleanup; + goto post_ref_error; } AWS_NAPI_CALL(env, napi_create_reference(env, node_external, 1, &binding->node_client_external_ref), { napi_throw_error(env, NULL, "mqtt5_client_new - Failed to create one count reference to napi external"); - goto cleanup; + goto post_ref_error; }); napi_client_wrapper = node_external; + goto cleanup; + +post_ref_error: + + if (binding->node_mqtt5_client_ref != NULL) { + napi_delete_reference(env, binding->node_mqtt5_client_ref); + binding->node_mqtt5_client_ref = NULL; + } + cleanup: s_aws_napi_mqtt5_client_creation_storage_clean_up(&options_storage); diff --git a/source/mqtt5_client.h b/source/mqtt5_client.h index ca0f7567d..a90245ebd 100644 --- a/source/mqtt5_client.h +++ b/source/mqtt5_client.h @@ -8,6 +8,8 @@ #include "module.h" +struct aws_mqtt5_client_binding; + napi_value aws_napi_mqtt5_client_new(napi_env env, napi_callback_info info); napi_value aws_napi_mqtt5_client_start(napi_env env, napi_callback_info info); @@ -24,4 +26,6 @@ napi_value aws_napi_mqtt5_client_get_queue_statistics(napi_env env, napi_callbac napi_value aws_napi_mqtt5_client_close(napi_env env, napi_callback_info info); +struct aws_mqtt5_client *aws_napi_get_mqtt5_client_from_binding(struct aws_mqtt5_client_binding *binding); + #endif /* AWS_CRT_NODEJS_MQTT5_CLIENT_H */ diff --git a/source/mqtt_client_connection.c b/source/mqtt_client_connection.c index 4f891aee8..2bd446d01 100644 --- a/source/mqtt_client_connection.c +++ b/source/mqtt_client_connection.c @@ -50,6 +50,15 @@ struct mqtt_connection_binding { bool first_successfull_connection; }; +struct aws_mqtt_client_connection *aws_napi_get_mqtt_client_connection_from_binding( + struct mqtt_connection_binding *binding) { + if (binding == NULL) { + return NULL; + } + + return binding->connection; +} + static void s_mqtt_client_connection_release_threadsafe_function_on_failure(struct mqtt_connection_binding *binding) { if (binding->on_connection_failure != NULL) { diff --git a/source/mqtt_client_connection.h b/source/mqtt_client_connection.h index 0a942eff7..346ea37e3 100644 --- a/source/mqtt_client_connection.h +++ b/source/mqtt_client_connection.h @@ -7,6 +7,9 @@ #include "module.h" +struct mqtt_connection_binding; +struct aws_mqtt_client_connection; + napi_value aws_napi_mqtt_client_connection_new(napi_env env, napi_callback_info info); napi_value aws_napi_mqtt_client_connection_close(napi_env env, napi_callback_info info); napi_value aws_napi_mqtt_client_connection_connect(napi_env env, napi_callback_info info); @@ -19,4 +22,7 @@ napi_value aws_napi_mqtt_client_connection_unsubscribe(napi_env env, napi_callba napi_value aws_napi_mqtt_client_connection_disconnect(napi_env env, napi_callback_info info); napi_value aws_napi_mqtt_client_connection_get_queue_statistics(napi_env env, napi_callback_info info); +struct aws_mqtt_client_connection *aws_napi_get_mqtt_client_connection_from_binding( + struct mqtt_connection_binding *binding); + #endif /* AWS_CRT_NODEJS_MQTT_CLIENT_CONNECTION_H */ diff --git a/source/mqtt_request_response.c b/source/mqtt_request_response.c new file mode 100644 index 000000000..85a6ccf42 --- /dev/null +++ b/source/mqtt_request_response.c @@ -0,0 +1,1772 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +#include "mqtt_request_response.h" + +#include "mqtt5_client.h" +#include "mqtt_client_connection.h" + +#include +#include + +static const char *AWS_NAPI_KEY_MAX_REQUEST_RESPONSE_SUBSCRIPTIONS = "maxRequestResponseSubscriptions"; +static const char *AWS_NAPI_KEY_MAX_STREAMING_SUBSCRIPTIONS = "maxStreamingSubscriptions"; +static const char *AWS_NAPI_KEY_OPERATION_TIMEOUT_IN_SECONDS = "operationTimeoutInSeconds"; +static const char *AWS_NAPI_KEY_SUBSCRIPTION_TOPIC_FILTERS = "subscriptionTopicFilters"; +static const char *AWS_NAPI_KEY_RESPONSE_PATHS = "responsePaths"; +static const char *AWS_NAPI_KEY_PUBLISH_TOPIC = "publishTopic"; +static const char *AWS_NAPI_KEY_PAYLOAD = "payload"; +static const char *AWS_NAPI_KEY_CORRELATION_TOKEN = "correlationToken"; +static const char *AWS_NAPI_KEY_TOPIC = "topic"; +static const char *AWS_NAPI_KEY_CORRELATION_TOKEN_JSON_PATH = "correlationTokenJsonPath"; +static const char *AWS_NAPI_KEY_SUBSCRIPTION_TOPIC_FILTER = "subscriptionTopicFilter"; + +struct aws_mqtt_request_response_client_binding { + struct aws_allocator *allocator; + + /* reference holding */ + struct aws_mqtt_request_response_client *client; + + /* + * Single count ref to the JS mqtt request response client object. + */ + napi_ref node_mqtt_request_response_client_ref; + + /* + * Single count ref to the node external associated with the JS client. + */ + napi_ref node_client_external_ref; +}; + +/* + * Invoked when the JS request-response client is garbage collected or if fails construction partway through + */ +static void s_aws_mqtt_request_response_client_extern_finalize(napi_env env, void *finalize_data, void *finalize_hint) { + (void)finalize_hint; + (void)env; + + struct aws_mqtt_request_response_client_binding *binding = finalize_data; + + AWS_LOGF_INFO( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_aws_mqtt_request_response_client_extern_finalize - node wrapper is being finalized", + (void *)binding->client); + + if (binding->client != NULL) { + /* + * If client is not null, then this is a successfully constructed client which should shutdown normally. + * The client doesn't call us back for any reason and we aren't waiting on the termination callback. + */ + aws_mqtt_request_response_client_release(binding->client); + binding->client = NULL; + } + + /* + * The client itself has very simple lifetime semantics. There are no callbacks, events, or asynchronous + * operations that route values through the client. As soon as the extern is destroyed we can delete + * everything, including the binding itself. + */ + aws_mem_release(binding->allocator, binding); +} + +static int s_aws_init_request_response_options_from_napi_value( + struct aws_mqtt_request_response_client_options *options, + napi_env env, + napi_value node_options, + void *log_handle) { + + uint32_t max_request_response_subscriptions = 0; + EXTRACT_REQUIRED_NAPI_PROPERTY( + AWS_NAPI_KEY_MAX_REQUEST_RESPONSE_SUBSCRIPTIONS, + "s_aws_init_request_response_options_from_napi_value", + aws_napi_get_named_property_as_uint32( + env, node_options, AWS_NAPI_KEY_MAX_REQUEST_RESPONSE_SUBSCRIPTIONS, &max_request_response_subscriptions), + {}, + log_handle); + + uint32_t max_streaming_subscriptions = 0; + EXTRACT_REQUIRED_NAPI_PROPERTY( + AWS_NAPI_KEY_MAX_STREAMING_SUBSCRIPTIONS, + "s_aws_init_request_response_options_from_napi_value", + aws_napi_get_named_property_as_uint32( + env, node_options, AWS_NAPI_KEY_MAX_STREAMING_SUBSCRIPTIONS, &max_streaming_subscriptions), + {}, + log_handle); + + EXTRACT_REQUIRED_NAPI_PROPERTY( + AWS_NAPI_KEY_OPERATION_TIMEOUT_IN_SECONDS, + "s_aws_init_request_response_options_from_napi_value", + aws_napi_get_named_property_as_uint32( + env, node_options, AWS_NAPI_KEY_OPERATION_TIMEOUT_IN_SECONDS, &options->operation_timeout_seconds), + {}, + log_handle); + + options->max_request_response_subscriptions = (size_t)max_request_response_subscriptions; + options->max_streaming_subscriptions = (size_t)max_streaming_subscriptions; + + return AWS_OP_SUCCESS; +} + +napi_value aws_napi_mqtt_request_response_client_new_from_5(napi_env env, napi_callback_info info) { + napi_value node_args[3]; + size_t num_args = AWS_ARRAY_SIZE(node_args); + napi_value *arg = &node_args[0]; + AWS_NAPI_CALL(env, napi_get_cb_info(env, info, &num_args, node_args, NULL, NULL), { + napi_throw_error(env, NULL, "aws_napi_request_mqtt_response_client_new_from_5 - Failed to retrieve arguments"); + return NULL; + }); + + if (num_args != AWS_ARRAY_SIZE(node_args)) { + napi_throw_error(env, NULL, "aws_napi_request_mqtt_response_client_new_from_5 - needs exactly 3 arguments"); + return NULL; + } + + napi_value napi_client_wrapper = NULL; + napi_value node_external = NULL; + struct aws_allocator *allocator = aws_napi_get_allocator(); + + struct aws_mqtt_request_response_client_binding *binding = + aws_mem_calloc(allocator, 1, sizeof(struct aws_mqtt_request_response_client_binding)); + binding->allocator = allocator; + + AWS_NAPI_CALL( + env, + napi_create_external(env, binding, s_aws_mqtt_request_response_client_extern_finalize, NULL, &node_external), + { + aws_mem_release(allocator, binding); + napi_throw_error( + env, NULL, "aws_napi_request_mqtt_response_client_new_from_5 - Failed to create n-api external"); + goto done; + }); + + /* Arg #1: the request response client */ + napi_value node_rr_client = *arg++; + if (aws_napi_is_null_or_undefined(env, node_rr_client)) { + napi_throw_error( + env, NULL, "aws_napi_request_mqtt_response_client_new_from_5 - Required client parameter is null"); + goto done; + } + + AWS_NAPI_CALL(env, napi_create_reference(env, node_rr_client, 1, &binding->node_mqtt_request_response_client_ref), { + napi_throw_error( + env, + NULL, + "aws_napi_request_mqtt_response_client_new_from_5 - Failed to create reference to node request response " + "client"); + goto done; + }); + + /* Arg #2: mqtt5 client native handle */ + struct aws_mqtt5_client *protocol_client = NULL; + napi_value node_mqtt5_client_handle = *arg++; + if (aws_napi_is_null_or_undefined(env, node_mqtt5_client_handle)) { + napi_throw_error(env, NULL, "aws_napi_request_mqtt_response_client_new_from_5 - invalid protocol client"); + goto done; + } + + struct aws_mqtt5_client_binding *mqtt5_client_binding = NULL; + napi_get_value_external(env, node_mqtt5_client_handle, (void **)&mqtt5_client_binding); + + protocol_client = aws_napi_get_mqtt5_client_from_binding(mqtt5_client_binding); + if (protocol_client == NULL) { + napi_throw_error( + env, NULL, "aws_napi_request_mqtt_response_client_new_from_5 - could not extract native protocol client"); + goto done; + } + + /* Arg #3: the request response client config object */ + napi_value node_client_config = *arg++; + if (aws_napi_is_null_or_undefined(env, node_client_config)) { + napi_throw_error( + env, NULL, "aws_napi_request_mqtt_response_client_new_from_5 - required configuration parameter is null"); + goto done; + } + + struct aws_mqtt_request_response_client_options client_options; + AWS_ZERO_STRUCT(client_options); + + if (s_aws_init_request_response_options_from_napi_value(&client_options, env, node_client_config, NULL)) { + napi_throw_error(env, NULL, "aws_napi_request_mqtt_response_client_new_from_5 - invalid configuration options"); + goto done; + } + + binding->client = + aws_mqtt_request_response_client_new_from_mqtt5_client(allocator, protocol_client, &client_options); + if (binding->client == NULL) { + aws_napi_throw_last_error_with_context( + env, "aws_napi_request_mqtt_response_client_new_from_5 - failed to create client"); + goto done; + } + + AWS_NAPI_CALL(env, napi_create_reference(env, node_external, 1, &binding->node_client_external_ref), { + napi_throw_error( + env, + NULL, + "aws_napi_request_mqtt_response_client_new_from_5 - Failed to create one count reference to napi external"); + goto done; + }); + + napi_client_wrapper = node_external; + +done: + + return napi_client_wrapper; +} + +napi_value aws_napi_mqtt_request_response_client_new_from_311(napi_env env, napi_callback_info info) { + napi_value node_args[3]; + size_t num_args = AWS_ARRAY_SIZE(node_args); + napi_value *arg = &node_args[0]; + AWS_NAPI_CALL(env, napi_get_cb_info(env, info, &num_args, node_args, NULL, NULL), { + napi_throw_error( + env, NULL, "aws_napi_mqtt_request_response_client_new_from_311 - Failed to retrieve arguments"); + return NULL; + }); + + if (num_args != AWS_ARRAY_SIZE(node_args)) { + napi_throw_error(env, NULL, "aws_napi_mqtt_request_response_client_new_from_311 - needs exactly 3 arguments"); + return NULL; + } + + napi_value napi_client_wrapper = NULL; + napi_value node_external = NULL; + struct aws_allocator *allocator = aws_napi_get_allocator(); + + struct aws_mqtt_request_response_client_binding *binding = + aws_mem_calloc(allocator, 1, sizeof(struct aws_mqtt_request_response_client_binding)); + binding->allocator = allocator; + + AWS_NAPI_CALL( + env, + napi_create_external(env, binding, s_aws_mqtt_request_response_client_extern_finalize, NULL, &node_external), + { + aws_mem_release(allocator, binding); + napi_throw_error( + env, NULL, "aws_napi_mqtt_request_response_client_new_from_311 - Failed to create n-api external"); + goto done; + }); + + /* Arg #1: the request response client */ + napi_value node_rr_client = *arg++; + if (aws_napi_is_null_or_undefined(env, node_rr_client)) { + napi_throw_error( + env, NULL, "aws_napi_mqtt_request_response_client_new_from_311 - Required client parameter is null"); + goto done; + } + + AWS_NAPI_CALL(env, napi_create_reference(env, node_rr_client, 1, &binding->node_mqtt_request_response_client_ref), { + napi_throw_error( + env, + NULL, + "aws_napi_mqtt_request_response_client_new_from_311 - Failed to create reference to node request response " + "client"); + goto done; + }); + + /* Arg #2: mqtt311 client native handle */ + struct aws_mqtt_client_connection *protocol_client = NULL; + napi_value node_mqtt_client_connection_handle = *arg++; + if (aws_napi_is_null_or_undefined(env, node_mqtt_client_connection_handle)) { + napi_throw_error(env, NULL, "aws_napi_mqtt_request_response_client_new_from_311 - invalid protocol client"); + goto done; + } + + struct mqtt_connection_binding *mqtt_client_connection_binding = NULL; + napi_get_value_external(env, node_mqtt_client_connection_handle, (void **)&mqtt_client_connection_binding); + + protocol_client = aws_napi_get_mqtt_client_connection_from_binding(mqtt_client_connection_binding); + if (protocol_client == NULL) { + napi_throw_error( + env, NULL, "aws_napi_mqtt_request_response_client_new_from_311 - could not extract native protocol client"); + goto done; + } + + /* Arg #3: the request response client config object */ + napi_value node_client_config = *arg++; + if (aws_napi_is_null_or_undefined(env, node_client_config)) { + napi_throw_error( + env, NULL, "aws_napi_mqtt_request_response_client_new_from_311 - required configuration parameter is null"); + goto done; + } + + struct aws_mqtt_request_response_client_options client_options; + AWS_ZERO_STRUCT(client_options); + + if (s_aws_init_request_response_options_from_napi_value(&client_options, env, node_client_config, NULL)) { + napi_throw_error( + env, NULL, "aws_napi_mqtt_request_response_client_new_from_311 - invalid configuration options"); + goto done; + } + + binding->client = + aws_mqtt_request_response_client_new_from_mqtt311_client(allocator, protocol_client, &client_options); + if (binding->client == NULL) { + aws_napi_throw_last_error_with_context( + env, "aws_napi_mqtt_request_response_client_new_from_311 - failed to create client"); + goto done; + } + + AWS_NAPI_CALL(env, napi_create_reference(env, node_external, 1, &binding->node_client_external_ref), { + napi_throw_error( + env, + NULL, + "aws_napi_mqtt_request_response_client_new_from_311 - Failed to create one count reference to napi " + "external"); + goto done; + }); + + napi_client_wrapper = node_external; + +done: + + return napi_client_wrapper; +} + +napi_value aws_napi_mqtt_request_response_client_close(napi_env env, napi_callback_info info) { + napi_value node_args[1]; + size_t num_args = AWS_ARRAY_SIZE(node_args); + napi_value *arg = &node_args[0]; + AWS_NAPI_CALL(env, napi_get_cb_info(env, info, &num_args, node_args, NULL, NULL), { + napi_throw_error(env, NULL, "aws_napi_mqtt_request_response_client_close - Failed to retrieve arguments"); + return NULL; + }); + + if (num_args != AWS_ARRAY_SIZE(node_args)) { + napi_throw_error(env, NULL, "aws_napi_mqtt_request_response_client_close - needs exactly 1 argument"); + return NULL; + } + + struct aws_mqtt_request_response_client_binding *binding = NULL; + napi_value node_binding = *arg++; + AWS_NAPI_CALL(env, napi_get_value_external(env, node_binding, (void **)&binding), { + napi_throw_error( + env, + NULL, + "aws_napi_mqtt_request_response_client_close - Failed to extract client binding from first argument"); + return NULL; + }); + + if (binding == NULL) { + napi_throw_error(env, NULL, "aws_napi_mqtt_request_response_client_close - binding was null"); + return NULL; + } + + if (binding->client == NULL) { + napi_throw_error(env, NULL, "aws_napi_mqtt_request_response_client_close - client was null"); + return NULL; + } + + napi_ref node_client_external_ref = binding->node_client_external_ref; + binding->node_client_external_ref = NULL; + + napi_ref node_mqtt_request_response_client_ref = binding->node_mqtt_request_response_client_ref; + binding->node_mqtt_request_response_client_ref = NULL; + + if (node_client_external_ref != NULL) { + napi_delete_reference(env, node_client_external_ref); + } + + if (node_mqtt_request_response_client_ref != NULL) { + napi_delete_reference(env, node_mqtt_request_response_client_ref); + } + + return NULL; +} + +/* + * request-response binding that lives from the time a request is made until the request has been completed + * on the libuv thread. + */ +struct aws_napi_mqtt_request_binding { + struct aws_allocator *allocator; + + napi_threadsafe_function on_completion; + + int error_code; + struct aws_byte_buf topic; + struct aws_byte_buf *payload; +}; + +static void s_aws_napi_mqtt_request_binding_destroy(struct aws_napi_mqtt_request_binding *binding) { + if (binding == NULL) { + return; + } + + AWS_CLEAN_THREADSAFE_FUNCTION(binding, on_completion); + + aws_byte_buf_clean_up(&binding->topic); + + /* + * Under normal circumstances the payload is attached to an external and nulled out in the binding. This + * handles the case where something goes wrong with the threadsafe function invoke, forcing us to clean up the + * payload ourselves. + */ + if (binding->payload) { + aws_byte_buf_clean_up(binding->payload); + aws_mem_release(binding->allocator, binding->payload); + } + + aws_mem_release(binding->allocator, binding); +} + +static void s_request_complete_external_arraybuffer_finalizer(napi_env env, void *finalize_data, void *finalize_hint) { + (void)env; + (void)finalize_data; + + struct aws_byte_buf *payload = finalize_hint; + struct aws_allocator *allocator = payload->allocator; + AWS_FATAL_ASSERT(allocator != NULL); + + aws_byte_buf_clean_up(payload); + aws_mem_release(allocator, payload); +} + +static void s_napi_on_request_complete(napi_env env, napi_value function, void *context, void *user_data) { + (void)user_data; + + struct aws_napi_mqtt_request_binding *binding = context; + + if (env) { + napi_value params[3]; + const size_t num_params = AWS_ARRAY_SIZE(params); + + // Arg 1: the error code + AWS_NAPI_CALL(env, napi_create_uint32(env, binding->error_code, ¶ms[0]), { goto done; }); + + // Arg 2: the topic or null on an error + if (binding->topic.len > 0) { + struct aws_byte_cursor topic_cursor = aws_byte_cursor_from_buf(&binding->topic); + AWS_NAPI_CALL( + env, napi_create_string_utf8(env, (const char *)(topic_cursor.ptr), topic_cursor.len, ¶ms[1]), { + goto done; + }); + } else { + if (napi_get_null(env, ¶ms[1]) != napi_ok) { + AWS_LOGF_ERROR(AWS_LS_NODEJS_CRT_GENERAL, "s_napi_on_request_complete - could not get null napi value"); + goto done; + } + } + + // Arg 3: the payload or null on an error + if (binding->payload != NULL) { + AWS_NAPI_ENSURE( + env, + aws_napi_create_external_arraybuffer( + env, + binding->payload->buffer, + binding->payload->len, + s_request_complete_external_arraybuffer_finalizer, + binding->payload, + ¶ms[2])); + } else { + if (napi_get_null(env, ¶ms[2]) != napi_ok) { + AWS_LOGF_ERROR(AWS_LS_NODEJS_CRT_GENERAL, "s_napi_on_request_complete - could not get null napi value"); + goto done; + } + } + + /* + * If we reach here then the payload (if it exists) is now owned by the external arraybuffer value. + * Nulling the member here prevents a double-free from the extern finalizer and the binding destructor. + */ + binding->payload = NULL; + + AWS_NAPI_ENSURE( + env, + aws_napi_dispatch_threadsafe_function(env, binding->on_completion, NULL, function, num_params, params)); + } + +done: + + s_aws_napi_mqtt_request_binding_destroy(binding); +} + +static void s_on_request_complete( + const struct aws_byte_cursor *response_topic, + const struct aws_byte_cursor *payload, + int error_code, + void *user_data) { + + struct aws_napi_mqtt_request_binding *binding = user_data; + + if (error_code == AWS_ERROR_SUCCESS) { + AWS_FATAL_ASSERT(response_topic != NULL && payload != NULL); + + aws_byte_buf_init_copy_from_cursor(&binding->topic, binding->allocator, *response_topic); + + binding->payload = aws_mem_calloc(binding->allocator, 1, sizeof(struct aws_byte_buf)); + aws_byte_buf_init_copy_from_cursor(binding->payload, binding->allocator, *payload); + } else { + binding->error_code = error_code; + } + + AWS_NAPI_ENSURE(NULL, aws_napi_queue_threadsafe_function(binding->on_completion, binding)); +} + +/* + * Temporary storage of napi binary/string data needed for request submission. + */ +struct aws_mqtt_request_response_storage { + struct aws_mqtt_request_operation_options options; + + struct aws_array_list subscription_topic_filters; + struct aws_array_list response_paths; + + struct aws_byte_buf storage; +}; + +static void s_cleanup_request_storage(struct aws_mqtt_request_response_storage *storage) { + aws_array_list_clean_up(&storage->subscription_topic_filters); + aws_array_list_clean_up(&storage->response_paths); + + aws_byte_buf_clean_up(&storage->storage); +} + +/* + * We initialize storage in two phases. The first phase computes how much memory we need to allocate to stora all the + * data. This structure tracks those numbers. + */ +struct aws_mqtt_request_response_storage_properties { + size_t bytes_needed; + size_t subscription_topic_filter_count; + size_t response_path_count; +}; + +static int s_compute_request_response_storage_properties( + napi_env env, + napi_value options, + void *log_context, + struct aws_mqtt_request_response_storage_properties *storage_properties) { + AWS_ZERO_STRUCT(*storage_properties); + + // Step 1 - figure out how many subscription topic filters there are + napi_value node_subscription_topic_filters = NULL; + if (aws_napi_get_named_property( + env, options, AWS_NAPI_KEY_SUBSCRIPTION_TOPIC_FILTERS, napi_object, &node_subscription_topic_filters) != + AWS_NGNPR_VALID_VALUE) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - missing subscription topic filters", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + if (aws_napi_is_null_or_undefined(env, node_subscription_topic_filters)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - null subscription topic filters", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + uint32_t subscription_filter_count = 0; + AWS_NAPI_CALL(env, napi_get_array_length(env, node_subscription_topic_filters, &subscription_filter_count), { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - subscription topic filters is not an array", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + }); + + storage_properties->subscription_topic_filter_count = subscription_filter_count; + + // Step 2 - figure out how many response paths there are + napi_value node_response_paths = NULL; + if (aws_napi_get_named_property(env, options, AWS_NAPI_KEY_RESPONSE_PATHS, napi_object, &node_response_paths) != + AWS_NGNPR_VALID_VALUE) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - missing response paths", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + if (aws_napi_is_null_or_undefined(env, node_response_paths)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - null response paths", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + uint32_t response_path_count = 0; + AWS_NAPI_CALL(env, napi_get_array_length(env, node_response_paths, &response_path_count), { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - response paths is not an array", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + }); + + storage_properties->response_path_count = response_path_count; + + // Step 3 - Go through all the subscription topic filters, response paths, and options fields and add up + // the lengths of all the string and binary data fields. + for (size_t i = 0; i < subscription_filter_count; ++i) { + napi_value array_element; + AWS_NAPI_CALL(env, napi_get_element(env, node_subscription_topic_filters, i, &array_element), { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - failed to get subscription topic filter entry", + log_context); + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + + size_t filter_length = 0; + if (aws_napi_value_get_storage_length(env, array_element, &filter_length)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - failed to get subscription topic filter length", + log_context); + return AWS_OP_ERR; + }; + + storage_properties->bytes_needed += filter_length; + } + + for (size_t i = 0; i < response_path_count; ++i) { + napi_value array_element; + AWS_NAPI_CALL(env, napi_get_element(env, node_response_paths, i, &array_element), { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - failed to get response path entry", + log_context); + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + + napi_value node_topic; + if (aws_napi_get_named_property(env, array_element, AWS_NAPI_KEY_TOPIC, napi_string, &node_topic) != + AWS_NGNPR_VALID_VALUE) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - failed to get response path topic", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + size_t topic_length = 0; + if (aws_napi_value_get_storage_length(env, node_topic, &topic_length)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - failed compute response path topic length", + log_context); + return AWS_OP_ERR; + } + + storage_properties->bytes_needed += topic_length; + + napi_value node_correlation_token_json_path; + enum aws_napi_get_named_property_result gpr = aws_napi_get_named_property( + env, + array_element, + AWS_NAPI_KEY_CORRELATION_TOKEN_JSON_PATH, + napi_string, + &node_correlation_token_json_path); + if (gpr != AWS_NGNPR_NO_VALUE) { + size_t json_path_length = 0; + if (gpr == AWS_NGNPR_INVALID_VALUE) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - response path correlation " + "token json path has invalid type", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + if (aws_napi_value_get_storage_length(env, node_correlation_token_json_path, &json_path_length)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - failed to compute response path correlation " + "token json path length", + log_context); + return AWS_OP_ERR; + } + + storage_properties->bytes_needed += json_path_length; + } + } + + napi_value node_publish_topic; + if (aws_napi_get_named_property(env, options, AWS_NAPI_KEY_PUBLISH_TOPIC, napi_string, &node_publish_topic) != + AWS_NGNPR_VALID_VALUE) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - failed to get publish topic", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + size_t publish_topic_length = 0; + if (aws_napi_value_get_storage_length(env, node_publish_topic, &publish_topic_length)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - failed to compute publish topic length", + log_context); + return AWS_OP_ERR; + } + + storage_properties->bytes_needed += publish_topic_length; + + napi_value node_payload; + if (aws_napi_get_named_property(env, options, AWS_NAPI_KEY_PAYLOAD, napi_undefined, &node_payload) != + AWS_NGNPR_VALID_VALUE) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - failed to get payload", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + size_t payload_length = 0; + if (aws_napi_value_get_storage_length(env, node_payload, &payload_length)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - failed to compute payload length", + log_context); + return AWS_OP_ERR; + } + + storage_properties->bytes_needed += payload_length; + + napi_value node_correlation_token; + enum aws_napi_get_named_property_result ct_gpr = + aws_napi_get_named_property(env, options, AWS_NAPI_KEY_CORRELATION_TOKEN, napi_string, &node_correlation_token); + if (ct_gpr != AWS_NGNPR_NO_VALUE) { + size_t correlation_token_length = 0; + if (ct_gpr == AWS_NGNPR_INVALID_VALUE) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - invalid correlation token", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + if (aws_napi_value_get_storage_length(env, node_correlation_token, &correlation_token_length)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_request_response_storage_properties - failed to compute correlation token length", + log_context); + return AWS_OP_ERR; + } + + storage_properties->bytes_needed += correlation_token_length; + } + + /* extracting a string value ends up writing the null terminator, so add sufficient padding */ + storage_properties->bytes_needed += 1; + + return AWS_OP_SUCCESS; +} + +static int s_initialize_request_storage_from_napi_options( + struct aws_mqtt_request_response_storage *storage, + napi_env env, + napi_value options, + void *log_context) { + struct aws_allocator *allocator = aws_napi_get_allocator(); + + struct aws_mqtt_request_response_storage_properties storage_properties; + AWS_ZERO_STRUCT(storage_properties); + + if (s_compute_request_response_storage_properties(env, options, log_context, &storage_properties)) { + // all failure paths in that function log the reason for failure already + return AWS_OP_ERR; + } + + if (storage_properties.subscription_topic_filter_count == 0) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - empty subscription topic filters array", + (void *)log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + if (storage_properties.response_path_count == 0) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - empty response paths array", + (void *)log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + aws_byte_buf_init(&storage->storage, allocator, storage_properties.bytes_needed); + aws_array_list_init_dynamic( + &storage->subscription_topic_filters, + allocator, + storage_properties.subscription_topic_filter_count, + sizeof(struct aws_byte_cursor)); + aws_array_list_init_dynamic( + &storage->response_paths, + allocator, + storage_properties.response_path_count, + sizeof(struct aws_mqtt_request_operation_response_path)); + + napi_value node_subscription_topic_filters = NULL; + if (aws_napi_get_named_property( + env, options, AWS_NAPI_KEY_SUBSCRIPTION_TOPIC_FILTERS, napi_object, &node_subscription_topic_filters) != + AWS_NGNPR_VALID_VALUE) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - missing subscription topic filters", + (void *)log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + for (size_t i = 0; i < storage_properties.subscription_topic_filter_count; ++i) { + napi_value array_element; + AWS_NAPI_CALL(env, napi_get_element(env, node_subscription_topic_filters, i, &array_element), { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - failed to get subscription topic filter " + "element", + (void *)log_context); + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + + struct aws_byte_cursor bytes_written; + if (aws_napi_value_bytebuf_append(env, array_element, &storage->storage, &bytes_written)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - failed to append subscription topic filter", + (void *)log_context); + return AWS_OP_ERR; + } + + aws_array_list_push_back(&storage->subscription_topic_filters, &bytes_written); + } + + storage->options.subscription_topic_filters = storage->subscription_topic_filters.data; + storage->options.subscription_topic_filter_count = storage_properties.subscription_topic_filter_count; + + napi_value node_response_paths = NULL; + if (aws_napi_get_named_property(env, options, AWS_NAPI_KEY_RESPONSE_PATHS, napi_object, &node_response_paths) != + AWS_NGNPR_VALID_VALUE) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - missing response paths", + (void *)log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + for (size_t i = 0; i < storage_properties.response_path_count; ++i) { + napi_value response_path_element; + AWS_NAPI_CALL(env, napi_get_element(env, node_response_paths, i, &response_path_element), { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - failed to get response path element", + (void *)log_context); + return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); + }); + + struct aws_mqtt_request_operation_response_path response_path; + AWS_ZERO_STRUCT(response_path); + + napi_value node_topic; + if (aws_napi_get_named_property(env, response_path_element, AWS_NAPI_KEY_TOPIC, napi_string, &node_topic) != + AWS_NGNPR_VALID_VALUE) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - failed to get response path topic", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + if (aws_napi_value_bytebuf_append(env, node_topic, &storage->storage, &response_path.topic)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - failed to append response path topic", + log_context); + return AWS_OP_ERR; + } + + napi_value node_correlation_token_json_path; + if (aws_napi_get_named_property( + env, + response_path_element, + AWS_NAPI_KEY_CORRELATION_TOKEN_JSON_PATH, + napi_string, + &node_correlation_token_json_path) == AWS_NGNPR_VALID_VALUE) { + if (aws_napi_value_bytebuf_append( + env, + node_correlation_token_json_path, + &storage->storage, + &response_path.correlation_token_json_path)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - failed to append response path correlation " + "token json path", + log_context); + return AWS_OP_ERR; + } + } + + aws_array_list_push_back(&storage->response_paths, &response_path); + } + + storage->options.response_paths = storage->response_paths.data; + storage->options.response_path_count = storage_properties.response_path_count; + + napi_value node_publish_topic; + if (aws_napi_get_named_property(env, options, AWS_NAPI_KEY_PUBLISH_TOPIC, napi_string, &node_publish_topic) != + AWS_NGNPR_VALID_VALUE) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - failed to get publish topic", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + if (aws_napi_value_bytebuf_append(env, node_publish_topic, &storage->storage, &storage->options.publish_topic)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - failed append publish topic", + log_context); + return AWS_OP_ERR; + } + + napi_value node_payload; + if (aws_napi_get_named_property(env, options, AWS_NAPI_KEY_PAYLOAD, napi_undefined, &node_payload) != + AWS_NGNPR_VALID_VALUE) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - failed to get payload", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + if (aws_napi_value_bytebuf_append(env, node_payload, &storage->storage, &storage->options.serialized_request)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - failed append payload", + log_context); + return AWS_OP_ERR; + } + + napi_value node_correlation_token; + if (aws_napi_get_named_property( + env, options, AWS_NAPI_KEY_CORRELATION_TOKEN, napi_string, &node_correlation_token) == + AWS_NGNPR_VALID_VALUE) { + if (aws_napi_value_bytebuf_append( + env, node_correlation_token, &storage->storage, &storage->options.correlation_token)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_request_storage_from_napi_options - failed to append correlation token", + log_context); + return AWS_OP_ERR; + } + } + + AWS_FATAL_ASSERT(storage->storage.capacity == storage->storage.len + 1); + + return AWS_OP_SUCCESS; +} + +napi_value aws_napi_mqtt_request_response_client_submit_request(napi_env env, napi_callback_info info) { + struct aws_allocator *allocator = aws_napi_get_allocator(); + + int result = AWS_OP_ERR; + + struct aws_mqtt_request_response_storage request_storage; + AWS_ZERO_STRUCT(request_storage); + + struct aws_napi_mqtt_request_binding *request_binding = + aws_mem_calloc(allocator, 1, sizeof(struct aws_napi_mqtt_request_binding)); + request_binding->allocator = allocator; + + napi_value node_args[3]; + size_t num_args = AWS_ARRAY_SIZE(node_args); + AWS_NAPI_CALL(env, napi_get_cb_info(env, info, &num_args, node_args, NULL, NULL), { + napi_throw_error( + env, + NULL, + "aws_napi_mqtt_request_response_client_submit_request - failed to retrieve callback information"); + goto done; + }); + + if (num_args != AWS_ARRAY_SIZE(node_args)) { + napi_throw_error(env, NULL, "aws_napi_mqtt_request_response_client_submit_request - needs exactly 3 arguments"); + goto done; + } + + napi_value *arg = &node_args[0]; + napi_value node_binding = *arg++; + struct aws_mqtt_request_response_client_binding *client_binding = NULL; + AWS_NAPI_CALL(env, napi_get_value_external(env, node_binding, (void **)&client_binding), { + napi_throw_error( + env, + NULL, + "aws_napi_mqtt_request_response_client_submit_request - failed to extract binding from external"); + goto done; + }); + + napi_value node_options = *arg++; + if (s_initialize_request_storage_from_napi_options(&request_storage, env, node_options, client_binding->client)) { + napi_throw_error(env, NULL, "aws_napi_mqtt_request_response_client_submit_request - invalid request options"); + goto done; + } + + napi_value node_on_completion = *arg++; + if (!aws_napi_is_null_or_undefined(env, node_on_completion)) { + AWS_NAPI_CALL( + env, + aws_napi_create_threadsafe_function( + env, + node_on_completion, + "aws_mqtt_request_response_client_on_completion", + s_napi_on_request_complete, + request_binding, + &request_binding->on_completion), + { + napi_throw_error( + env, + NULL, + "aws_napi_mqtt_request_response_client_submit_request - failed to create completion callback"); + goto done; + }); + } else { + napi_throw_error( + env, NULL, "aws_napi_mqtt_request_response_client_submit_request - invalid completion callback"); + goto done; + } + + request_storage.options.completion_callback = s_on_request_complete; + request_storage.options.user_data = request_binding; + + result = aws_mqtt_request_response_client_submit_request(client_binding->client, &request_storage.options); + if (result == AWS_OP_ERR) { + napi_throw_error( + env, + NULL, + "aws_napi_mqtt_request_response_client_submit_request - failure invoking native client submit_request"); + } + +done: + + s_cleanup_request_storage(&request_storage); + + if (result == AWS_OP_ERR) { + s_aws_napi_mqtt_request_binding_destroy(request_binding); + } + + return NULL; +} + +/////////////////////////////////////////////////////////////////////////////////////////// + +struct aws_request_response_streaming_operation_binding { + struct aws_allocator *allocator; + + /* + * May only be accessed from within the libuv thread. + */ + struct aws_mqtt_rr_client_operation *streaming_operation; + + /* + * +1 from successful new -> termination callback + * +1 for every in-flight callback from client event loop thread to lib uv thread + */ + struct aws_ref_count ref_count; + + /* + * Single count ref to the JS streaming operation object. + */ + napi_ref node_streaming_operation_ref; + + /* + * Single count ref to the node external managed by the binding. + */ + napi_ref node_streaming_operation_external_ref; + + napi_threadsafe_function on_subscription_status_changed; + napi_threadsafe_function on_incoming_publish; + + bool is_closed; +}; + +static void s_aws_request_response_streaming_operation_binding_on_zero(void *context) { + if (context == NULL) { + return; + } + + struct aws_request_response_streaming_operation_binding *binding = context; + + AWS_CLEAN_THREADSAFE_FUNCTION(binding, on_subscription_status_changed); + AWS_CLEAN_THREADSAFE_FUNCTION(binding, on_incoming_publish); + + aws_mem_release(binding->allocator, binding); +} + +static struct aws_request_response_streaming_operation_binding * + s_aws_request_response_streaming_operation_binding_acquire( + struct aws_request_response_streaming_operation_binding *binding) { + if (binding != NULL) { + aws_ref_count_acquire(&binding->ref_count); + } + + return binding; +} + +static struct aws_request_response_streaming_operation_binding * + s_aws_request_response_streaming_operation_binding_release( + struct aws_request_response_streaming_operation_binding *binding) { + if (binding != NULL) { + aws_ref_count_release(&binding->ref_count); + } + + return NULL; +} + +static void s_streaming_operation_close( + struct aws_request_response_streaming_operation_binding *binding, + napi_env env) { + if (binding == NULL) { + return; + } + + binding->is_closed = true; + + napi_ref node_streaming_operation_external_ref = binding->node_streaming_operation_external_ref; + binding->node_streaming_operation_external_ref = NULL; + + napi_ref node_streaming_operation_ref = binding->node_streaming_operation_ref; + binding->node_streaming_operation_ref = NULL; + + if (node_streaming_operation_external_ref != NULL) { + napi_delete_reference(env, node_streaming_operation_external_ref); + } + + if (node_streaming_operation_ref != NULL) { + napi_delete_reference(env, node_streaming_operation_ref); + } + + aws_mqtt_rr_client_operation_release(binding->streaming_operation); + binding->streaming_operation = NULL; +} + +static void s_aws_mqtt_request_response_streaming_operation_extern_finalize( + napi_env env, + void *finalize_data, + void *finalize_hint) { + (void)finalize_hint; + (void)env; + + struct aws_request_response_streaming_operation_binding *binding = finalize_data; + + AWS_LOGF_INFO( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_aws_mqtt_request_response_streaming_operation_extern_finalize - node wrapper is being finalized", + (void *)binding->streaming_operation); + + s_aws_request_response_streaming_operation_binding_release(binding); +} + +struct on_subscription_status_changed_user_data { + struct aws_allocator *allocator; + + struct aws_request_response_streaming_operation_binding *binding_ref; + + enum aws_rr_streaming_subscription_event_type status; + int error_code; +}; + +static void s_on_subscription_status_changed_user_data_destroy( + struct on_subscription_status_changed_user_data *user_data) { + if (user_data == NULL) { + return; + } + + user_data->binding_ref = s_aws_request_response_streaming_operation_binding_release(user_data->binding_ref); + + aws_mem_release(user_data->allocator, user_data); +} + +static struct on_subscription_status_changed_user_data *s_on_subscription_status_changed_user_data_new( + struct aws_request_response_streaming_operation_binding *binding, + enum aws_rr_streaming_subscription_event_type status, + int error_code) { + + struct on_subscription_status_changed_user_data *user_data = + aws_mem_calloc(binding->allocator, 1, sizeof(struct on_subscription_status_changed_user_data)); + user_data->allocator = binding->allocator; + user_data->status = status; + user_data->error_code = error_code; + + user_data->binding_ref = s_aws_request_response_streaming_operation_binding_acquire(binding); + + return user_data; +} + +static void s_napi_mqtt_streaming_operation_on_subscription_status_changed( + napi_env env, + napi_value function, + void *context, + void *user_data) { + + (void)context; + + struct on_subscription_status_changed_user_data *status_event = user_data; + struct aws_request_response_streaming_operation_binding *binding = status_event->binding_ref; + + if (env && !binding->is_closed) { + napi_value params[3]; + const size_t num_params = AWS_ARRAY_SIZE(params); + + params[0] = NULL; + if (napi_get_reference_value(env, binding->node_streaming_operation_ref, ¶ms[0]) != napi_ok || + params[0] == NULL) { + AWS_LOGF_INFO( + AWS_LS_NODEJS_CRT_GENERAL, + "s_napi_mqtt_streaming_operation_on_subscription_status_changed - streaming operation node wrapper no " + "longer resolvable"); + goto done; + } + + AWS_NAPI_CALL(env, napi_create_int32(env, (int)status_event->status, ¶ms[1]), { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "s_napi_mqtt_streaming_operation_on_subscription_status_changed - failed to create status value"); + goto done; + }); + + AWS_NAPI_CALL(env, napi_create_int32(env, status_event->error_code, ¶ms[2]), { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "s_napi_mqtt_streaming_operation_on_subscription_status_changed - failed to create error code " + "value"); + goto done; + }); + + AWS_NAPI_ENSURE( + env, + aws_napi_dispatch_threadsafe_function( + env, binding->on_subscription_status_changed, NULL, function, num_params, params)); + } + +done: + + s_on_subscription_status_changed_user_data_destroy(status_event); +} + +static void s_mqtt_streaming_operation_on_subscription_status_changed( + enum aws_rr_streaming_subscription_event_type event_type, + int error_code, + void *user_data) { + + struct aws_request_response_streaming_operation_binding *binding = user_data; + + struct on_subscription_status_changed_user_data *status_changed_ud = + s_on_subscription_status_changed_user_data_new(binding, event_type, error_code); + if (status_changed_ud == NULL) { + return; + } + + /* queue a callback in node's libuv thread */ + AWS_NAPI_ENSURE( + NULL, aws_napi_queue_threadsafe_function(binding->on_subscription_status_changed, status_changed_ud)); +} + +struct on_incoming_publish_user_data { + struct aws_allocator *allocator; + + struct aws_request_response_streaming_operation_binding *binding_ref; + struct aws_byte_buf *payload; +}; + +static void s_on_incoming_publish_user_data_destroy(struct on_incoming_publish_user_data *user_data) { + if (user_data == NULL) { + return; + } + + user_data->binding_ref = s_aws_request_response_streaming_operation_binding_release(user_data->binding_ref); + + if (user_data->payload != NULL) { + aws_byte_buf_clean_up(user_data->payload); + aws_mem_release(user_data->allocator, user_data->payload); + } + + aws_mem_release(user_data->allocator, user_data); +} + +static struct on_incoming_publish_user_data *s_on_incoming_publish_user_data_new( + struct aws_request_response_streaming_operation_binding *binding, + struct aws_byte_cursor payload) { + + struct on_incoming_publish_user_data *user_data = + aws_mem_calloc(binding->allocator, 1, sizeof(struct on_incoming_publish_user_data)); + user_data->allocator = binding->allocator; + + user_data->payload = aws_mem_calloc(binding->allocator, 1, sizeof(struct aws_byte_buf)); + if (aws_byte_buf_init_copy_from_cursor(user_data->payload, binding->allocator, payload)) { + goto error; + } + + user_data->binding_ref = s_aws_request_response_streaming_operation_binding_acquire(binding); + + return user_data; + +error: + + s_on_incoming_publish_user_data_destroy(user_data); + + return NULL; +} + +static int s_aws_create_napi_value_from_incoming_publish_event( + napi_env env, + struct on_incoming_publish_user_data *publish_event, + napi_value *napi_publish_event_out) { + + if (env == NULL) { + return aws_raise_error(AWS_CRT_NODEJS_ERROR_THREADSAFE_FUNCTION_NULL_NAPI_ENV); + } + + napi_value napi_event = NULL; + AWS_NAPI_CALL( + env, napi_create_object(env, &napi_event), { return aws_raise_error(AWS_CRT_NODEJS_ERROR_NAPI_FAILURE); }); + + if (aws_napi_attach_object_property_binary_as_finalizable_external( + napi_event, env, AWS_NAPI_KEY_PAYLOAD, publish_event->payload)) { + return AWS_OP_ERR; + } + + /* the extern's finalizer is now responsible for cleaning up the buffer */ + publish_event->payload = NULL; + + *napi_publish_event_out = napi_event; + + return AWS_OP_SUCCESS; +} + +static void s_napi_mqtt_streaming_operation_on_incoming_publish( + napi_env env, + napi_value function, + void *context, + void *user_data) { + + (void)context; + + struct on_incoming_publish_user_data *publish_event = user_data; + struct aws_request_response_streaming_operation_binding *binding = publish_event->binding_ref; + + if (env && !binding->is_closed) { + napi_value params[2]; + const size_t num_params = AWS_ARRAY_SIZE(params); + + /* + * If we can't resolve the weak ref to the event stream, then it's been garbage collected and we + * should not do anything. + */ + params[0] = NULL; + if (napi_get_reference_value(env, binding->node_streaming_operation_ref, ¶ms[0]) != napi_ok || + params[0] == NULL) { + AWS_LOGF_INFO( + AWS_LS_NODEJS_CRT_GENERAL, + "s_napi_mqtt_streaming_operation_on_incoming_publish - streaming operation node wrapper no " + "longer resolvable"); + goto done; + } + + if (s_aws_create_napi_value_from_incoming_publish_event(env, publish_event, ¶ms[1])) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "s_napi_mqtt_streaming_operation_on_incoming_publish - failed to create JS representation of incoming " + "publish"); + goto done; + } + + AWS_NAPI_ENSURE( + env, + aws_napi_dispatch_threadsafe_function( + env, binding->on_incoming_publish, NULL, function, num_params, params)); + } + +done: + + s_on_incoming_publish_user_data_destroy(publish_event); +} + +static void s_mqtt_streaming_operation_on_incoming_publish(struct aws_byte_cursor payload, void *user_data) { + struct aws_request_response_streaming_operation_binding *binding = user_data; + + struct on_incoming_publish_user_data *incoming_publish_ud = s_on_incoming_publish_user_data_new(binding, payload); + if (incoming_publish_ud == NULL) { + return; + } + + /* queue a callback in node's libuv thread */ + AWS_NAPI_ENSURE(NULL, aws_napi_queue_threadsafe_function(binding->on_incoming_publish, incoming_publish_ud)); +} + +static void s_mqtt_streaming_operation_terminated_fn(void *user_data) { + struct aws_request_response_streaming_operation_binding *binding = user_data; + if (binding == NULL) { + return; + } + + s_aws_request_response_streaming_operation_binding_release(binding); +} + +/* + * Temporary storage of napi binary/string data needed for request submission. + */ +struct aws_mqtt_streaming_operation_options_storage { + struct aws_byte_cursor topic_filter; + + struct aws_byte_buf storage; +}; + +static void s_cleanup_streaming_operation_storage(struct aws_mqtt_streaming_operation_options_storage *storage) { + aws_byte_buf_clean_up(&storage->storage); +} + +/* + * We initialize storage in two phases. The first phase computes how much memory we need to allocate to store all the + * data. This structure tracks those numbers. + */ +struct aws_mqtt_streaming_operation_storage_properties { + size_t bytes_needed; +}; + +static int s_compute_streaming_operation_storage_properties( + napi_env env, + napi_value options, + void *log_context, + struct aws_mqtt_streaming_operation_storage_properties *storage_properties) { + AWS_ZERO_STRUCT(*storage_properties); + + napi_value node_subscription_topic_filter; + if (aws_napi_get_named_property( + env, options, AWS_NAPI_KEY_SUBSCRIPTION_TOPIC_FILTER, napi_string, &node_subscription_topic_filter) != + AWS_NGNPR_VALID_VALUE) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_streaming_operation_storage_properties - failed to get subscription topic filter", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + size_t subscription_topic_filter_length = 0; + if (aws_napi_value_get_storage_length(env, node_subscription_topic_filter, &subscription_topic_filter_length)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_compute_streaming_operation_storage_properties - failed to compute subscription topic filter " + "length", + log_context); + return AWS_OP_ERR; + } + + storage_properties->bytes_needed += subscription_topic_filter_length; + + /* extracting a string value ends up writing the null terminator, so add sufficient padding */ + storage_properties->bytes_needed += 1; + + return AWS_OP_SUCCESS; +} + +static int s_initialize_streaming_operation_storage_from_napi_options( + struct aws_mqtt_streaming_operation_options_storage *storage, + napi_env env, + napi_value options, + void *log_context) { + struct aws_allocator *allocator = aws_napi_get_allocator(); + + struct aws_mqtt_streaming_operation_storage_properties storage_properties; + AWS_ZERO_STRUCT(storage_properties); + + if (s_compute_streaming_operation_storage_properties(env, options, log_context, &storage_properties)) { + // all failure paths in that function log the reason for failure already + return AWS_OP_ERR; + } + + aws_byte_buf_init(&storage->storage, allocator, storage_properties.bytes_needed); + + napi_value node_subscription_topic_filter; + if (aws_napi_get_named_property( + env, options, AWS_NAPI_KEY_SUBSCRIPTION_TOPIC_FILTER, napi_string, &node_subscription_topic_filter) != + AWS_NGNPR_VALID_VALUE) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_streaming_operation_storage_from_napi_options - failed to get subscription topic " + "filter", + log_context); + return aws_raise_error(AWS_ERROR_INVALID_ARGUMENT); + } + + if (aws_napi_value_bytebuf_append(env, node_subscription_topic_filter, &storage->storage, &storage->topic_filter)) { + AWS_LOGF_ERROR( + AWS_LS_NODEJS_CRT_GENERAL, + "id=%p s_initialize_streaming_operation_storage_from_napi_options - failed append subscription topic " + "filter", + log_context); + return AWS_OP_ERR; + } + + AWS_FATAL_ASSERT(storage->storage.capacity == storage->storage.len + 1); + + return AWS_OP_SUCCESS; +} + +napi_value aws_napi_mqtt_streaming_operation_new(napi_env env, napi_callback_info info) { + napi_value node_args[5]; + size_t num_args = AWS_ARRAY_SIZE(node_args); + napi_value *arg = &node_args[0]; + AWS_NAPI_CALL(env, napi_get_cb_info(env, info, &num_args, node_args, NULL, NULL), { + napi_throw_error(env, NULL, "aws_napi_mqtt_streaming_operation_new - Failed to retrieve arguments"); + return NULL; + }); + + if (num_args != AWS_ARRAY_SIZE(node_args)) { + napi_throw_error(env, NULL, "aws_napi_mqtt_streaming_operation_new - needs exactly 5 arguments"); + return NULL; + } + + struct aws_mqtt_streaming_operation_options_storage streaming_operation_options; + AWS_ZERO_STRUCT(streaming_operation_options); + + napi_value node_streaming_operation_ref = NULL; + napi_value node_external = NULL; + struct aws_allocator *allocator = aws_napi_get_allocator(); + + struct aws_request_response_streaming_operation_binding *binding = + aws_mem_calloc(allocator, 1, sizeof(struct aws_request_response_streaming_operation_binding)); + binding->allocator = allocator; + aws_ref_count_init(&binding->ref_count, binding, s_aws_request_response_streaming_operation_binding_on_zero); + + AWS_NAPI_CALL( + env, + napi_create_external( + env, binding, s_aws_mqtt_request_response_streaming_operation_extern_finalize, NULL, &node_external), + { + napi_throw_error(env, NULL, "aws_napi_mqtt_streaming_operation_new - Failed to create n-api external"); + s_aws_request_response_streaming_operation_binding_release(binding); + goto done; + }); + + /* + * From here on out, a failure will lead the external to getting finalized by node, which in turn will lead the + * binding to getting cleaned up. + */ + + /* Arg #1: the js stream */ + napi_value node_streaming_operation = *arg++; + if (aws_napi_is_null_or_undefined(env, node_streaming_operation)) { + napi_throw_error( + env, NULL, "aws_napi_mqtt_streaming_operation_new - Required streaming operation parameter is null"); + goto done; + } + + AWS_NAPI_CALL( + env, napi_create_reference(env, node_streaming_operation, 1, &binding->node_streaming_operation_ref), { + napi_throw_error( + env, + NULL, + "aws_napi_mqtt_streaming_operation_new - Failed to create reference to node streaming operation"); + goto done; + }); + + /* + * the reference to the JS streaming operation was successfully created. From now on, any failure needs to undo it. + * All paths now require close, either as an error path (post_ref_error) or success (user must call). For this + * reason bump the binding ref count to two, so that both a close and an extern finalize must take place. + * + * close releases the native stream, and its termination callback leads to one decref + * extern finalize leads to the other decref + * + * In progress callbacks continue to use inc/dec to bracket their (brief) lifetimes. + */ + s_aws_request_response_streaming_operation_binding_acquire(binding); + + /* Arg #2: the request response client to create a streaming operation from */ + struct aws_mqtt_request_response_client_binding *client_binding = NULL; + napi_value node_client_binding = *arg++; + AWS_NAPI_CALL(env, napi_get_value_external(env, node_client_binding, (void **)&client_binding), { + napi_throw_error(env, NULL, "aws_napi_mqtt_streaming_operation_new - Failed to extract client binding"); + goto post_ref_error; + }); + + if (client_binding == NULL) { + napi_throw_error(env, NULL, "aws_napi_mqtt_streaming_operation_new - client binding was null"); + goto post_ref_error; + } + + if (client_binding->client == NULL) { + napi_throw_error(env, NULL, "aws_napi_mqtt_streaming_operation_new - native client is null"); + goto post_ref_error; + } + + /* Arg #3: streaming operation options */ + napi_value node_streaming_operation_config = *arg++; + if (aws_napi_is_null_or_undefined(env, node_streaming_operation_config)) { + napi_throw_error(env, NULL, "aws_napi_mqtt_streaming_operation_new - required configuration parameter is null"); + goto post_ref_error; + } + + if (s_initialize_streaming_operation_storage_from_napi_options( + &streaming_operation_options, env, node_streaming_operation_config, client_binding->client)) { + napi_throw_error(env, NULL, "aws_napi_mqtt_streaming_operation_new - invalid configuration options"); + goto post_ref_error; + } + + /* Arg #4: subscription status event callback */ + napi_value on_subscription_status_changed_handler = *arg++; + if (aws_napi_is_null_or_undefined(env, on_subscription_status_changed_handler)) { + napi_throw_error( + env, + NULL, + "aws_napi_mqtt_streaming_operation_new - required on_subscription_status_changed event handler is null"); + goto post_ref_error; + } + + AWS_NAPI_CALL( + env, + aws_napi_create_threadsafe_function( + env, + on_subscription_status_changed_handler, + "aws_mqtt_streaming_operation_on_subscription_status_changed", + s_napi_mqtt_streaming_operation_on_subscription_status_changed, + NULL, + &binding->on_subscription_status_changed), + { + napi_throw_error( + env, + NULL, + "aws_napi_mqtt_streaming_operation_new - failed to initialize on_subscription_status_changed " + "threadsafe function"); + goto post_ref_error; + }); + + /* Arg #5: incoming publish callback */ + napi_value on_incoming_publish_handler = *arg++; + if (aws_napi_is_null_or_undefined(env, on_incoming_publish_handler)) { + napi_throw_error( + env, NULL, "aws_napi_mqtt_streaming_operation_new - required on_incoming_publish event handler is null"); + goto post_ref_error; + } + + AWS_NAPI_CALL( + env, + aws_napi_create_threadsafe_function( + env, + on_incoming_publish_handler, + "aws_mqtt_streaming_operation_on_incoming_publish", + s_napi_mqtt_streaming_operation_on_incoming_publish, + NULL, + &binding->on_incoming_publish), + { + napi_throw_error( + env, + NULL, + "aws_napi_mqtt_streaming_operation_new - failed to initialize on_incoming_publish threadsafe function"); + goto post_ref_error; + }); + + struct aws_mqtt_streaming_operation_options operation_options = { + .topic_filter = streaming_operation_options.topic_filter, + .subscription_status_callback = s_mqtt_streaming_operation_on_subscription_status_changed, + .incoming_publish_callback = s_mqtt_streaming_operation_on_incoming_publish, + .terminated_callback = s_mqtt_streaming_operation_terminated_fn, + .user_data = binding, + }; + + binding->streaming_operation = + aws_mqtt_request_response_client_create_streaming_operation(client_binding->client, &operation_options); + if (binding->streaming_operation == NULL) { + napi_throw_error( + env, NULL, "aws_napi_mqtt_streaming_operation_new - Failed to create native streaming operation"); + goto post_ref_error; + } + + AWS_NAPI_CALL(env, napi_create_reference(env, node_external, 1, &binding->node_streaming_operation_external_ref), { + napi_throw_error( + env, NULL, "aws_napi_mqtt_streaming_operation_new - Failed to create one count reference to napi external"); + goto post_ref_error; + }); + + node_streaming_operation_ref = node_external; + goto done; + +post_ref_error: + + s_streaming_operation_close(binding, env); + +done: + + s_cleanup_streaming_operation_storage(&streaming_operation_options); + + return node_streaming_operation_ref; +} + +napi_value aws_napi_mqtt_streaming_operation_open(napi_env env, napi_callback_info info) { + napi_value node_args[1]; + size_t num_args = AWS_ARRAY_SIZE(node_args); + napi_value *arg = &node_args[0]; + AWS_NAPI_CALL(env, napi_get_cb_info(env, info, &num_args, node_args, NULL, NULL), { + napi_throw_error(env, NULL, "aws_napi_mqtt_streaming_operation_open - Failed to extract parameter array"); + return NULL; + }); + + if (num_args != AWS_ARRAY_SIZE(node_args)) { + napi_throw_error(env, NULL, "aws_napi_mqtt_streaming_operation_open - needs exactly 1 arguments"); + return NULL; + } + + struct aws_request_response_streaming_operation_binding *binding = NULL; + napi_value node_binding = *arg++; + AWS_NAPI_CALL(env, napi_get_value_external(env, node_binding, (void **)&binding), { + napi_throw_error( + env, + NULL, + "aws_napi_mqtt_streaming_operation_open - Failed to extract stream binding from first " + "argument"); + return NULL; + }); + + if (binding == NULL) { + napi_throw_error(env, NULL, "aws_napi_mqtt_streaming_operation_open - binding is null"); + return NULL; + } + + if (binding->streaming_operation == NULL) { + napi_throw_error(env, NULL, "aws_napi_mqtt_streaming_operation_open - streaming operation is null"); + return NULL; + } + + if (aws_mqtt_rr_client_operation_activate(binding->streaming_operation)) { + napi_throw_error( + env, NULL, "aws_napi_mqtt_streaming_operation_open - streaming operation activation failed synchronously"); + return NULL; + } + + return NULL; +} + +napi_value aws_napi_mqtt_streaming_operation_close(napi_env env, napi_callback_info info) { + napi_value node_args[1]; + size_t num_args = AWS_ARRAY_SIZE(node_args); + napi_value *arg = &node_args[0]; + AWS_NAPI_CALL(env, napi_get_cb_info(env, info, &num_args, node_args, NULL, NULL), { + napi_throw_error(env, NULL, "aws_napi_mqtt_streaming_operation_close - Failed to retrieve arguments"); + return NULL; + }); + + if (num_args != AWS_ARRAY_SIZE(node_args)) { + napi_throw_error(env, NULL, "aws_napi_mqtt_streaming_operation_close - needs exactly 1 argument"); + return NULL; + } + + struct aws_request_response_streaming_operation_binding *binding = NULL; + napi_value node_binding = *arg++; + AWS_NAPI_CALL(env, napi_get_value_external(env, node_binding, (void **)&binding), { + napi_throw_error( + env, + NULL, + "aws_napi_mqtt_streaming_operation_close - Failed to extract streaming operation binding from first " + "argument"); + return NULL; + }); + + s_streaming_operation_close(binding, env); + + return NULL; +} diff --git a/source/mqtt_request_response.h b/source/mqtt_request_response.h new file mode 100644 index 000000000..4fc881b8d --- /dev/null +++ b/source/mqtt_request_response.h @@ -0,0 +1,25 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +#ifndef AWS_CRT_NODEJS_MQTT_REQUEST_RESPONSE_H +#define AWS_CRT_NODEJS_MQTT_REQUEST_RESPONSE_H + +#include "module.h" + +napi_value aws_napi_mqtt_request_response_client_new_from_5(napi_env env, napi_callback_info info); + +napi_value aws_napi_mqtt_request_response_client_new_from_311(napi_env env, napi_callback_info info); + +napi_value aws_napi_mqtt_request_response_client_close(napi_env env, napi_callback_info info); + +napi_value aws_napi_mqtt_streaming_operation_new(napi_env env, napi_callback_info info); + +napi_value aws_napi_mqtt_streaming_operation_open(napi_env env, napi_callback_info info); + +napi_value aws_napi_mqtt_streaming_operation_close(napi_env env, napi_callback_info info); + +napi_value aws_napi_mqtt_request_response_client_submit_request(napi_env env, napi_callback_info info); + +#endif /* AWS_CRT_NODEJS_MQTT_REQUEST_RESPONSE_H */ diff --git a/test/browser/jest.config.js b/test/browser/jest.config.js index 32c80295b..b17b1247c 100644 --- a/test/browser/jest.config.js +++ b/test/browser/jest.config.js @@ -4,6 +4,7 @@ module.exports = { testMatch: [ '/lib/common/*.spec.ts', '/lib/browser/*.spec.ts', + '/lib/browser/mqtt_request_response/*.spec.ts' ], preset: 'jest-puppeteer', globals: { diff --git a/test/mqtt5.ts b/test/mqtt5.ts index 3109c1129..9bf65cdac 100644 --- a/test/mqtt5.ts +++ b/test/mqtt5.ts @@ -65,8 +65,7 @@ export class ClientEnvironmentalConfig { { return ClientEnvironmentalConfig.AWS_IOT_HOST !== "" && ClientEnvironmentalConfig.AWS_IOT_ACCESS_KEY_ID !== "" && - ClientEnvironmentalConfig.AWS_IOT_SECRET_ACCESS_KEY !== "" && - ClientEnvironmentalConfig.AWS_IOT_SESSION_TOKEN !== ""; + ClientEnvironmentalConfig.AWS_IOT_SECRET_ACCESS_KEY !== ""; } public static hasIotCoreEnvironment() { diff --git a/test/mqtt_request_response.ts b/test/mqtt_request_response.ts new file mode 100644 index 000000000..0470f27ab --- /dev/null +++ b/test/mqtt_request_response.ts @@ -0,0 +1,542 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +import * as iot from "@awscrt/iot"; +import * as mqtt5 from "@awscrt/mqtt5"; +import * as test_env from "./test_env"; +import {v4 as uuid} from "uuid"; +import * as mqtt311 from "@awscrt/mqtt"; +import * as mqtt_request_response from "@awscrt/mqtt_request_response"; +import {once} from "events"; +import {toUtf8} from "@aws-sdk/util-utf8-browser"; +import {StreamingOperationOptions, SubscriptionStatusEvent} from "@awscrt/mqtt_request_response"; +import {newLiftedPromise} from "../lib/common/promise"; + +export type ClientBuilderFactory5 = () => iot.AwsIotMqtt5ClientConfigBuilder; +export type ClientBuilderFactory311 = () => iot.AwsIotMqttConnectionConfigBuilder; + +var testBuilderFactory5 : ClientBuilderFactory5 | undefined = undefined; +var testBuilderFactory311 : ClientBuilderFactory311 | undefined = undefined; + +export function setClientBuilderFactories(factory5: ClientBuilderFactory5, factory311: ClientBuilderFactory311) { + testBuilderFactory5 = factory5; + testBuilderFactory311 = factory311; +} + +export enum ProtocolVersion { + Mqtt311, + Mqtt5 +} + +export interface TestingOptions { + version: ProtocolVersion, + timeoutSeconds?: number, + startOffline?: boolean, + builder_mutator5?: (builder: iot.AwsIotMqtt5ClientConfigBuilder) => iot.AwsIotMqtt5ClientConfigBuilder, + builder_mutator311?: (builder: iot.AwsIotMqttConnectionConfigBuilder) => iot.AwsIotMqttConnectionConfigBuilder, +} + +export function build_protocol_client_mqtt5(builder: iot.AwsIotMqtt5ClientConfigBuilder, builder_mutator?: (builder: iot.AwsIotMqtt5ClientConfigBuilder) => iot.AwsIotMqtt5ClientConfigBuilder) : mqtt5.Mqtt5Client { + builder.withConnectProperties({ + clientId : uuid(), + keepAliveIntervalSeconds: 1200, + }); + + if (builder_mutator) { + builder = builder_mutator(builder); + } + + return new mqtt5.Mqtt5Client(builder.build()); +} + +export function build_protocol_client_mqtt311(builder: iot.AwsIotMqttConnectionConfigBuilder, builder_mutator?: (builder: iot.AwsIotMqttConnectionConfigBuilder) => iot.AwsIotMqttConnectionConfigBuilder) : mqtt311.MqttClientConnection { + builder.with_endpoint(test_env.AWS_IOT_ENV.MQTT5_HOST); // yes, 5 not 3 + builder.with_client_id(uuid()); + + if (builder_mutator) { + builder = builder_mutator(builder); + } + + let client = new mqtt311.MqttClient(); + return client.new_connection(builder.build()); +} + +export class TestingContext { + + mqtt311Client?: mqtt311.MqttClientConnection; + mqtt5Client?: mqtt5.Mqtt5Client; + + client: mqtt_request_response.RequestResponseClient; + + private protocolStarted : boolean = false; + + async startProtocolClient() { + if (!this.protocolStarted) { + this.protocolStarted = true; + if (this.mqtt5Client) { + let connected = once(this.mqtt5Client, mqtt5.Mqtt5Client.CONNECTION_SUCCESS); + this.mqtt5Client.start(); + + await connected; + } + + if (this.mqtt311Client) { + await this.mqtt311Client.connect(); + } + } + } + + async stopProtocolClient() { + if (this.protocolStarted) { + this.protocolStarted = false; + if (this.mqtt5Client) { + let stopped = once(this.mqtt5Client, mqtt5.Mqtt5Client.STOPPED); + this.mqtt5Client.stop(); + await stopped; + + this.mqtt5Client.close(); + } + + if (this.mqtt311Client) { + await this.mqtt311Client.disconnect(); + } + } + } + + async publishProtocolClient(topic: string, payload: ArrayBuffer) { + if (this.mqtt5Client) { + await this.mqtt5Client.publish({ + topicName: topic, + qos: mqtt5.QoS.AtLeastOnce, + payload: payload, + }); + } + + if (this.mqtt311Client) { + await this.mqtt311Client.publish(topic, payload, mqtt311.QoS.AtLeastOnce); + } + } + + constructor(options: TestingOptions) { + if (options.version == ProtocolVersion.Mqtt5) { + // @ts-ignore + this.mqtt5Client = build_protocol_client_mqtt5(testBuilderFactory5(), options.builder_mutator5); + + let rrOptions : mqtt_request_response.RequestResponseClientOptions = { + maxRequestResponseSubscriptions : 6, + maxStreamingSubscriptions : 2, + operationTimeoutInSeconds : options.timeoutSeconds ?? 60, + } + + this.client = mqtt_request_response.RequestResponseClient.newFromMqtt5(this.mqtt5Client, rrOptions); + } else { + // @ts-ignore + this.mqtt311Client = build_protocol_client_mqtt311(testBuilderFactory311(), options.builder_mutator311); + + let rrOptions : mqtt_request_response.RequestResponseClientOptions = { + maxRequestResponseSubscriptions : 6, + maxStreamingSubscriptions : 2, + operationTimeoutInSeconds : options.timeoutSeconds ?? 60, + } + + this.client = mqtt_request_response.RequestResponseClient.newFromMqtt311(this.mqtt311Client, rrOptions); + } + } + + async open() { + await this.startProtocolClient(); + } + + async close() { + this.client.close(); + await this.stopProtocolClient(); + } +} + +export function createRejectedGetNamedShadowRequest(addCorelationToken: boolean) : mqtt_request_response.RequestResponseOperationOptions { + let requestOptions : mqtt_request_response.RequestResponseOperationOptions = { + subscriptionTopicFilters: [ "$aws/things/NoSuchThing/shadow/name/Derp/get/+" ], + responsePaths: [{ + topic: "$aws/things/NoSuchThing/shadow/name/Derp/get/accepted", + }, { + topic: "$aws/things/NoSuchThing/shadow/name/Derp/get/rejected", + }], + publishTopic: "$aws/things/NoSuchThing/shadow/name/Derp/get", + payload: Buffer.from("{}", "utf-8"), + } + + if (addCorelationToken) { + let correlationToken = uuid(); + + requestOptions.responsePaths = [{ + topic: "$aws/things/NoSuchThing/shadow/name/Derp/get/accepted", + correlationTokenJsonPath: "clientToken", + }, { + topic: "$aws/things/NoSuchThing/shadow/name/Derp/get/rejected", + correlationTokenJsonPath: "clientToken", + }]; + requestOptions.payload = Buffer.from(`{\"clientToken\":\"${correlationToken}\"}`); + requestOptions.correlationToken = correlationToken; + } + + return requestOptions; +} + +export async function do_get_named_shadow_success_rejected_test(version: ProtocolVersion, useCorrelationToken: boolean) : Promise { + let context = new TestingContext({ + version: version + }); + + await context.open(); + + let requestOptions = createRejectedGetNamedShadowRequest(useCorrelationToken); + + let response = await context.client.submitRequest(requestOptions); + expect(response.topic).toEqual(requestOptions.responsePaths[1].topic); + expect(response.payload.byteLength).toBeGreaterThan(0); + + let response_string = toUtf8(new Uint8Array(response.payload)); + expect(response_string).toContain("No shadow exists with name"); + + await context.close(); +} + +export function createAcceptedUpdateNamedShadowRequest(addCorelationToken: boolean) : mqtt_request_response.RequestResponseOperationOptions { + let requestOptions : mqtt_request_response.RequestResponseOperationOptions = { + subscriptionTopicFilters: [ + "$aws/things/NoSuchThing/shadow/name/UpdateShadowCITest/update/accepted", + "$aws/things/NoSuchThing/shadow/name/UpdateShadowCITest/update/rejected" + ], + responsePaths: [{ + topic: "$aws/things/NoSuchThing/shadow/name/UpdateShadowCITest/update/accepted", + }, { + topic: "$aws/things/NoSuchThing/shadow/name/UpdateShadowCITest/update/rejected", + }], + publishTopic: "$aws/things/NoSuchThing/shadow/name/UpdateShadowCITest/update", + payload: Buffer.from("", "utf-8"), + } + + let desired_state = `{\"magic\":\"${uuid()}\"}`; + + if (addCorelationToken) { + let correlationToken = uuid(); + + requestOptions.responsePaths[0].correlationTokenJsonPath = "clientToken"; + requestOptions.responsePaths[1].correlationTokenJsonPath = "clientToken"; + requestOptions.correlationToken = correlationToken; + requestOptions.payload = Buffer.from(`{\"clientToken\":\"${correlationToken}\",\"state\":{\"desired\":${desired_state}}}`); + } else { + requestOptions.payload = Buffer.from(`{\"state\":{\"desired\":${desired_state}}}`); + } + + return requestOptions; +} + +export async function do_update_named_shadow_success_accepted_test(version: ProtocolVersion, useCorrelationToken: boolean) : Promise { + let context = new TestingContext({ + version: version + }); + + await context.open(); + + let requestOptions = createAcceptedUpdateNamedShadowRequest(useCorrelationToken); + + let response = await context.client.submitRequest(requestOptions); + expect(response.topic).toEqual(requestOptions.responsePaths[0].topic); + expect(response.payload.byteLength).toBeGreaterThan(0); + + await context.close(); +} + +export async function do_get_named_shadow_failure_timeout_test(version: ProtocolVersion, useCorrelationToken: boolean) : Promise { + let context = new TestingContext({ + version: version, + timeoutSeconds: 4, + }); + + await context.open(); + + let requestOptions = createRejectedGetNamedShadowRequest(useCorrelationToken); + requestOptions.publishTopic = "not/the/right/topic"; + + try { + await context.client.submitRequest(requestOptions); + expect(false); + } catch (e) { + let err = e as Error; + expect(err.message).toContain("timeout"); + } + + await context.close(); +} + +export async function do_get_named_shadow_failure_on_close_test(version: ProtocolVersion, expectedFailureSubstring: string) : Promise { + let context = new TestingContext({ + version: version, + }); + + await context.open(); + + let requestOptions = createRejectedGetNamedShadowRequest(true); + + try { + let resultPromise = context.client.submitRequest(requestOptions); + context.client.close(); + await resultPromise; + expect(false); + } catch (e) { + let err = e as Error; + expect(err.message).toContain(expectedFailureSubstring); + } + + await context.close(); +} + +export function do_client_creation_failure_test(version: ProtocolVersion, configMutator: (config: mqtt_request_response.RequestResponseClientOptions) => mqtt_request_response.RequestResponseClientOptions | undefined, expected_error_text: string) { + if (version == ProtocolVersion.Mqtt311) { + // @ts-ignore + let protocolClient = build_protocol_client_mqtt311(testBuilderFactory311()); + let goodConfig : mqtt_request_response.RequestResponseClientOptions = { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions : 2, + operationTimeoutInSeconds : 5, + }; + let badConfig = configMutator(goodConfig); + + // @ts-ignore + expect(() => {mqtt_request_response.RequestResponseClient.newFromMqtt311(protocolClient, badConfig)}).toThrow(expected_error_text); + } else { + // @ts-ignore + let protocolClient = build_protocol_client_mqtt5(testBuilderFactory5()); + let goodConfig : mqtt_request_response.RequestResponseClientOptions = { + maxRequestResponseSubscriptions: 2, + maxStreamingSubscriptions : 2, + operationTimeoutInSeconds : 5, + }; + let badConfig = configMutator(goodConfig); + + // @ts-ignore + expect(() => {mqtt_request_response.RequestResponseClient.newFromMqtt5(protocolClient, badConfig)}).toThrow(expected_error_text); + } +} + +export function create_bad_config_no_max_request_response_subscriptions(config: mqtt_request_response.RequestResponseClientOptions) : mqtt_request_response.RequestResponseClientOptions | undefined { + return { + maxRequestResponseSubscriptions: 0, + maxStreamingSubscriptions : config.maxStreamingSubscriptions, + operationTimeoutInSeconds : config.operationTimeoutInSeconds + } +} + +export function create_bad_config_invalid_max_request_response_subscriptions(config: mqtt_request_response.RequestResponseClientOptions) : mqtt_request_response.RequestResponseClientOptions | undefined { + return { + // @ts-ignore + maxRequestResponseSubscriptions: "help", + maxStreamingSubscriptions : config.maxStreamingSubscriptions, + operationTimeoutInSeconds : config.operationTimeoutInSeconds + } +} + +export function create_bad_config_undefined_config(config: mqtt_request_response.RequestResponseClientOptions) : mqtt_request_response.RequestResponseClientOptions | undefined { + return undefined +} + +export function create_bad_config_undefined_max_request_response_subscriptions(config: mqtt_request_response.RequestResponseClientOptions) : mqtt_request_response.RequestResponseClientOptions | undefined { + return { + // @ts-ignore + maxRequestResponseSubscriptions: undefined, + maxStreamingSubscriptions : config.maxStreamingSubscriptions, + operationTimeoutInSeconds : config.operationTimeoutInSeconds + } +} + +export function create_bad_config_null_max_request_response_subscriptions(config: mqtt_request_response.RequestResponseClientOptions) : mqtt_request_response.RequestResponseClientOptions | undefined { + return { + // @ts-ignore + maxRequestResponseSubscriptions: null, + maxStreamingSubscriptions : config.maxStreamingSubscriptions, + operationTimeoutInSeconds : config.operationTimeoutInSeconds + } +} + +export function create_bad_config_missing_max_request_response_subscriptions(config: mqtt_request_response.RequestResponseClientOptions) : mqtt_request_response.RequestResponseClientOptions | undefined { + // @ts-ignore + return { + maxStreamingSubscriptions : config.maxStreamingSubscriptions, + operationTimeoutInSeconds : config.operationTimeoutInSeconds + } +} + +export function create_bad_config_undefined_max_streaming_subscriptions(config: mqtt_request_response.RequestResponseClientOptions) : mqtt_request_response.RequestResponseClientOptions | undefined { + return { + maxRequestResponseSubscriptions: config.maxRequestResponseSubscriptions, + // @ts-ignore + maxStreamingSubscriptions : undefined, + operationTimeoutInSeconds : config.operationTimeoutInSeconds + } +} + +export function create_bad_config_null_max_streaming_subscriptions(config: mqtt_request_response.RequestResponseClientOptions) : mqtt_request_response.RequestResponseClientOptions | undefined { + return { + maxRequestResponseSubscriptions: config.maxRequestResponseSubscriptions, + // @ts-ignore + maxStreamingSubscriptions : null, + operationTimeoutInSeconds : config.operationTimeoutInSeconds + } +} + +export function create_bad_config_missing_max_streaming_subscriptions(config: mqtt_request_response.RequestResponseClientOptions) : mqtt_request_response.RequestResponseClientOptions | undefined { + // @ts-ignore + return { + maxRequestResponseSubscriptions : config.maxRequestResponseSubscriptions, + operationTimeoutInSeconds : config.operationTimeoutInSeconds + } +} + +export function create_bad_config_invalid_operation_timeout(config: mqtt_request_response.RequestResponseClientOptions) : mqtt_request_response.RequestResponseClientOptions | undefined { + return { + maxRequestResponseSubscriptions : config.maxRequestResponseSubscriptions, + maxStreamingSubscriptions : config.maxStreamingSubscriptions, + // @ts-ignore + operationTimeoutInSeconds : "no" + } +} + +export async function do_get_named_shadow_failure_invalid_test(useCorrelationToken: boolean, expected_error_substring: string, options_mutator: (options: mqtt_request_response.RequestResponseOperationOptions) => mqtt_request_response.RequestResponseOperationOptions) : Promise { + let context = new TestingContext({ + version: ProtocolVersion.Mqtt5 + }); + + await context.open(); + + let requestOptions = createRejectedGetNamedShadowRequest(useCorrelationToken); + + let responsePromise = context.client.submitRequest(options_mutator(requestOptions)); + try { + await responsePromise; + expect(false); + } catch (err: any) { + expect(err.message).toContain(expected_error_substring); + } + + await context.close(); +} + +export async function do_streaming_operation_new_open_close_test(version: ProtocolVersion) { + let context = new TestingContext({ + version: version + }); + + await context.open(); + + let streaming_options : StreamingOperationOptions = { + subscriptionTopicFilter : "$aws/things/NoSuchThing/shadow/name/UpdateShadowCITest/update/delta" + } + + let stream = context.client.createStream(streaming_options); + stream.open(); + stream.close(); + + await context.close(); +} + +export async function do_streaming_operation_incoming_publish_test(version: ProtocolVersion) { + let context = new TestingContext({ + version: version + }); + + await context.open(); + + let topic_filter = `not/a/real/shadow/${uuid()}`; + let streaming_options : StreamingOperationOptions = { + subscriptionTopicFilter : topic_filter, + } + + let stream = context.client.createStream(streaming_options); + let publish_received_promise = once(stream, mqtt_request_response.StreamingOperationBase.INCOMING_PUBLISH); + let initialSubscriptionComplete = once(stream, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + stream.open(); + + await initialSubscriptionComplete; + + let payload : Buffer = Buffer.from("IncomingPublish", "utf-8"); + await context.publishProtocolClient(topic_filter, payload); + + let incoming_publish : mqtt_request_response.IncomingPublishEvent = (await publish_received_promise)[0]; + + expect(Buffer.from(incoming_publish.payload as ArrayBuffer)).toEqual(payload); + + stream.close(); + + await context.close(); +} + +export async function do_streaming_operation_subscription_events_test(options: TestingOptions) { + let context = new TestingContext(options); + + await context.open(); + + let topic_filter = `not/a/real/shadow/${uuid()}`; + let streaming_options : StreamingOperationOptions = { + subscriptionTopicFilter : topic_filter, + } + + let events : Array = []; + let allEventsPromise = newLiftedPromise(); + let stream = context.client.createStream(streaming_options); + stream.addListener("subscriptionStatus", (eventData) => { + events.push(eventData); + + if (events.length === 3) { + allEventsPromise.resolve(); + } + }); + + let initialSubscriptionComplete = once(stream, mqtt_request_response.StreamingOperationBase.SUBSCRIPTION_STATUS); + + stream.open(); + + await initialSubscriptionComplete; + + let protocolClient = context.mqtt5Client; + if (protocolClient) { + let stopped = once(protocolClient, mqtt5.Mqtt5Client.STOPPED); + protocolClient.stop(); + await stopped; + + let started = once(protocolClient, mqtt5.Mqtt5Client.CONNECTION_SUCCESS); + protocolClient.start(); + await started; + } + + await allEventsPromise.promise; + + expect(events[0].type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionEstablished); + expect(events[0].error).toBeUndefined(); + expect(events[1].type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionLost); + expect(events[1].error).toBeUndefined(); + expect(events[2].type).toEqual(mqtt_request_response.SubscriptionStatusEventType.SubscriptionEstablished); + expect(events[2].error).toBeUndefined(); + + stream.close(); + + await context.close(); +} + +export async function do_invalid_streaming_operation_config_test(config: StreamingOperationOptions, expected_error: string) { + let context = new TestingContext({ + version: ProtocolVersion.Mqtt5 + }); + + await context.open(); + + expect(() => { + // @ts-ignore + context.client.createStream(config) + }).toThrow(expected_error); + + await context.close(); +} \ No newline at end of file diff --git a/test/test_env.ts b/test/test_env.ts index 690e0a0e1..1d8d1647a 100644 --- a/test/test_env.ts +++ b/test/test_env.ts @@ -108,8 +108,7 @@ export class AWS_IOT_ENV { return AWS_IOT_ENV.MQTT5_HOST !== "" && AWS_IOT_ENV.MQTT5_REGION !== "" && AWS_IOT_ENV.MQTT5_CRED_ACCESS_KEY !== "" && - AWS_IOT_ENV.MQTT5_CRED_SECRET_ACCESS_KEY !== "" && - AWS_IOT_ENV.MQTT5_CRED_SESSION_TOKEN !== ""; + AWS_IOT_ENV.MQTT5_CRED_SECRET_ACCESS_KEY !== ""; } public static mqtt5_is_valid_cognito() { diff --git a/tsconfig.browser.json b/tsconfig.browser.json index 68ad197c9..93584d681 100644 --- a/tsconfig.browser.json +++ b/tsconfig.browser.json @@ -3,7 +3,8 @@ "include": [ "lib/browser.ts", "lib/common/*.ts", - "lib/browser/*.ts" + "lib/browser/*.ts", + "lib/browser/mqtt_request_response/*.ts" ], "compilerOptions": { "target": "es5", From 02ffc151b8586ad84f3db5d3f73f66dce47be387 Mon Sep 17 00:00:00 2001 From: Dmitriy Musatkin <63878209+DmitriyMusatkin@users.noreply.github.com> Date: Fri, 6 Dec 2024 14:49:29 -0800 Subject: [PATCH 2/2] Switch CI to Roles (#591) --- .github/workflows/ci.yml | 155 +++++++++++++++++++++++-------------- .github/workflows/docs.yml | 4 +- 2 files changed, 101 insertions(+), 58 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 822570f71..4d1d49ecc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,21 +7,21 @@ on: - 'docs' env: - BUILDER_VERSION: v0.9.62 + BUILDER_VERSION: v0.9.73 BUILDER_SOURCE: releases BUILDER_HOST: https://d19elf31gohf1l.cloudfront.net PACKAGE_NAME: aws-crt-nodejs LINUX_BASE_IMAGE: ubuntu-18-x64 RUN: ${{ github.run_id }}-${{ github.run_number }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }} - AWS_REGION: ${{ secrets.AWS_DEFAULT_REGION }} + CRT_CI_ROLE: ${{ secrets.CRT_CI_ROLE_ARN }} + AWS_DEFAULT_REGION: us-east-1 -jobs: +permissions: + id-token: write # This is required for requesting the JWT +jobs: linux-compat: - runs-on: ubuntu-20.04 # latest + runs-on: ubuntu-24.04 # latest strategy: fail-fast: false matrix: @@ -35,7 +35,11 @@ jobs: - rhel8-x64 - raspbian-bullseye steps: - # We can't use the `uses: docker://image` version yet, GitHub lacks authentication for actions -> packages + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.CRT_CI_ROLE }} + aws-region: ${{ env.AWS_DEFAULT_REGION }} + # We can't use the `uses: docker://image` version yet, GitHub lacks authentication for actions -> packages - name: Build ${{ env.PACKAGE_NAME }} run: | aws s3 cp s3://aws-crt-test-stuff/ci/${{ env.BUILDER_VERSION }}/linux-container-ci.sh ./linux-container-ci.sh && chmod a+x ./linux-container-ci.sh @@ -43,46 +47,51 @@ jobs: ./linux-container-ci.sh ${{ env.BUILDER_VERSION }} aws-crt-${{ matrix.image }} build -p ${{ env.PACKAGE_NAME }} musl-linux: - runs-on: ubuntu-20.04 # latest + runs-on: ubuntu-24.04 # latest strategy: fail-fast: false matrix: image: - alpine-3.16-x64 steps: - # We can't use the `uses: docker://image` version yet, GitHub lacks authentication for actions -> packages - - name: Build ${{ env.PACKAGE_NAME }} - run: | - aws s3 cp s3://aws-crt-test-stuff/ci/${{ env.BUILDER_VERSION }}/linux-container-ci.sh ./linux-container-ci.sh && chmod a+x ./linux-container-ci.sh - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - ./linux-container-ci.sh ${{ env.BUILDER_VERSION }} aws-crt-${{ matrix.image }} build -p ${{ env.PACKAGE_NAME }} - + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.CRT_CI_ROLE }} + aws-region: ${{ env.AWS_DEFAULT_REGION }} + # We can't use the `uses: docker://image` version yet, GitHub lacks authentication for actions -> packages + - name: Build ${{ env.PACKAGE_NAME }} + run: | + aws s3 cp s3://aws-crt-test-stuff/ci/${{ env.BUILDER_VERSION }}/linux-container-ci.sh ./linux-container-ci.sh && chmod a+x ./linux-container-ci.sh + docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + ./linux-container-ci.sh ${{ env.BUILDER_VERSION }} aws-crt-${{ matrix.image }} build -p ${{ env.PACKAGE_NAME }} linux-musl-armv7: - runs-on: ubuntu-20.04 # latest - strategy: - fail-fast: false - matrix: - image: - - alpine-3.16-x64 - steps: - - name: Install qemu/docker - run: docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - - name: Checkout Sources - uses: actions/checkout@v2 - with: - submodules: true - # We can't use the `uses: docker://image` version yet, GitHub lacks authentication for actions -> packages - - name: Build ${{ env.PACKAGE_NAME }} - run: | - export AWS_CRT_ARCH=armv7 - aws s3 cp s3://aws-crt-test-stuff/ci/${{ env.BUILDER_VERSION }}/linux-container-ci.sh ./linux-container-ci.sh && chmod a+x ./linux-container-ci.sh - ./linux-container-ci.sh ${{ env.BUILDER_VERSION }} aws-crt-alpine-3.16-armv7 build -p ${{ env.PACKAGE_NAME }} - - + runs-on: ubuntu-24.04 # latest + strategy: + fail-fast: false + matrix: + image: + - alpine-3.16-x64 + steps: + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.CRT_CI_ROLE }} + aws-region: ${{ env.AWS_DEFAULT_REGION }} + - name: Install qemu/docker + run: docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + - name: Checkout Sources + uses: actions/checkout@v2 + with: + submodules: true + # We can't use the `uses: docker://image` version yet, GitHub lacks authentication for actions -> packages + - name: Build ${{ env.PACKAGE_NAME }} + run: | + export AWS_CRT_ARCH=armv7 + aws s3 cp s3://aws-crt-test-stuff/ci/${{ env.BUILDER_VERSION }}/linux-container-ci.sh ./linux-container-ci.sh && chmod a+x ./linux-container-ci.sh + ./linux-container-ci.sh ${{ env.BUILDER_VERSION }} aws-crt-alpine-3.16-armv7 build -p ${{ env.PACKAGE_NAME }} linux-compiler-compat: - runs-on: ubuntu-20.04 # latest + runs-on: ubuntu-24.04 # latest strategy: fail-fast: false matrix: @@ -93,14 +102,20 @@ jobs: clang-9, clang-10, clang-11, + clang-15, gcc-4.8, gcc-5, gcc-6, gcc-7, - gcc-8 + gcc-8, + gcc-11 ] steps: - # We can't use the `uses: docker://image` version yet, GitHub lacks authentication for actions -> packages + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.CRT_CI_ROLE }} + aws-region: ${{ env.AWS_DEFAULT_REGION }} + # We can't use the `uses: docker://image` version yet, GitHub lacks authentication for actions -> packages - name: Build ${{ env.PACKAGE_NAME }} run: | aws s3 cp s3://aws-crt-test-stuff/ci/${{ env.BUILDER_VERSION }}/linux-container-ci.sh ./linux-container-ci.sh && chmod a+x ./linux-container-ci.sh @@ -109,6 +124,10 @@ jobs: windows: runs-on: windows-2022 # latest steps: + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.CRT_CI_ROLE }} + aws-region: ${{ env.AWS_DEFAULT_REGION }} - name: Build ${{ env.PACKAGE_NAME }} + consumers run: | python -c "from urllib.request import urlretrieve; urlretrieve('${{ env.BUILDER_HOST }}/${{ env.BUILDER_SOURCE }}/${{ env.BUILDER_VERSION }}/builder.pyz?run=${{ env.RUN }}', 'builder.pyz')" @@ -117,6 +136,10 @@ jobs: windows-vc14-x86: runs-on: windows-2019 # windows-2019 is last env with Visual Studio 2015 (v14.0) steps: + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.CRT_CI_ROLE }} + aws-region: ${{ env.AWS_DEFAULT_REGION }} - name: Build ${{ env.PACKAGE_NAME }} + consumers env: AWS_CMAKE_TOOLSET: v140 @@ -132,6 +155,10 @@ jobs: macos: runs-on: macos-14 # latest steps: + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.CRT_CI_ROLE }} + aws-region: ${{ env.AWS_DEFAULT_REGION }} - name: Build ${{ env.PACKAGE_NAME }} + consumers run: | python3 -c "from urllib.request import urlretrieve; urlretrieve('${{ env.BUILDER_HOST }}/${{ env.BUILDER_SOURCE }}/${{ env.BUILDER_VERSION }}/builder.pyz?run=${{ env.RUN }}', 'builder')" @@ -145,6 +172,10 @@ jobs: macos-x64: runs-on: macos-14-large # latest steps: + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.CRT_CI_ROLE }} + aws-region: ${{ env.AWS_DEFAULT_REGION }} - name: Build ${{ env.PACKAGE_NAME }} + consumers run: | python3 -c "from urllib.request import urlretrieve; urlretrieve('${{ env.BUILDER_HOST }}/${{ env.BUILDER_SOURCE }}/${{ env.BUILDER_VERSION }}/builder.pyz?run=${{ env.RUN }}', 'builder')" @@ -165,19 +196,27 @@ jobs: # check that docs can still build check-docs: - runs-on: ubuntu-20.04 # latest + runs-on: ubuntu-24.04 # latest steps: - - uses: actions/checkout@v4 - with: - submodules: true - - name: Check docs - run: | - npm ci - ./make-docs.sh + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.CRT_CI_ROLE }} + aws-region: ${{ env.AWS_DEFAULT_REGION }} + - uses: actions/checkout@v4 + with: + submodules: true + - name: Check docs + run: | + npm ci + ./make-docs.sh check-submodules: - runs-on: ubuntu-20.04 # latest + runs-on: ubuntu-24.04 # latest steps: + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.CRT_CI_ROLE }} + aws-region: ${{ env.AWS_DEFAULT_REGION }} - name: Checkout Source uses: actions/checkout@v4 with: @@ -189,11 +228,15 @@ jobs: uses: awslabs/aws-crt-builder/.github/actions/check-submodules@main check-lockfile-version: - runs-on: ubuntu-20.04 # latest + runs-on: ubuntu-24.04 # latest steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Check for edits to package-lock.json - run: | - test `jq -r '.lockfileVersion' package-lock.json` = 1 + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.CRT_CI_ROLE }} + aws-region: ${{ env.AWS_DEFAULT_REGION }} + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check for edits to package-lock.json + run: | + test `jq -r '.lockfileVersion' package-lock.json` = 1 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 961e80d48..174f4c4c7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -9,12 +9,12 @@ on: jobs: update-docs-branch: - runs-on: ubuntu-20.04 # latest + runs-on: ubuntu-24.04 # latest permissions: contents: write # allow push steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: true