From 6e92b9d7dd0b819f93d3e3921dcf7d93da03637c Mon Sep 17 00:00:00 2001 From: SravanThotakura05 <83568543+SravanThotakura05@users.noreply.github.com> Date: Mon, 18 Dec 2023 12:25:18 +0530 Subject: [PATCH 1/3] solace quarkus extension --- .gitignore | 4 + LICENSE | 201 ++++++ deployment/pom.xml | 77 +++ .../solace/deployment/DevServicesConfig.java | 58 ++ .../DevServicesSolaceProcessor.java | 294 +++++++++ .../solace/deployment/SolaceBuildItem.java | 7 + .../deployment/SolaceBuildTimeConfig.java | 60 ++ .../solace/deployment/SolaceProcessor.java | 104 +++ .../solace/test/SolaceContainer.java | 345 ++++++++++ .../solace/test/SolaceCustomizerTest.java | 58 ++ .../solace/test/SolaceDevModeTest.java | 23 + .../test/SolaceHelloWorldPersistentTest.java | 124 ++++ .../solace/test/SolaceHelloWorldTest.java | 105 ++++ .../solace/test/SolaceTestResource.java | 29 + docker-compose.yaml | 62 ++ docs/antora.yml | 5 + docs/modules/ROOT/assets/images/.keepme | 0 docs/modules/ROOT/examples/.keepme | 0 docs/modules/ROOT/nav.adoc | 1 + .../ROOT/pages/includes/attributes.adoc | 3 + .../ROOT/pages/includes/quarkus-solace.adoc | 190 ++++++ docs/modules/ROOT/pages/index.adoc | 27 + docs/pom.xml | 106 ++++ docs/templates/includes/attributes.adoc | 3 + integration-tests/pom.xml | 15 + .../solace-client-integration-tests/pom.xml | 123 ++++ .../io/quarkiverse/solace/SolaceConsumer.java | 60 ++ .../quarkiverse/solace/SolaceCustomizer.java | 13 + .../io/quarkiverse/solace/SolaceResource.java | 61 ++ .../java/io/quarkiverse/solace/SolaceIT.java | 8 + .../io/quarkiverse/solace/SolaceTest.java | 59 ++ pom.xml | 104 +++ pubsub-plus-connector/pom.xml | 168 +++++ .../quarkiverse/solace/SolaceConnector.java | 161 +++++ .../converters/SolaceMessageConverter.java | 28 + .../solace/i18n/SolaceExceptions.java | 24 + .../solace/i18n/SolaceLogging.java | 43 ++ .../incoming/OutboundErrorMessageMapper.java | 28 + .../solace/incoming/SettleMetadata.java | 28 + .../solace/incoming/SolaceAckHandler.java | 23 + .../SolaceErrorTopicPublisherHandler.java | 64 ++ .../solace/incoming/SolaceFailureHandler.java | 46 ++ .../solace/incoming/SolaceInboundMessage.java | 132 ++++ .../incoming/SolaceInboundMetadata.java | 132 ++++ .../incoming/SolaceIncomingChannel.java | 217 +++++++ .../incoming/UnsignedCounterBarrier.java | 103 +++ .../solace/outgoing/SenderProcessor.java | 115 ++++ .../outgoing/SolaceOutboundMessage.java | 70 +++ .../outgoing/SolaceOutboundMetadata.java | 150 +++++ .../outgoing/SolaceOutgoingChannel.java | 228 +++++++ .../outgoing/UnsignedCounterBarrier.java | 103 +++ .../src/main/resources/META-INF/beans.xml | 0 .../solace/SolaceConsumerTest.java | 297 +++++++++ .../solace/SolaceProcessorTest.java | 84 +++ .../solace/SolacePublisherTest.java | 209 +++++++ .../solace/base/MessagingServiceProvider.java | 17 + .../solace/base/SolaceBaseTest.java | 59 ++ .../solace/base/SolaceBrokerExtension.java | 126 ++++ .../solace/base/SolaceContainer.java | 467 ++++++++++++++ .../quarkiverse/solace/base/WeldTestBase.java | 141 +++++ .../locals/LocalPropagationAckTest.java | 106 ++++ .../solace/locals/LocalPropagationTest.java | 592 ++++++++++++++++++ .../src/test/resources/log4j.properties | 7 + runtime/pom.xml | 65 ++ .../MessagingServiceClientCustomizer.java | 26 + .../solace/runtime/SolaceConfig.java | 30 + .../solace/runtime/SolaceRecorder.java | 68 ++ .../observability/SolaceHealthCheck.java | 26 + .../observability/SolaceMetricBinder.java | 25 + .../resources/META-INF/quarkus-extension.yaml | 9 + samples/hello-connector-solace/pom.xml | 103 +++ .../solace/samples/HelloConsumer.java | 70 +++ .../io/quarkiverse/solace/samples/Person.java | 15 + .../solace/samples/PublisherResource.java | 50 ++ .../src/main/resources/application.properties | 29 + samples/hello-solace/docker-compose.yaml | 62 ++ samples/hello-solace/pom.xml | 98 +++ .../solace/samples/HelloConsumer.java | 34 + .../solace/samples/PublisherResource.java | 33 + .../src/main/resources/application.properties | 4 + 80 files changed, 7044 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 deployment/pom.xml create mode 100644 deployment/src/main/java/io/quarkiverse/solace/deployment/DevServicesConfig.java create mode 100644 deployment/src/main/java/io/quarkiverse/solace/deployment/DevServicesSolaceProcessor.java create mode 100644 deployment/src/main/java/io/quarkiverse/solace/deployment/SolaceBuildItem.java create mode 100644 deployment/src/main/java/io/quarkiverse/solace/deployment/SolaceBuildTimeConfig.java create mode 100644 deployment/src/main/java/io/quarkiverse/solace/deployment/SolaceProcessor.java create mode 100644 deployment/src/test/java/io/quarkiverse/solace/test/SolaceContainer.java create mode 100644 deployment/src/test/java/io/quarkiverse/solace/test/SolaceCustomizerTest.java create mode 100644 deployment/src/test/java/io/quarkiverse/solace/test/SolaceDevModeTest.java create mode 100644 deployment/src/test/java/io/quarkiverse/solace/test/SolaceHelloWorldPersistentTest.java create mode 100644 deployment/src/test/java/io/quarkiverse/solace/test/SolaceHelloWorldTest.java create mode 100644 deployment/src/test/java/io/quarkiverse/solace/test/SolaceTestResource.java create mode 100644 docker-compose.yaml create mode 100644 docs/antora.yml create mode 100644 docs/modules/ROOT/assets/images/.keepme create mode 100644 docs/modules/ROOT/examples/.keepme create mode 100644 docs/modules/ROOT/nav.adoc create mode 100644 docs/modules/ROOT/pages/includes/attributes.adoc create mode 100644 docs/modules/ROOT/pages/includes/quarkus-solace.adoc create mode 100644 docs/modules/ROOT/pages/index.adoc create mode 100644 docs/pom.xml create mode 100644 docs/templates/includes/attributes.adoc create mode 100644 integration-tests/pom.xml create mode 100644 integration-tests/solace-client-integration-tests/pom.xml create mode 100644 integration-tests/solace-client-integration-tests/src/main/java/io/quarkiverse/solace/SolaceConsumer.java create mode 100644 integration-tests/solace-client-integration-tests/src/main/java/io/quarkiverse/solace/SolaceCustomizer.java create mode 100644 integration-tests/solace-client-integration-tests/src/main/java/io/quarkiverse/solace/SolaceResource.java create mode 100644 integration-tests/solace-client-integration-tests/src/test/java/io/quarkiverse/solace/SolaceIT.java create mode 100644 integration-tests/solace-client-integration-tests/src/test/java/io/quarkiverse/solace/SolaceTest.java create mode 100644 pom.xml create mode 100644 pubsub-plus-connector/pom.xml create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/SolaceConnector.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/converters/SolaceMessageConverter.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/i18n/SolaceExceptions.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/i18n/SolaceLogging.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/OutboundErrorMessageMapper.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SettleMetadata.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceAckHandler.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceErrorTopicPublisherHandler.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceFailureHandler.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceInboundMessage.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceInboundMetadata.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceIncomingChannel.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/UnsignedCounterBarrier.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SenderProcessor.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutboundMessage.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutboundMetadata.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutgoingChannel.java create mode 100644 pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/UnsignedCounterBarrier.java create mode 100644 pubsub-plus-connector/src/main/resources/META-INF/beans.xml create mode 100644 pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolaceConsumerTest.java create mode 100644 pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolaceProcessorTest.java create mode 100644 pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolacePublisherTest.java create mode 100644 pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/MessagingServiceProvider.java create mode 100644 pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceBaseTest.java create mode 100644 pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceBrokerExtension.java create mode 100644 pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceContainer.java create mode 100644 pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/WeldTestBase.java create mode 100644 pubsub-plus-connector/src/test/java/io/quarkiverse/solace/locals/LocalPropagationAckTest.java create mode 100644 pubsub-plus-connector/src/test/java/io/quarkiverse/solace/locals/LocalPropagationTest.java create mode 100644 pubsub-plus-connector/src/test/resources/log4j.properties create mode 100644 runtime/pom.xml create mode 100644 runtime/src/main/java/io/quarkiverse/solace/MessagingServiceClientCustomizer.java create mode 100644 runtime/src/main/java/io/quarkiverse/solace/runtime/SolaceConfig.java create mode 100644 runtime/src/main/java/io/quarkiverse/solace/runtime/SolaceRecorder.java create mode 100644 runtime/src/main/java/io/quarkiverse/solace/runtime/observability/SolaceHealthCheck.java create mode 100644 runtime/src/main/java/io/quarkiverse/solace/runtime/observability/SolaceMetricBinder.java create mode 100644 runtime/src/main/resources/META-INF/quarkus-extension.yaml create mode 100644 samples/hello-connector-solace/pom.xml create mode 100644 samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/HelloConsumer.java create mode 100644 samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/Person.java create mode 100644 samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/PublisherResource.java create mode 100644 samples/hello-connector-solace/src/main/resources/application.properties create mode 100644 samples/hello-solace/docker-compose.yaml create mode 100644 samples/hello-solace/pom.xml create mode 100644 samples/hello-solace/src/main/java/io/quarkiverse/solace/samples/HelloConsumer.java create mode 100644 samples/hello-solace/src/main/java/io/quarkiverse/solace/samples/PublisherResource.java create mode 100644 samples/hello-solace/src/main/resources/application.properties diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6f96de --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +.DS_Store +target/* +target/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/deployment/pom.xml b/deployment/pom.xml new file mode 100644 index 0000000..73e26d4 --- /dev/null +++ b/deployment/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + io.quarkiverse.solace + quarkus-solace-parent + 999-SNAPSHOT + + quarkus-solace-deployment + Quarkus Solace - Deployment + + + io.quarkus + quarkus-arc-deployment + + + io.quarkus + quarkus-smallrye-health-spi + + + io.quarkiverse.solace + quarkus-solace + ${project.version} + + + io.quarkus + quarkus-devservices-deployment + + + io.quarkus + quarkus-junit5-internal + test + + + org.testcontainers + testcontainers + + + junit + junit + + + + + io.quarkus + quarkus-junit4-mock + + + org.awaitility + awaitility + test + + + org.assertj + assertj-core + 3.24.2 + test + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/deployment/src/main/java/io/quarkiverse/solace/deployment/DevServicesConfig.java b/deployment/src/main/java/io/quarkiverse/solace/deployment/DevServicesConfig.java new file mode 100644 index 0000000..1ef9c64 --- /dev/null +++ b/deployment/src/main/java/io/quarkiverse/solace/deployment/DevServicesConfig.java @@ -0,0 +1,58 @@ +package io.quarkiverse.solace.deployment; + +import java.util.Map; +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.smallrye.config.WithDefault; + +@ConfigGroup +public interface DevServicesConfig { + + /** + * If DevServices has been explicitly enabled or disabled. DevServices is generally enabled + * by default, unless there is an existing configuration present. + *

+ * When DevServices is enabled Quarkus will attempt to automatically configure and start + * the Solace broker when running in Dev or Test mode and when Docker is running. + */ + @WithDefault("true") + boolean enabled(); + + /** + * The container image name to use, for container based DevServices providers. + */ + Optional imageName(); + + /** + * Indicates if the Solace broker managed by Quarkus Dev Services is shared. + * When shared, Quarkus looks for running containers using label-based service discovery. + * If a matching container is found, it is used, and so a second one is not started. + * Otherwise, Dev Services for Solace starts a new container. + *

+ * The discovery uses the {@code quarkus-dev-service-solace} label. + * The value is configured using the {@code service-name} property. + *

+ * Container sharing is only used in dev mode. + */ + @WithDefault("true") + boolean shared(); + + /** + * The value of the {@code quarkus-dev-service-solace} label attached to the started container. + * This property is used when {@code shared} is set to {@code true}. + * In this case, before starting a container, Dev Services for Solace looks for a container with the + * {@code quarkus-dev-service-solace} label + * set to the configured value. If found, it will use this container instead of starting a new one. Otherwise, it + * starts a new container with the {@code quarkus-dev-service-solace} label set to the specified value. + *

+ * This property is used when you need multiple shared Solace broker. + */ + @WithDefault("solace") + String serviceName(); + + /** + * Environment variables that are passed to the container. + */ + Map containerEnv(); +} diff --git a/deployment/src/main/java/io/quarkiverse/solace/deployment/DevServicesSolaceProcessor.java b/deployment/src/main/java/io/quarkiverse/solace/deployment/DevServicesSolaceProcessor.java new file mode 100644 index 0000000..5a5547a --- /dev/null +++ b/deployment/src/main/java/io/quarkiverse/solace/deployment/DevServicesSolaceProcessor.java @@ -0,0 +1,294 @@ +package io.quarkiverse.solace.deployment; + +import static io.quarkus.runtime.LaunchMode.DEVELOPMENT; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; + +import org.jboss.logging.Logger; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import com.github.dockerjava.api.model.Ulimit; + +import io.quarkus.deployment.IsNormal; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem; +import io.quarkus.deployment.builditem.DevServicesResultBuildItem; +import io.quarkus.deployment.builditem.DevServicesResultBuildItem.RunningDevService; +import io.quarkus.deployment.builditem.DevServicesSharedNetworkBuildItem; +import io.quarkus.deployment.builditem.DockerStatusBuildItem; +import io.quarkus.deployment.builditem.LaunchModeBuildItem; +import io.quarkus.deployment.console.ConsoleInstalledBuildItem; +import io.quarkus.deployment.console.StartupLogCompressor; +import io.quarkus.deployment.dev.devservices.GlobalDevServicesConfig; +import io.quarkus.deployment.logging.LoggingSetupBuildItem; +import io.quarkus.devservices.common.ConfigureUtil; +import io.quarkus.devservices.common.ContainerLocator; +import io.quarkus.runtime.LaunchMode; +import io.quarkus.runtime.configuration.ConfigUtils; + +@BuildSteps(onlyIfNot = IsNormal.class, onlyIf = { GlobalDevServicesConfig.Enabled.class }) +public class DevServicesSolaceProcessor { + private static final Logger log = Logger.getLogger(DevServicesSolaceProcessor.class); + private static final String SOLACE_IMAGE = "solace/solace-pubsub-standard:latest"; + private static final String SOLACE_READY_MESSAGE = ".*Primary Virtual Router is now active.*"; + + /** + * Label to add to shared Dev Service for Solace running in containers. + * This allows other applications to discover the running service and use it instead of starting a new instance. + */ + private static final String DEV_SERVICE_LABEL = "quarkus-dev-service-solace"; + + private static final ContainerLocator solaceContainerLocator = new ContainerLocator(DEV_SERVICE_LABEL, 55555); + private static volatile RunningDevService running; + private static volatile SolaceDevServiceConfig cfg; + private static volatile boolean first = true; + + @BuildStep + public DevServicesResultBuildItem startSolaceContainer(LaunchModeBuildItem launchMode, + DockerStatusBuildItem dockerStatusBuildItem, + List devServicesSharedNetworkBuildItem, + SolaceBuildTimeConfig config, + Optional consoleInstalledBuildItem, + CuratedApplicationShutdownBuildItem closeBuildItem, + LoggingSetupBuildItem loggingSetupBuildItem, + GlobalDevServicesConfig devServicesConfig) { + + SolaceDevServiceConfig configuration = getConfiguration(config); + + if (running != null) { + boolean shouldShutdownTheBroker = !configuration.equals(cfg); + if (!shouldShutdownTheBroker) { + return running.toBuildItem(); + } + try { + running.close(); + } catch (Throwable e) { + log.error("Failed to stop solace container", e); + } + running = null; + cfg = null; + } + + StartupLogCompressor compressor = new StartupLogCompressor( + (launchMode.isTest() ? "(test) " : "") + "Solace Dev Services Starting:", consoleInstalledBuildItem, + loggingSetupBuildItem); + try { + RunningDevService devService = startContainer(dockerStatusBuildItem, + configuration, + launchMode.getLaunchMode(), + !devServicesSharedNetworkBuildItem.isEmpty(), devServicesConfig.timeout); + + if (devService != null) { + String configKey = "quarkus.solace.host"; + log.infof("The solace broker is ready to accept connections on %s", + devService.getConfig().get(configKey)); + compressor.close(); + running = devService; + } else { + compressor.closeAndDumpCaptured(); + return null; + } + } catch (Throwable t) { + compressor.closeAndDumpCaptured(); + throw new RuntimeException(t); + } + + if (first) { + first = false; + Runnable closeTask = () -> { + if (running != null) { + try { + running.close(); + } catch (Throwable t) { + log.error("Failed to stop database", t); + } + } + first = true; + running = null; + cfg = null; + }; + closeBuildItem.addCloseTask(closeTask, true); + } + cfg = configuration; + + return running.toBuildItem(); + + } + + private SolaceDevServiceConfig getConfiguration(SolaceBuildTimeConfig config) { + SolaceBuildTimeConfig.DevServiceConfiguration cfg = config.defaultDevService(); + return new SolaceDevServiceConfig(cfg); + } + + private RunningDevService startContainer(DockerStatusBuildItem dockerStatusBuildItem, + SolaceDevServiceConfig devServicesConfig, LaunchMode launchMode, + boolean useSharedNetwork, Optional timeout) { + if (!devServicesConfig.enabled) { + // explicitly disabled + log.debug("Not starting devservices for solace as it has been disabled in the config"); + return null; + } + + boolean needToStart = !ConfigUtils.isPropertyPresent("quarkus.solace.host"); + if (!needToStart) { + log.debug("Not starting devservices for solace as `quarkus.solace.host` have been provided"); + return null; + } + + if (!dockerStatusBuildItem.isDockerAvailable()) { + log.warn( + "Please configure `quarkus.solace.host` to point to a running Solace instance or get a working docker instance"); + return null; + } + + DockerImageName dockerImageName = DockerImageName.parse(devServicesConfig.imageName) + .asCompatibleSubstituteFor(SOLACE_IMAGE); + + Supplier supplier = () -> { + QuarkusSolaceContainer container = new QuarkusSolaceContainer(dockerImageName, + launchMode == DEVELOPMENT ? devServicesConfig.serviceName : null, useSharedNetwork); + timeout.ifPresent(container::withStartupTimeout); + container.withEnv(devServicesConfig.containerEnv); + container.start(); + + String host = container.getHost() + ":" + container.getPort(); + Map config = Map.of("quarkus.solace.host", host, + "quarkus.solace.vpn", "default", + "quarkus.solace.authentication.basic.username", "admin", + "quarkus.solace.authentication.basic.password", "admin"); + return new RunningDevService("solace", container.getContainerId(), + container::close, config); + }; + + return solaceContainerLocator.locateContainer(devServicesConfig.serviceName, devServicesConfig.shared, launchMode) + .map(containerAddress -> { + String host = containerAddress.getUrl(); + Map config = Map.of("quarkus.solace.host", host, + "quarkus.solace.vpn", "default", + "quarkus.solace.authentication.basic.username", "admin", + "quarkus.solace.authentication.basic.password", "admin"); + return new RunningDevService("solace", containerAddress.getId(), + null, config); + }) + .orElseGet(supplier); + } + + private static class QuarkusSolaceContainer extends GenericContainer { + private final boolean useSharedNetwork; + + private String hostName = null; + + public QuarkusSolaceContainer(DockerImageName dockerImageName, String serviceName, + boolean useSharedNetwork) { + super(dockerImageName); + + withEnv("system_scaling_maxconnectioncount", "100"); + withEnv("logging_system_output", "all"); + + addExposedPort(8008); // Web Transport + addExposedPort(1443);// Web transport over TLS + addExposedPort(1943); // SEMP over TLS + addExposedPort(1883); // MQTT Default VPN + addExposedPort(5671); // AMQP Default VPN over TLS + addExposedPort(5672); // AMQP Default VPN + addExposedPort(8000);// MQTT Default VPN over WebSockets + addExposedPort(8443); // MQTT Default VPN over WebSockets / TLS + addExposedPort(8883); // MQTT Default VPN over TLS + addExposedPort(8080); // SEMP / PubSub+ Manager + addExposedPort(9000); // REST Default VPN + addExposedPort(9443); // REST Default VPN over TLS + addExposedPort(55555); // SMF + addExposedPort(55003); // SMF Compressed + addExposedPort(55443); // SMF over TLS + addExposedPort(2222); // SSH connection to CLI + + withCreateContainerCmdModifier(cmd -> { + cmd.getHostConfig().withShmSize((long) Math.pow(1024, 3)) + .withUlimits(new Ulimit[] { + new Ulimit("core", -1, -1), + new Ulimit("memlock", -1, -1), + new Ulimit("nofile", 2448L, 42192L), + }) + .withCpusetCpus("0-1") + .withMemorySwap(-1L) + .withMemoryReservation(0L); + }); + + withEnv("username_admin_globalaccesslevel", "admin"); + withEnv("username_admin_password", "admin"); + + this.useSharedNetwork = useSharedNetwork; + + if (serviceName != null) { + withLabel(DEV_SERVICE_LABEL, serviceName); + } + + this.hostName = "solace"; + setWaitStrategy(Wait.forLogMessage(SOLACE_READY_MESSAGE, 1).withStartupTimeout(Duration.ofSeconds(60))); + } + + @Override + protected void configure() { + super.configure(); + + if (useSharedNetwork) { + hostName = ConfigureUtil.configureSharedNetwork(this, "solace"); + return; + } + } + + public int getPort() { + if (useSharedNetwork) { + return 55555; + } + + return super.getMappedPort(55555); + } + + @Override + public String getHost() { + return useSharedNetwork ? hostName : super.getHost(); + } + } + + private static class SolaceDevServiceConfig { + + final boolean enabled; + final String serviceName; + final String imageName; + final boolean shared; + final Map containerEnv; + + public SolaceDevServiceConfig(SolaceBuildTimeConfig.DevServiceConfiguration cfg) { + enabled = cfg.devservices().enabled(); + serviceName = cfg.devservices().serviceName(); + imageName = cfg.devservices().imageName().orElse(SOLACE_IMAGE); + shared = cfg.devservices().shared(); + containerEnv = cfg.devservices().containerEnv(); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + SolaceDevServiceConfig that = (SolaceDevServiceConfig) o; + return enabled == that.enabled && shared == that.shared && Objects.equals(serviceName, that.serviceName) + && Objects.equals(imageName, that.imageName) && Objects.equals(containerEnv, that.containerEnv); + } + + @Override + public int hashCode() { + return Objects.hash(enabled, serviceName, imageName, shared, containerEnv); + } + } +} diff --git a/deployment/src/main/java/io/quarkiverse/solace/deployment/SolaceBuildItem.java b/deployment/src/main/java/io/quarkiverse/solace/deployment/SolaceBuildItem.java new file mode 100644 index 0000000..16b209e --- /dev/null +++ b/deployment/src/main/java/io/quarkiverse/solace/deployment/SolaceBuildItem.java @@ -0,0 +1,7 @@ +package io.quarkiverse.solace.deployment; + +import io.quarkus.builder.item.SimpleBuildItem; + +public final class SolaceBuildItem extends SimpleBuildItem { + // Just a marker indicating the that Solace Messaging Service is available. +} diff --git a/deployment/src/main/java/io/quarkiverse/solace/deployment/SolaceBuildTimeConfig.java b/deployment/src/main/java/io/quarkiverse/solace/deployment/SolaceBuildTimeConfig.java new file mode 100644 index 0000000..33d3a97 --- /dev/null +++ b/deployment/src/main/java/io/quarkiverse/solace/deployment/SolaceBuildTimeConfig.java @@ -0,0 +1,60 @@ +package io.quarkiverse.solace.deployment; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import io.smallrye.config.WithParentName; + +@ConfigMapping(prefix = "quarkus.solace") +@ConfigRoot(phase = ConfigPhase.BUILD_TIME) +public interface SolaceBuildTimeConfig { + /** + * Metrics configuration. + */ + MetricsConfig metrics(); + + /** + * Health configuration. + */ + HealthConfig health(); + + /** + * Default Dev services configuration. + */ + @WithParentName + DevServiceConfiguration defaultDevService(); + + @ConfigGroup + public interface DevServiceConfiguration { + /** + * Configuration for DevServices + *

+ * DevServices allows Quarkus to automatically start Solace in dev and test mode. + */ + DevServicesConfig devservices(); + } + + /** + * Metrics configuration. + */ + interface MetricsConfig { + /** + * Whether a metrics is enabled in case the micrometer is present. + */ + @WithDefault("true") + boolean enabled(); + } + + /** + * Health configuration. + */ + interface HealthConfig { + /** + * Whether the liveness health check should be exposed if the smallrye-health extension is present. + */ + @WithDefault("true") + boolean enabled(); + } +} diff --git a/deployment/src/main/java/io/quarkiverse/solace/deployment/SolaceProcessor.java b/deployment/src/main/java/io/quarkiverse/solace/deployment/SolaceProcessor.java new file mode 100644 index 0000000..c6da302 --- /dev/null +++ b/deployment/src/main/java/io/quarkiverse/solace/deployment/SolaceProcessor.java @@ -0,0 +1,104 @@ +package io.quarkiverse.solace.deployment; + +import java.util.Optional; +import java.util.function.Function; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; + +import org.jboss.jandex.*; + +import com.solace.messaging.MessagingService; +import com.solacesystems.jcsmp.JCSMPFactory; + +import io.quarkiverse.solace.MessagingServiceClientCustomizer; +import io.quarkiverse.solace.runtime.SolaceConfig; +import io.quarkiverse.solace.runtime.SolaceRecorder; +import io.quarkiverse.solace.runtime.observability.SolaceMetricBinder; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem; +import io.quarkus.arc.deployment.UnremovableBeanBuildItem; +import io.quarkus.deployment.annotations.*; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.ExtensionSslNativeSupportBuildItem; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.ServiceStartBuildItem; +import io.quarkus.deployment.builditem.ShutdownContextBuildItem; +import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; +import io.quarkus.deployment.metrics.MetricsCapabilityBuildItem; +import io.quarkus.runtime.metrics.MetricsFactory; +import io.quarkus.smallrye.health.deployment.spi.HealthBuildItem; + +class SolaceProcessor { + + private static final String FEATURE = "solace"; + + private static final ParameterizedType SOLACE_CUSTOMIZER_INJECTION_TYPE = ParameterizedType.create( + DotName.createSimple(Instance.class), + new Type[] { ClassType.create(DotName.createSimple(MessagingServiceClientCustomizer.class.getName())) }, null); + + private static final AnnotationInstance[] EMPTY_ANNOTATIONS = new AnnotationInstance[0]; + + @BuildStep + FeatureBuildItem feature() { + return new FeatureBuildItem(FEATURE); + } + + @BuildStep + void registerBean(BuildProducer producer) { + producer.produce(UnremovableBeanBuildItem.beanTypes(MessagingServiceClientCustomizer.class)); + } + + @BuildStep + ExtensionSslNativeSupportBuildItem ssl() { + return new ExtensionSslNativeSupportBuildItem(FEATURE); + } + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + ServiceStartBuildItem init( + SolaceConfig config, SolaceRecorder recorder, + ShutdownContextBuildItem shutdown, BuildProducer syntheticBeans) { + + Function, MessagingService> function = recorder.init(config, shutdown); + + SyntheticBeanBuildItem.ExtendedBeanConfigurator solaceConfigurator = SyntheticBeanBuildItem + .configure(MessagingService.class) + .defaultBean() + .scope(ApplicationScoped.class) + .addInjectionPoint(SOLACE_CUSTOMIZER_INJECTION_TYPE, EMPTY_ANNOTATIONS) + .createWith(function) + .unremovable() + .setRuntimeInit(); + + syntheticBeans.produce(solaceConfigurator.done()); + + return new ServiceStartBuildItem(FEATURE); + } + + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + @Consume(SyntheticBeansRuntimeInitBuildItem.class) + @Consume(SolaceBuildItem.class) + void initMetrics(SolaceBuildTimeConfig btConfig, Optional metrics, + SolaceMetricBinder metricRecorder) { + if (metrics.isPresent() && btConfig.metrics().enabled()) { + if (metrics.get().metricsSupported(MetricsFactory.MICROMETER)) { + metricRecorder.initMetrics(); + } + } + } + + @BuildStep + void configureNativeCompilation(BuildProducer producer) { + producer.produce(new RuntimeInitializedClassBuildItem(JCSMPFactory.class.getName())); + } + + @BuildStep + HealthBuildItem addHealthCheck(SolaceBuildTimeConfig buildTimeConfig) { + return new HealthBuildItem("io.quarkiverse.solace.runtime.observability.SolaceHealthCheck", + buildTimeConfig.health().enabled()); + } + +} diff --git a/deployment/src/test/java/io/quarkiverse/solace/test/SolaceContainer.java b/deployment/src/test/java/io/quarkiverse/solace/test/SolaceContainer.java new file mode 100644 index 0000000..79637cd --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/solace/test/SolaceContainer.java @@ -0,0 +1,345 @@ +package io.quarkiverse.solace.test; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.shaded.org.apache.commons.lang3.tuple.Pair; +import org.testcontainers.shaded.org.awaitility.Awaitility; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.Ulimit; + +public class SolaceContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("solace/solace-pubsub-standard"); + + private static final String DEFAULT_VPN = "default"; + + private static final String DEFAULT_USERNAME = "default"; + + private static final String SOLACE_READY_MESSAGE = ".*Running pre-startup checks:.*"; + + private static final String SOLACE_ACTIVE_MESSAGE = "Primary Virtual Router is now active"; + + private static final String TMP_SCRIPT_LOCATION = "/tmp/script.cli"; + + private static final Long SHM_SIZE = (long) Math.pow(1024, 3); + + private String username = "root"; + + private String password = "password"; + + private String vpn = DEFAULT_VPN; + + private final List> topicsConfiguration = new ArrayList<>(); + + private boolean withClientCert; + + /** + * Create a new solace container with the specified image name. + * + * @param dockerImageName the image name that should be used. + */ + public SolaceContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public SolaceContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withCreateContainerCmdModifier(cmd -> { + cmd.getHostConfig().withShmSize(SHM_SIZE).withUlimits(new Ulimit[] { new Ulimit("nofile", 2448L, 6592L) }); + }); + this.waitStrategy = Wait.forLogMessage(SOLACE_READY_MESSAGE, 1).withStartupTimeout(Duration.ofSeconds(60)); + withExposedPorts(8080); + withEnv("username_admin_globalaccesslevel", "admin"); + withEnv("username_admin_password", "admin"); + } + + @Override + protected void configure() { + withCopyToContainer(createConfigurationScript(), TMP_SCRIPT_LOCATION); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + if (withClientCert) { + executeCommand("cp", "/tmp/solace.pem", "/usr/sw/jail/certs/solace.pem"); + executeCommand("cp", "/tmp/rootCA.crt", "/usr/sw/jail/certs/rootCA.crt"); + } + executeCommand("cp", TMP_SCRIPT_LOCATION, "/usr/sw/jail/cliscripts/script.cli"); + waitOnCommandResult(SOLACE_ACTIVE_MESSAGE, "grep", "-R", SOLACE_ACTIVE_MESSAGE, "/usr/sw/jail/logs/system.log"); + executeCommand("/usr/sw/loads/currentload/bin/cli", "-A", "-es", "script.cli"); + } + + private Transferable createConfigurationScript() { + StringBuilder scriptBuilder = new StringBuilder(); + updateConfigScript(scriptBuilder, "enable"); + updateConfigScript(scriptBuilder, "configure"); + + // updateConfigScript(scriptBuilder, "client-profile default"); + // updateConfigScript(scriptBuilder, "allow-guaranteed-message-receive"); + // updateConfigScript(scriptBuilder, "allow-guaranteed-message-send"); + // updateConfigScript(scriptBuilder, "exit"); + + // Create VPN if not default + if (!vpn.equals(DEFAULT_VPN)) { + updateConfigScript(scriptBuilder, "create message-vpn " + vpn); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "exit"); + } + + // Configure username and password + if (username.equals(DEFAULT_USERNAME)) { + throw new RuntimeException("Cannot override password for default client"); + } + updateConfigScript(scriptBuilder, "create client-username " + username + " message-vpn " + vpn); + updateConfigScript(scriptBuilder, "password " + password); + // updateConfigScript(scriptBuilder, "client-profile default"); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "exit"); + + if (withClientCert) { + // Client certificate authority configuration + updateConfigScript(scriptBuilder, "authentication"); + updateConfigScript(scriptBuilder, "create client-certificate-authority RootCA"); + updateConfigScript(scriptBuilder, "certificate file rootCA.crt"); + updateConfigScript(scriptBuilder, "show client-certificate-authority ca-name *"); + updateConfigScript(scriptBuilder, "end"); + + // Server certificates configuration + updateConfigScript(scriptBuilder, "configure"); + updateConfigScript(scriptBuilder, "ssl"); + updateConfigScript(scriptBuilder, "server-certificate solace.pem"); + updateConfigScript(scriptBuilder, "cipher-suite msg-backbone name AES128-SHA"); + updateConfigScript(scriptBuilder, "exit"); + + updateConfigScript(scriptBuilder, "message-vpn " + vpn); + // Enable client certificate authentication + updateConfigScript(scriptBuilder, "authentication client-certificate"); + updateConfigScript(scriptBuilder, "allow-api-provided-username"); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "end"); + } else { + // Configure VPN Basic authentication + updateConfigScript(scriptBuilder, "message-vpn " + vpn); + updateConfigScript(scriptBuilder, "authentication basic auth-type internal"); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "end"); + } + + if (!topicsConfiguration.isEmpty()) { + // Enable services + updateConfigScript(scriptBuilder, "configure"); + // Configure default ACL + updateConfigScript(scriptBuilder, "acl-profile default message-vpn " + vpn); + // Configure default action to disallow + updateConfigScript(scriptBuilder, "subscribe-topic default-action disallow"); + updateConfigScript(scriptBuilder, "publish-topic default-action disallow"); + updateConfigScript(scriptBuilder, "exit"); + + updateConfigScript(scriptBuilder, "message-vpn " + vpn); + updateConfigScript(scriptBuilder, "service"); + for (Pair topicConfig : topicsConfiguration) { + Service service = topicConfig.getValue(); + String topicName = topicConfig.getKey(); + updateConfigScript(scriptBuilder, service.getName()); + if (service.isSupportSSL()) { + if (withClientCert) { + updateConfigScript(scriptBuilder, "ssl"); + } else { + updateConfigScript(scriptBuilder, "plain-text"); + } + } + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "end"); + // Add publish/subscribe topic exceptions + updateConfigScript(scriptBuilder, "configure"); + updateConfigScript(scriptBuilder, "acl-profile default message-vpn " + vpn); + updateConfigScript( + scriptBuilder, + String.format("publish-topic exceptions %s list %s", service.getName(), topicName)); + updateConfigScript( + scriptBuilder, + String.format("subscribe-topic exceptions %s list %s", service.getName(), topicName)); + updateConfigScript(scriptBuilder, "end"); + } + } + return Transferable.of(scriptBuilder.toString()); + } + + private void executeCommand(String... command) { + try { + ExecResult execResult = execInContainer(command); + if (execResult.getExitCode() != 0) { + logCommandError(execResult.getStderr(), command); + } + } catch (IOException | InterruptedException e) { + logCommandError(e.getMessage(), command); + } + } + + private void updateConfigScript(StringBuilder scriptBuilder, String command) { + scriptBuilder.append(command).append("\n"); + } + + private void waitOnCommandResult(String waitingFor, String... command) { + Awaitility + .await() + .pollInterval(Duration.ofMillis(500)) + .timeout(Duration.ofSeconds(30)) + .until(() -> { + try { + return execInContainer(command).getStdout().contains(waitingFor); + } catch (IOException | InterruptedException e) { + logCommandError(e.getMessage(), command); + return true; + } + }); + } + + private void logCommandError(String error, String... command) { + logger().error("Could not execute command {}: {}", command, error); + } + + /** + * Sets the client credentials + * + * @param username Client username + * @param password Client password + * @return This container. + */ + public SolaceContainer withCredentials(final String username, final String password) { + this.username = username; + this.password = password; + return this; + } + + /** + * Adds the topic configuration + * + * @param topic Name of the topic + * @param service Service to be supported on provided topic + * @return This container. + */ + public SolaceContainer withTopic(String topic, Service service) { + topicsConfiguration.add(Pair.of(topic, service)); + addExposedPort(service.getPort()); + return this; + } + + /** + * Sets the VPN name + * + * @param vpn VPN name + * @return This container. + */ + public SolaceContainer withVpn(String vpn) { + this.vpn = vpn; + return this; + } + + /** + * Sets the solace server ceritificates + * + * @param certFile Server certificate + * @param caFile Certified Authority ceritificate + * @return This container. + */ + public SolaceContainer withClientCert(final MountableFile certFile, final MountableFile caFile) { + this.withClientCert = true; + return withCopyFileToContainer(certFile, "/tmp/solace.pem").withCopyFileToContainer(caFile, "/tmp/rootCA.crt"); + } + + /** + * Configured VPN + * + * @return the configured VPN that should be used for connections + */ + public String getVpn() { + return this.vpn; + } + + /** + * Host address for provided service + * + * @param service - service for which host needs to be retrieved + * @return host address exposed from the container + */ + public String getOrigin(Service service) { + return String.format("%s://%s:%s", service.getProtocol(), getHost(), getMappedPort(service.getPort())); + } + + /** + * Configured username + * + * @return the standard username that should be used for connections + */ + public String getUsername() { + return this.username; + } + + /** + * Configured password + * + * @return the standard password that should be used for connections + */ + public String getPassword() { + return this.password; + } + + public enum Service { + AMQP("amqp", 5672, "amqp", false), + MQTT("mqtt", 1883, "tcp", false), + REST("rest", 9000, "http", false), + SMF("smf", 55555, "tcp", true), + SMF_SSL("smf", 55443, "tcps", true); + + private final String name; + private final Integer port; + private final String protocol; + private final boolean supportSSL; + + Service(String name, Integer port, String protocol, boolean supportSSL) { + this.name = name; + this.port = port; + this.protocol = protocol; + this.supportSSL = supportSSL; + } + + /** + * @return Port assigned for the service + */ + public Integer getPort() { + return this.port; + } + + /** + * @return Protocol of the service + */ + public String getProtocol() { + return this.protocol; + } + + /** + * @return Name of the service + */ + public String getName() { + return this.name; + } + + /** + * @return Is SSL for this service supported ? + */ + public boolean isSupportSSL() { + return this.supportSSL; + } + } +} diff --git a/deployment/src/test/java/io/quarkiverse/solace/test/SolaceCustomizerTest.java b/deployment/src/test/java/io/quarkiverse/solace/test/SolaceCustomizerTest.java new file mode 100644 index 0000000..5379cc3 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/solace/test/SolaceCustomizerTest.java @@ -0,0 +1,58 @@ +package io.quarkiverse.solace.test; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.atomic.AtomicBoolean; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.MessagingServiceClientBuilder; +import com.solace.messaging.config.RetryStrategy; +import com.solace.messaging.publisher.DirectMessagePublisher; + +import io.quarkiverse.solace.MessagingServiceClientCustomizer; +import io.quarkus.test.QuarkusUnitTest; + +public class SolaceCustomizerTest { + + @RegisterExtension + static final QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(MyCustomizer.class)); + + @Inject + MyCustomizer customizer; + @Inject + MessagingService solace; + + @Test + public void test() { + DirectMessagePublisher publisher = solace.createDirectMessagePublisherBuilder() + .build().start(); + assertThat(customizer.called()).isTrue(); + publisher.terminate(1); + } + + @Singleton + public static class MyCustomizer implements MessagingServiceClientCustomizer { + + AtomicBoolean called = new AtomicBoolean(); + + @Override + public MessagingServiceClientBuilder customize(MessagingServiceClientBuilder builder) { + called.set(true); + return builder.withReconnectionRetryStrategy(RetryStrategy.neverRetry()); + } + + public boolean called() { + return called.get(); + } + } +} diff --git a/deployment/src/test/java/io/quarkiverse/solace/test/SolaceDevModeTest.java b/deployment/src/test/java/io/quarkiverse/solace/test/SolaceDevModeTest.java new file mode 100644 index 0000000..97d545c --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/solace/test/SolaceDevModeTest.java @@ -0,0 +1,23 @@ +package io.quarkiverse.solace.test; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusDevModeTest; + +public class SolaceDevModeTest { + + // Start hot reload (DevMode) test with your extension loaded + @RegisterExtension + static final QuarkusDevModeTest devModeTest = new QuarkusDevModeTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)); + + @Test + public void writeYourOwnDevModeTest() { + // Write your dev mode tests here - see the testing extension guide https://quarkus.io/guides/writing-extensions#testing-hot-reload for more information + Assertions.assertTrue(true, "Add dev mode assertions to " + getClass().getName()); + } +} diff --git a/deployment/src/test/java/io/quarkiverse/solace/test/SolaceHelloWorldPersistentTest.java b/deployment/src/test/java/io/quarkiverse/solace/test/SolaceHelloWorldPersistentTest.java new file mode 100644 index 0000000..f2a0ede --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/solace/test/SolaceHelloWorldPersistentTest.java @@ -0,0 +1,124 @@ +package io.quarkiverse.solace.test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.config.MissingResourcesCreationConfiguration; +import com.solace.messaging.publisher.OutboundMessage; +import com.solace.messaging.publisher.PersistentMessagePublisher; +import com.solace.messaging.receiver.InboundMessage; +import com.solace.messaging.receiver.PersistentMessageReceiver; +import com.solace.messaging.resources.Queue; +import com.solace.messaging.resources.Topic; +import com.solace.messaging.resources.TopicSubscription; + +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupEvent; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.QuarkusTestResource; + +/** + * Based on + * Hello + * World + * but use persistent messaging. + */ +@QuarkusTestResource(SolaceTestResource.class) +public class SolaceHelloWorldPersistentTest { + + @RegisterExtension + static final QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(HelloWorldReceiver.class, HelloWorldPublisher.class)); + + @Inject + HelloWorldPublisher publisher; + @Inject + HelloWorldReceiver receiver; + + @Inject + MessagingService service; + + @Test + public void hello() { + publisher.send("Hello World 1"); + publisher.send("Hello World 2"); + publisher.send("Hello World 3"); + + await().until(() -> receiver.list().size() == 3); + + for (InboundMessage message : receiver.list()) { + assertThat(message.getPayloadAsString()).startsWith("Hello World"); + } + } + + @ApplicationScoped + public static class HelloWorldReceiver { + + @Inject + MessagingService messagingService; + private PersistentMessageReceiver receiver; + private final List list = new CopyOnWriteArrayList<>(); + + public void init(@Observes StartupEvent ev) { + receiver = messagingService.createPersistentMessageReceiverBuilder() + .withSubscriptions(TopicSubscription.of("hello/persistent")) + .withMissingResourcesCreationStrategy( + MissingResourcesCreationConfiguration.MissingResourcesCreationStrategy.CREATE_ON_START) + .build(Queue.durableExclusiveQueue("my-queue")).start(); + receiver.receiveAsync(m -> { + receiver.ack(m); + list.add(m); + }); + } + + public List list() { + return list; + } + + public void stop(@Observes ShutdownEvent ev) { + receiver.terminate(100); + } + } + + @ApplicationScoped + public static class HelloWorldPublisher { + @Inject + MessagingService messagingService; + private PersistentMessagePublisher publisher; + + public void init(@Observes StartupEvent ev) { + publisher = messagingService.createPersistentMessagePublisherBuilder() + .onBackPressureWait(1) + .build().start(); + } + + public void send(String message) { + String topicString = "hello/persistent"; + OutboundMessage om = messagingService.messageBuilder().build(message); + try { + publisher.publishAwaitAcknowledgement(om, Topic.of(topicString), 10000L); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + public void stop(@Observes ShutdownEvent ev) { + publisher.terminate(100); + } + } +} diff --git a/deployment/src/test/java/io/quarkiverse/solace/test/SolaceHelloWorldTest.java b/deployment/src/test/java/io/quarkiverse/solace/test/SolaceHelloWorldTest.java new file mode 100644 index 0000000..c6f6e2d --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/solace/test/SolaceHelloWorldTest.java @@ -0,0 +1,105 @@ +package io.quarkiverse.solace.test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.publisher.DirectMessagePublisher; +import com.solace.messaging.receiver.DirectMessageReceiver; +import com.solace.messaging.receiver.InboundMessage; +import com.solace.messaging.resources.Topic; +import com.solace.messaging.resources.TopicSubscription; + +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupEvent; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.QuarkusTestResource; + +/** + * Based on + * Hello + * World + */ +@QuarkusTestResource(SolaceTestResource.class) +public class SolaceHelloWorldTest { + + @RegisterExtension + static final QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(HelloWorldReceiver.class, HelloWorldPublisher.class)); + + @Inject + HelloWorldPublisher publisher; + @Inject + HelloWorldReceiver receiver; + + @Test + public void hello() { + publisher.send("Hello World 1"); + publisher.send("Hello World 2"); + publisher.send("Hello World 3"); + + await().until(() -> receiver.list().size() == 3); + + for (InboundMessage message : receiver.list()) { + assertThat(message.getPayloadAsString()).startsWith("Hello World"); + } + } + + @ApplicationScoped + public static class HelloWorldReceiver { + + @Inject + MessagingService messagingService; + private DirectMessageReceiver receiver; + private final List list = new CopyOnWriteArrayList<>(); + + public void init(@Observes StartupEvent ev) { + receiver = messagingService.createDirectMessageReceiverBuilder() + .withSubscriptions(TopicSubscription.of("hello/direct")).build().start(); + receiver.receiveAsync(list::add); + } + + public List list() { + return list; + } + + public void stop(@Observes ShutdownEvent ev) { + receiver.terminate(1); + } + } + + @ApplicationScoped + public static class HelloWorldPublisher { + @Inject + MessagingService messagingService; + private DirectMessagePublisher publisher; + + public void init(@Observes StartupEvent ev) { + publisher = messagingService.createDirectMessagePublisherBuilder() + .onBackPressureWait(1).build().start(); + } + + public void send(String message) { + String topicString = "hello/direct"; + publisher.publish(message, Topic.of(topicString)); + } + + public void stop(@Observes ShutdownEvent ev) { + publisher.terminate(1); + } + } +} diff --git a/deployment/src/test/java/io/quarkiverse/solace/test/SolaceTestResource.java b/deployment/src/test/java/io/quarkiverse/solace/test/SolaceTestResource.java new file mode 100644 index 0000000..4825369 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/solace/test/SolaceTestResource.java @@ -0,0 +1,29 @@ +package io.quarkiverse.solace.test; + +import java.util.Map; + +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; + +public class SolaceTestResource implements QuarkusTestResourceLifecycleManager { + + private static final String SOLACE_IMAGE = "solace/solace-pubsub-standard:latest"; + private SolaceContainer container; + + @Override + public Map start() { + container = new SolaceContainer(SOLACE_IMAGE) + .withCredentials("user", "pass") + .withTopic("hello/direct", SolaceContainer.Service.SMF) + .withTopic("hello/persistent", SolaceContainer.Service.SMF); + container.start(); + return Map.of("quarkus.solace.host", container.getHost() + ":" + container.getMappedPort(55555), + "quarkus.solace.vpn", "default", + "quarkus.solace.authentication.basic.username", "user", + "quarkus.solace.authentication.basic.password", "pass"); + } + + @Override + public void stop() { + container.stop(); + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..df247bb --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,62 @@ +# docker-compose -f PubSubStandard_singleNode.yml up +version: '3.3' + +services: + primary: + container_name: pubSubStandardSingleNode + image: solace/solace-pubsub-standard:latest + volumes: + - "storage-group:/var/lib/solace" + shm_size: 1g + ulimits: + core: -1 + nofile: + soft: 2448 + hard: 6592 + deploy: + restart_policy: + condition: on-failure + max_attempts: 1 + ports: + #Port Mappings: With the exception of SMF, ports are mapped straight + #through from host to container. This may result in port collisions on + #commonly used ports that will cause failure of the container to start. + #Web transport + - '8008:8008' + #Web transport over TLS + - '1443:1443' + #SEMP over TLS + - '1943:1943' + #MQTT Default VPN + - '1883:1883' + #AMQP Default VPN over TLS + - '5671:5671' + #AMQP Default VPN + - '5672:5672' + #MQTT Default VPN over WebSockets + - '8000:8000' + #MQTT Default VPN over WebSockets / TLS + - '8443:8443' + #MQTT Default VPN over TLS + - '8883:8883' + #SEMP / PubSub+ Manager + - '8080:8080' + #REST Default VPN + - '9000:9000' + #REST Default VPN over TLS + - '9443:9443' + #SMF + - '55554:55555' + #SMF Compressed + - '55003:55003' + #SMF over TLS + - '55443:55443' + #SSH connection to CLI + - '2222:2222' + environment: + - username_admin_globalaccesslevel=admin + - username_admin_password=admin + - system_scaling_maxconnectioncount=100 + +volumes: + storage-group: \ No newline at end of file diff --git a/docs/antora.yml b/docs/antora.yml new file mode 100644 index 0000000..f6bb606 --- /dev/null +++ b/docs/antora.yml @@ -0,0 +1,5 @@ +name: quarkus-solace +title: Quarkus Solace +version: dev +nav: + - modules/ROOT/nav.adoc diff --git a/docs/modules/ROOT/assets/images/.keepme b/docs/modules/ROOT/assets/images/.keepme new file mode 100644 index 0000000..e69de29 diff --git a/docs/modules/ROOT/examples/.keepme b/docs/modules/ROOT/examples/.keepme new file mode 100644 index 0000000..e69de29 diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc new file mode 100644 index 0000000..535e474 --- /dev/null +++ b/docs/modules/ROOT/nav.adoc @@ -0,0 +1 @@ +* xref:index.adoc[Quarkus Solace] diff --git a/docs/modules/ROOT/pages/includes/attributes.adoc b/docs/modules/ROOT/pages/includes/attributes.adoc new file mode 100644 index 0000000..19a687e --- /dev/null +++ b/docs/modules/ROOT/pages/includes/attributes.adoc @@ -0,0 +1,3 @@ +:project-version: 0 + +:examples-dir: ./../examples/ \ No newline at end of file diff --git a/docs/modules/ROOT/pages/includes/quarkus-solace.adoc b/docs/modules/ROOT/pages/includes/quarkus-solace.adoc new file mode 100644 index 0000000..5b625cd --- /dev/null +++ b/docs/modules/ROOT/pages/includes/quarkus-solace.adoc @@ -0,0 +1,190 @@ + +:summaryTableId: quarkus-solace +[.configuration-legend] +icon:lock[title=Fixed at build time] Configuration property fixed at build time - All other configuration properties are overridable at runtime +[.configuration-reference.searchable, cols="80,.^10,.^10"] +|=== + +h|[[quarkus-solace_configuration]]link:#quarkus-solace_configuration[Configuration property] + +h|Type +h|Default + +a|icon:lock[title=Fixed at build time] [[quarkus-solace_quarkus.solace.metrics.enabled]]`link:#quarkus-solace_quarkus.solace.metrics.enabled[quarkus.solace.metrics.enabled]` + + +[.description] +-- +Whether a metrics is enabled in case the micrometer is present. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_SOLACE_METRICS_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_SOLACE_METRICS_ENABLED+++` +endif::add-copy-button-to-env-var[] +--|boolean +|`true` + + +a|icon:lock[title=Fixed at build time] [[quarkus-solace_quarkus.solace.health.enabled]]`link:#quarkus-solace_quarkus.solace.health.enabled[quarkus.solace.health.enabled]` + + +[.description] +-- +Whether the liveness health check should be exposed if the smallrye-health extension is present. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_SOLACE_HEALTH_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_SOLACE_HEALTH_ENABLED+++` +endif::add-copy-button-to-env-var[] +--|boolean +|`true` + + +a|icon:lock[title=Fixed at build time] [[quarkus-solace_quarkus.solace.devservices.enabled]]`link:#quarkus-solace_quarkus.solace.devservices.enabled[quarkus.solace.devservices.enabled]` + + +[.description] +-- +If DevServices has been explicitly enabled or disabled. DevServices is generally enabled by default, unless there is an existing configuration present. + +When DevServices is enabled Quarkus will attempt to automatically configure and start the Solace broker when running in Dev or Test mode and when Docker is running. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_SOLACE_DEVSERVICES_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_SOLACE_DEVSERVICES_ENABLED+++` +endif::add-copy-button-to-env-var[] +--|boolean +|`true` + + +a|icon:lock[title=Fixed at build time] [[quarkus-solace_quarkus.solace.devservices.image-name]]`link:#quarkus-solace_quarkus.solace.devservices.image-name[quarkus.solace.devservices.image-name]` + + +[.description] +-- +The container image name to use, for container based DevServices providers. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_SOLACE_DEVSERVICES_IMAGE_NAME+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_SOLACE_DEVSERVICES_IMAGE_NAME+++` +endif::add-copy-button-to-env-var[] +--|string +| + + +a|icon:lock[title=Fixed at build time] [[quarkus-solace_quarkus.solace.devservices.shared]]`link:#quarkus-solace_quarkus.solace.devservices.shared[quarkus.solace.devservices.shared]` + + +[.description] +-- +Indicates if the Solace broker managed by Quarkus Dev Services is shared. When shared, Quarkus looks for running containers using label-based service discovery. If a matching container is found, it is used, and so a second one is not started. Otherwise, Dev Services for Solace starts a new container. + +The discovery uses the `quarkus-dev-service-solace` label. The value is configured using the `service-name` property. + +Container sharing is only used in dev mode. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_SOLACE_DEVSERVICES_SHARED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_SOLACE_DEVSERVICES_SHARED+++` +endif::add-copy-button-to-env-var[] +--|boolean +|`true` + + +a|icon:lock[title=Fixed at build time] [[quarkus-solace_quarkus.solace.devservices.service-name]]`link:#quarkus-solace_quarkus.solace.devservices.service-name[quarkus.solace.devservices.service-name]` + + +[.description] +-- +The value of the `quarkus-dev-service-solace` label attached to the started container. This property is used when `shared` is set to `true`. In this case, before starting a container, Dev Services for Solace looks for a container with the `quarkus-dev-service-solace` label set to the configured value. If found, it will use this container instead of starting a new one. Otherwise, it starts a new container with the `quarkus-dev-service-solace` label set to the specified value. + +This property is used when you need multiple shared Solace broker. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_SOLACE_DEVSERVICES_SERVICE_NAME+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_SOLACE_DEVSERVICES_SERVICE_NAME+++` +endif::add-copy-button-to-env-var[] +--|string +|`solace` + + +a| [[quarkus-solace_quarkus.solace.host]]`link:#quarkus-solace_quarkus.solace.host[quarkus.solace.host]` + + +[.description] +-- +The Solace host (hostname:port) + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_SOLACE_HOST+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_SOLACE_HOST+++` +endif::add-copy-button-to-env-var[] +--|string +|required icon:exclamation-circle[title=Configuration property is required] + + +a| [[quarkus-solace_quarkus.solace.vpn]]`link:#quarkus-solace_quarkus.solace.vpn[quarkus.solace.vpn]` + + +[.description] +-- +The Solace VPN + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_SOLACE_VPN+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_SOLACE_VPN+++` +endif::add-copy-button-to-env-var[] +--|string +|required icon:exclamation-circle[title=Configuration property is required] + + +a|icon:lock[title=Fixed at build time] [[quarkus-solace_quarkus.solace.devservices.container-env-container-env]]`link:#quarkus-solace_quarkus.solace.devservices.container-env-container-env[quarkus.solace.devservices.container-env]` + + +[.description] +-- +Environment variables that are passed to the container. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_SOLACE_DEVSERVICES_CONTAINER_ENV+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_SOLACE_DEVSERVICES_CONTAINER_ENV+++` +endif::add-copy-button-to-env-var[] +--|`Map` +| + + +a| [[quarkus-solace_quarkus.solace-extra]]`link:#quarkus-solace_quarkus.solace-extra[quarkus.solace]` + + +[.description] +-- +Any extra parameters to pass to the Solace client + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_SOLACE+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_SOLACE+++` +endif::add-copy-button-to-env-var[] +--|`Map` +| + +|=== \ No newline at end of file diff --git a/docs/modules/ROOT/pages/index.adoc b/docs/modules/ROOT/pages/index.adoc new file mode 100644 index 0000000..990a874 --- /dev/null +++ b/docs/modules/ROOT/pages/index.adoc @@ -0,0 +1,27 @@ += Quarkus Solace + +include::./includes/attributes.adoc[] + +TIP: Describe what the extension does here. + +== Installation + +If you want to use this extension, you need to add the `io.quarkiverse.solace:quarkus-solace` extension first to your build file. + +For instance, with Maven, add the following dependency to your POM file: + +[source,xml,subs=attributes+] +---- + + io.quarkiverse.solace + quarkus-solace + {project-version} + +---- + +[[extension-configuration-reference]] +== Extension Configuration Reference + +TIP: Remove this section if you don't have Quarkus configuration properties in your extension. + +include::includes/quarkus-solace.adoc[leveloffset=+1, opts=optional] diff --git a/docs/pom.xml b/docs/pom.xml new file mode 100644 index 0000000..1c6562f --- /dev/null +++ b/docs/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + io.quarkiverse.solace + quarkus-solace-parent + 999-SNAPSHOT + ../pom.xml + + + quarkus-solace-docs + Quarkus Solace - Documentation + + + + + io.quarkiverse.solace + quarkus-solace-deployment + ${project.version} + + + + + modules/ROOT/examples + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + it.ozimov + yaml-properties-maven-plugin + + + initialize + + read-project-properties + + + + ${project.basedir}/../.github/project.yml + + + + + + + maven-resources-plugin + + + copy-resources + generate-resources + + copy-resources + + + ${project.basedir}/modules/ROOT/pages/includes/ + + + ${project.basedir}/../target/asciidoc/generated/config/ + quarkus-solace.adoc + false + + + ${project.basedir}/templates/includes + attributes.adoc + true + + + + + + copy-images + prepare-package + + copy-resources + + + ${project.build.directory}/generated-docs/_images/ + + + ${project.basedir}/modules/ROOT/assets/images/ + false + + + + + + + + org.asciidoctor + asciidoctor-maven-plugin + + + + + diff --git a/docs/templates/includes/attributes.adoc b/docs/templates/includes/attributes.adoc new file mode 100644 index 0000000..e1a2881 --- /dev/null +++ b/docs/templates/includes/attributes.adoc @@ -0,0 +1,3 @@ +:project-version: ${release.current-version} + +:examples-dir: ./../examples/ \ No newline at end of file diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml new file mode 100644 index 0000000..a0129fc --- /dev/null +++ b/integration-tests/pom.xml @@ -0,0 +1,15 @@ + + + 4.0.0 + + io.quarkiverse.solace + quarkus-solace-parent + 999-SNAPSHOT + + quarkus-solace-integration-tests-parent + Quarkus - Solace - Integration Tests - Parent + pom + + solace-client-integration-tests + + \ No newline at end of file diff --git a/integration-tests/solace-client-integration-tests/pom.xml b/integration-tests/solace-client-integration-tests/pom.xml new file mode 100644 index 0000000..4ff8452 --- /dev/null +++ b/integration-tests/solace-client-integration-tests/pom.xml @@ -0,0 +1,123 @@ + + + 4.0.0 + + + io.quarkiverse.solace + quarkus-solace-integration-tests-parent + 999-SNAPSHOT + + + solace-client-integration-tests + + + + io.quarkus + quarkus-resteasy-reactive-jackson + + + io.quarkiverse.solace + quarkus-solace + ${project.version} + + + io.quarkus + quarkus-smallrye-health + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + org.awaitility + awaitility + test + + + org.assertj + assertj-core + 3.24.2 + test + + + + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + maven-jar-plugin + + + + test-jar + + + + + + maven-surefire-plugin + + + + + + + + + native-image + + + native + + + + + + maven-failsafe-plugin + + + + integration-test + verify + + + + + ${project.build.directory}/${project.build.finalName}-runner + + org.jboss.logmanager.LogManager + + ${maven.home} + + + + + + + + + native + + + + + \ No newline at end of file diff --git a/integration-tests/solace-client-integration-tests/src/main/java/io/quarkiverse/solace/SolaceConsumer.java b/integration-tests/solace-client-integration-tests/src/main/java/io/quarkiverse/solace/SolaceConsumer.java new file mode 100644 index 0000000..43b28e2 --- /dev/null +++ b/integration-tests/solace-client-integration-tests/src/main/java/io/quarkiverse/solace/SolaceConsumer.java @@ -0,0 +1,60 @@ +package io.quarkiverse.solace; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.config.MissingResourcesCreationConfiguration; +import com.solace.messaging.receiver.DirectMessageReceiver; +import com.solace.messaging.receiver.PersistentMessageReceiver; +import com.solace.messaging.resources.Queue; +import com.solace.messaging.resources.TopicSubscription; + +import io.quarkus.runtime.ShutdownEvent; + +@ApplicationScoped +public class SolaceConsumer { + + private final DirectMessageReceiver directReceiver; + private final PersistentMessageReceiver persistentReceiver; + List direct = new CopyOnWriteArrayList<>(); + List persistent = new CopyOnWriteArrayList<>(); + + public SolaceConsumer(MessagingService solace) { + directReceiver = solace.createDirectMessageReceiverBuilder() + .withSubscriptions(TopicSubscription.of("hello/direct")) + .build().start(); + persistentReceiver = solace.createPersistentMessageReceiverBuilder() + .withMissingResourcesCreationStrategy( + MissingResourcesCreationConfiguration.MissingResourcesCreationStrategy.CREATE_ON_START) + .withSubscriptions(TopicSubscription.of("hello/persistent")) + .build(Queue.durableExclusiveQueue("hello/persistent")).start(); + + directReceiver.receiveAsync(h -> consumeDirect(h.getPayloadAsString())); + persistentReceiver.receiveAsync(h -> consumePersistent(h.getPayloadAsString())); + } + + public void shutdown(@Observes ShutdownEvent event) { + directReceiver.terminate(1); + persistentReceiver.terminate(1); + } + + public void consumeDirect(String message) { + direct.add(message); + } + + public void consumePersistent(String message) { + persistent.add(message); + } + + public List direct() { + return direct; + } + + public List persistent() { + return persistent; + } +} diff --git a/integration-tests/solace-client-integration-tests/src/main/java/io/quarkiverse/solace/SolaceCustomizer.java b/integration-tests/solace-client-integration-tests/src/main/java/io/quarkiverse/solace/SolaceCustomizer.java new file mode 100644 index 0000000..5f9e489 --- /dev/null +++ b/integration-tests/solace-client-integration-tests/src/main/java/io/quarkiverse/solace/SolaceCustomizer.java @@ -0,0 +1,13 @@ +package io.quarkiverse.solace; + +import jakarta.enterprise.context.ApplicationScoped; + +import com.solace.messaging.MessagingServiceClientBuilder; + +@ApplicationScoped +public class SolaceCustomizer implements MessagingServiceClientCustomizer { + @Override + public MessagingServiceClientBuilder customize(MessagingServiceClientBuilder builder) { + return builder; + } +} diff --git a/integration-tests/solace-client-integration-tests/src/main/java/io/quarkiverse/solace/SolaceResource.java b/integration-tests/solace-client-integration-tests/src/main/java/io/quarkiverse/solace/SolaceResource.java new file mode 100644 index 0000000..4a390db --- /dev/null +++ b/integration-tests/solace-client-integration-tests/src/main/java/io/quarkiverse/solace/SolaceResource.java @@ -0,0 +1,61 @@ +package io.quarkiverse.solace; + +import java.util.List; + +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.publisher.DirectMessagePublisher; +import com.solace.messaging.publisher.PersistentMessagePublisher; +import com.solace.messaging.resources.Topic; + +import io.quarkus.runtime.StartupEvent; + +@Path("/solace") +public class SolaceResource { + + @Inject + SolaceConsumer consumer; + + @Inject + MessagingService solace; + private DirectMessagePublisher directMessagePublisher; + private PersistentMessagePublisher persistentMessagePublisher; + + public void init(@Observes StartupEvent ev) { + directMessagePublisher = solace.createDirectMessagePublisherBuilder().build().start(); + persistentMessagePublisher = solace.createPersistentMessagePublisherBuilder().build().start(); + } + + @GET + @Path("/direct") + @Produces("application/json") + public List getDirectMessages() { + return consumer.direct(); + } + + @GET + @Path("/persistent") + @Produces("application/json") + public List getPersistentMessages() { + return consumer.persistent(); + } + + @POST + @Path("/direct") + public void sendDirect(String message) { + directMessagePublisher.publish(message, Topic.of("hello/direct")); + } + + @POST + @Path("/persistent") + public void sendPersistent(String message) { + persistentMessagePublisher.publish(message, Topic.of("hello/persistent")); + } + +} diff --git a/integration-tests/solace-client-integration-tests/src/test/java/io/quarkiverse/solace/SolaceIT.java b/integration-tests/solace-client-integration-tests/src/test/java/io/quarkiverse/solace/SolaceIT.java new file mode 100644 index 0000000..6018baa --- /dev/null +++ b/integration-tests/solace-client-integration-tests/src/test/java/io/quarkiverse/solace/SolaceIT.java @@ -0,0 +1,8 @@ +package io.quarkiverse.solace; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +class SolaceIT extends SolaceTest { + +} \ No newline at end of file diff --git a/integration-tests/solace-client-integration-tests/src/test/java/io/quarkiverse/solace/SolaceTest.java b/integration-tests/solace-client-integration-tests/src/test/java/io/quarkiverse/solace/SolaceTest.java new file mode 100644 index 0000000..b5c659c --- /dev/null +++ b/integration-tests/solace-client-integration-tests/src/test/java/io/quarkiverse/solace/SolaceTest.java @@ -0,0 +1,59 @@ +package io.quarkiverse.solace; + +import static org.awaitility.Awaitility.await; + +import java.util.List; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.restassured.common.mapper.TypeRef; + +@QuarkusTest +class SolaceTest { + + private final TypeRef> listOfString = new TypeRef<>() { + // Empty + }; + + @Test + void testDirect() { + List list = RestAssured + .given().header("Accept", "application/json") + .get("/solace/direct").as(listOfString); + Assertions.assertThat(list).isEmpty(); + + for (int i = 0; i < 3; i++) { + RestAssured + .given().body("hello " + i) + .post("/solace/direct") + .then().statusCode(204); + } + + await().until(() -> RestAssured + .given().header("Accept", "application/json") + .get("/solace/direct").as(listOfString).size() == 3); + } + + @Test + void testPersistent() { + List list = RestAssured + .given().header("Accept", "application/json") + .get("/solace/persistent").as(listOfString); + Assertions.assertThat(list).isEmpty(); + + for (int i = 0; i < 3; i++) { + RestAssured + .given().body("hello " + i) + .post("/solace/persistent") + .then().statusCode(204); + } + + await().until(() -> RestAssured + .given().header("Accept", "application/json") + .get("/solace/persistent").as(listOfString).size() == 3); + } + +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8622bac --- /dev/null +++ b/pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + + io.quarkiverse + quarkiverse-parent + 15 + + io.quarkiverse.solace + quarkus-solace-parent + 999-SNAPSHOT + pom + Quarkus Solace - Parent + + + deployment + runtime + pubsub-plus-connector + docs + samples/hello-solace + samples/hello-connector-solace + integration-tests + + + + scm:git:git@github.com:quarkiverse/quarkus-solace.git + scm:git:git@github.com:quarkiverse/quarkus-solace.git + https://github.com/quarkiverse/quarkus-solace + + + + 3.11.0 + 11 + UTF-8 + UTF-8 + 3.2.8.Final + + 1.4.0 + + 3.24.2 + 5.1.2.Final + 1.18.3 + + + + + + io.quarkus + quarkus-bom + ${quarkus.version} + pom + import + + + + com.solace + solace-messaging-client + ${solace.version} + + + + org.jboss.weld.se + weld-se-shaded + ${weld.version} + test + + + org.jboss.weld + weld-core-impl + ${weld.version} + test + + + org.assertj + assertj-core + ${assertj-core.version} + test + + + + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + maven-compiler-plugin + ${compiler-plugin.version} + + + -parameters + + + + + + + diff --git a/pubsub-plus-connector/pom.xml b/pubsub-plus-connector/pom.xml new file mode 100644 index 0000000..d6dc9c7 --- /dev/null +++ b/pubsub-plus-connector/pom.xml @@ -0,0 +1,168 @@ + + + 4.0.0 + + io.quarkiverse.solace + quarkus-solace-parent + 999-SNAPSHOT + + + quarkus-solace-messaging-connector + Quarkus Solace Messaging Connector + + + + io.smallrye.config + smallrye-config + + + io.quarkus + quarkus-smallrye-reactive-messaging + + + io.smallrye.reactive + smallrye-reactive-messaging-api + + + io.smallrye.reactive + smallrye-connector-attribute-processor + + + com.solace + solace-messaging-client + + + org.jboss.logging + jboss-logging + + + org.jboss.logging + jboss-logging-annotations + + + org.jboss.logging + jboss-logging-processor + + + + + org.jboss.weld.se + weld-se-shaded + test + + + org.jboss.weld + weld-core-impl + test + + + io.smallrye.reactive + mutiny-reactive-streams-operators + test + + + org.junit.jupiter + junit-jupiter + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.assertj + assertj-core + test + + + org.awaitility + awaitility + test + + + org.mockito + mockito-core + test + + + org.junit.jupiter + junit-jupiter-params + test + + + + io.smallrye.reactive + test-common + 4.11.0 + test + + + io.smallrye + smallrye-fault-tolerance + test + + + io.smallrye + smallrye-metrics + test + + + io.opentelemetry + opentelemetry-sdk-trace + test + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + org.testcontainers + testcontainers + test + + + + org.reactivestreams + reactive-streams-tck + 1.0.4 + test + + + + + org.slf4j + slf4j-log4j12 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${project.build.directory}/generated-sources/ + + + io.smallrye.reactive.messaging.connector.ConnectorAttributeProcessor + + + org.jboss.logging.processor.apt.LoggingToolsProcessor + + + + + + + + \ No newline at end of file diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/SolaceConnector.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/SolaceConnector.java new file mode 100644 index 0000000..e25b7b5 --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/SolaceConnector.java @@ -0,0 +1,161 @@ +package io.quarkiverse.solace; + +import static io.smallrye.reactive.messaging.annotations.ConnectorAttribute.Direction.INCOMING; +import static io.smallrye.reactive.messaging.annotations.ConnectorAttribute.Direction.INCOMING_AND_OUTGOING; +import static io.smallrye.reactive.messaging.annotations.ConnectorAttribute.Direction.OUTGOING; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Flow; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.BeforeDestroyed; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.event.Reception; +import jakarta.inject.Inject; + +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.reactive.messaging.Message; +import org.eclipse.microprofile.reactive.messaging.spi.Connector; + +import com.solace.messaging.MessagingService; + +import io.quarkiverse.solace.i18n.SolaceLogging; +import io.quarkiverse.solace.incoming.SolaceIncomingChannel; +import io.quarkiverse.solace.outgoing.SolaceOutgoingChannel; +import io.quarkus.runtime.ShutdownEvent; +import io.smallrye.reactive.messaging.annotations.ConnectorAttribute; +import io.smallrye.reactive.messaging.connector.InboundConnector; +import io.smallrye.reactive.messaging.connector.OutboundConnector; +import io.smallrye.reactive.messaging.health.HealthReport; +import io.smallrye.reactive.messaging.health.HealthReporter; +import io.smallrye.reactive.messaging.providers.connectors.ExecutionHolder; +import io.vertx.mutiny.core.Vertx; + +@ApplicationScoped +@Connector(SolaceConnector.CONNECTOR_NAME) + +// TODO only persisted is implemented +//@ConnectorAttribute(name = "client.type", type = "string", direction = INCOMING_AND_OUTGOING, description = "Direct or persisted", defaultValue = "persisted") +@ConnectorAttribute(name = "client.lazy.start", type = "boolean", direction = INCOMING_AND_OUTGOING, description = "Whether the receiver or publisher is started at initialization or lazily at subscription time", defaultValue = "false") +@ConnectorAttribute(name = "client.shutdown.wait-timeout", type = "long", direction = INCOMING_AND_OUTGOING, description = "Timeout in milliseconds to wait for messages to finish processing before shutdown", defaultValue = "10000") +@ConnectorAttribute(name = "consumer.queue.enable-nacks", type = "boolean", direction = INCOMING, description = "Whether to enable negative acknowledgments on failed messages. Nacks are supported on event brokers 10.2.1 and later. If an event broker does not support Nacks, an exception is thrown", defaultValue = "false") +@ConnectorAttribute(name = "consumer.queue.add-additional-subscriptions", type = "boolean", direction = INCOMING, description = "Whether to add configured subscriptions to queue. Will fail if permissions to configure subscriptions is not allowed on broker", defaultValue = "false") +@ConnectorAttribute(name = "consumer.queue.subscriptions", type = "string", direction = INCOMING, description = "The comma separated list of subscriptions, the channel name if empty") +@ConnectorAttribute(name = "consumer.queue.type", type = "string", direction = INCOMING, description = "The queue type of receiver", defaultValue = "durable-non-exclusive") +@ConnectorAttribute(name = "consumer.queue.name", type = "string", direction = INCOMING, description = "The queue name of receiver") +// TODO implement consumer concurrency +//@ConnectorAttribute(name = "consumer.queue.concurrency", type = "int", direction = INCOMING, description = "The number of concurrent consumers", defaultValue = "1") +@ConnectorAttribute(name = "consumer.queue.polled-wait-time-in-millis", type = "int", direction = INCOMING, description = "Maximum wait time for polled consumers to receive a message from configured queue", defaultValue = "100") +@ConnectorAttribute(name = "consumer.queue.missing-resource-creation-strategy", type = "string", direction = INCOMING, description = "Missing resource creation strategy", defaultValue = "do-not-create") +@ConnectorAttribute(name = "consumer.queue.selector-query", type = "string", direction = INCOMING, description = "The receiver selector query") +@ConnectorAttribute(name = "consumer.queue.replay.strategy", type = "string", direction = INCOMING, description = "The receiver replay strategy") +@ConnectorAttribute(name = "consumer.queue.replay.timebased-start-time", type = "string", direction = INCOMING, description = "The receiver replay timebased start time") +@ConnectorAttribute(name = "consumer.queue.replay.replication-group-message-id", type = "string", direction = INCOMING, description = "The receiver replay replication group message id") +@ConnectorAttribute(name = "consumer.queue.discard-messages-on-failure", type = "boolean", direction = INCOMING, description = "Whether discard messages from queue on failure. A negative acknowledgment of type REJECTED is sent to broker which discards the messages from queue and will move to DMQ if enabled. This option works only when enable-nacks is true and error topic is not configured", defaultValue = "false") +@ConnectorAttribute(name = "consumer.queue.publish-to-error-topic-on-failure", type = "boolean", direction = INCOMING, description = "Whether to publish consumed message to error topic on failure", defaultValue = "false") +@ConnectorAttribute(name = "consumer.queue.error.topic", type = "string", direction = INCOMING, description = "The error topic where message should be published in case of error") +@ConnectorAttribute(name = "consumer.queue.error.message.dmq-eligible", type = "boolean", direction = INCOMING, description = "Whether error message is eligible to move to dead message queue", defaultValue = "false") +@ConnectorAttribute(name = "consumer.queue.error.message.ttl", type = "long", direction = INCOMING, description = "Error message TTL before moving to dead message queue") +@ConnectorAttribute(name = "consumer.queue.error.message.max-delivery-attempts", type = "int", direction = INCOMING, description = "Maximum number of attempts to send a failed message to the error topic in case of failure. Each attempt will have a backoff interval of 1 second. When all delivery attempts have been exhausted, the failed message will be requeued on the queue for redelivery.", defaultValue = "3") + +@ConnectorAttribute(name = "producer.topic", type = "string", direction = OUTGOING, description = "The topic to publish messages, by default the channel name") +@ConnectorAttribute(name = "producer.max-inflight-messages", type = "long", direction = OUTGOING, description = "The maximum number of messages to be written to Solace broker. It limits the number of messages waiting to be written and acknowledged by the broker. You can set this attribute to `0` remove the limit", defaultValue = "1024") +@ConnectorAttribute(name = "producer.waitForPublishReceipt", type = "boolean", direction = OUTGOING, description = "Whether the client waits to receive the publish receipt from Solace broker before acknowledging the message", defaultValue = "true") +@ConnectorAttribute(name = "producer.delivery.ack.timeout", type = "int", direction = OUTGOING, description = "Delivery ack timeout") +@ConnectorAttribute(name = "producer.delivery.ack.window.size", type = "int", direction = OUTGOING, description = "Delivery ack window size") +@ConnectorAttribute(name = "producer.back-pressure.strategy", type = "string", direction = OUTGOING, description = "Outgoing messages backpressure strategy", defaultValue = "reject") +@ConnectorAttribute(name = "producer.back-pressure.buffer-capacity", type = "int", direction = OUTGOING, description = "Outgoing messages backpressure buffer capacity", defaultValue = "1024") +public class SolaceConnector implements InboundConnector, OutboundConnector, HealthReporter { + + public static final String CONNECTOR_NAME = "quarkus-solace"; + + @Inject + ExecutionHolder executionHolder; + + @Inject + MessagingService solace; + + Vertx vertx; + + List incomingChannels = new CopyOnWriteArrayList<>(); + List outgoingChannels = new CopyOnWriteArrayList<>(); + + public void onStop(@Observes ShutdownEvent shutdownEvent) { + if (solace.isConnected()) { + SolaceLogging.log.info("Waiting incoming channel messages to be acknowledged"); + incomingChannels.forEach(SolaceIncomingChannel::waitForUnAcknowledgedMessages); + SolaceLogging.log.info("All incoming channel messages are acknowledged"); + + SolaceLogging.log.info("Waiting for outgoing messages to be published"); + outgoingChannels.forEach(SolaceOutgoingChannel::waitForPublishedMessages); + SolaceLogging.log.info("All outgoing messages are published"); + } + } + + public void terminate( + @Observes(notifyObserver = Reception.IF_EXISTS) @Priority(50) @BeforeDestroyed(ApplicationScoped.class) Object event) { + incomingChannels.forEach(SolaceIncomingChannel::close); + outgoingChannels.forEach(SolaceOutgoingChannel::close); + } + + @PostConstruct + void init() { + this.vertx = executionHolder.vertx(); + } + + @Override + public Flow.Publisher> getPublisher(Config config) { + var ic = new SolaceConnectorIncomingConfiguration(config); + SolaceIncomingChannel channel = new SolaceIncomingChannel(vertx, ic, solace); + incomingChannels.add(channel); + return channel.getStream(); + } + + @Override + public Flow.Subscriber> getSubscriber(Config config) { + var oc = new SolaceConnectorOutgoingConfiguration(config); + SolaceOutgoingChannel channel = new SolaceOutgoingChannel(vertx, oc, solace); + outgoingChannels.add(channel); + return channel.getSubscriber(); + } + + @Override + public HealthReport getStartup() { + HealthReport.HealthReportBuilder builder = HealthReport.builder(); + for (SolaceIncomingChannel in : incomingChannels) { + in.isStarted(builder); + } + for (SolaceOutgoingChannel sink : outgoingChannels) { + sink.isStarted(builder); + } + return builder.build(); + } + + @Override + public HealthReport getReadiness() { + HealthReport.HealthReportBuilder builder = HealthReport.builder(); + for (SolaceIncomingChannel in : incomingChannels) { + in.isReady(builder); + } + for (SolaceOutgoingChannel sink : outgoingChannels) { + sink.isReady(builder); + } + return builder.build(); + + } + + @Override + public HealthReport getLiveness() { + HealthReport.HealthReportBuilder builder = HealthReport.builder(); + for (SolaceIncomingChannel in : incomingChannels) { + in.isAlive(builder); + } + for (SolaceOutgoingChannel out : outgoingChannels) { + out.isAlive(builder); + } + return builder.build(); + } +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/converters/SolaceMessageConverter.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/converters/SolaceMessageConverter.java new file mode 100644 index 0000000..c5eb946 --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/converters/SolaceMessageConverter.java @@ -0,0 +1,28 @@ +package io.quarkiverse.solace.converters; + +import java.lang.reflect.Type; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.reactive.messaging.Message; + +import com.solace.messaging.receiver.InboundMessage; + +import io.quarkiverse.solace.incoming.SolaceInboundMetadata; +import io.smallrye.reactive.messaging.MessageConverter; +import io.smallrye.reactive.messaging.providers.helpers.TypeUtils; + +@ApplicationScoped +public class SolaceMessageConverter implements MessageConverter { + @Override + public boolean canConvert(Message in, Type target) { + return TypeUtils.isAssignable(target, InboundMessage.class) + && in.getMetadata(SolaceInboundMetadata.class).isPresent(); + } + + @Override + public Message convert(Message in, Type target) { + return in.withPayload(in.getMetadata(SolaceInboundMetadata.class) + .map(SolaceInboundMetadata::getMessage).orElse(null)); + } +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/i18n/SolaceExceptions.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/i18n/SolaceExceptions.java new file mode 100644 index 0000000..31f8cfb --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/i18n/SolaceExceptions.java @@ -0,0 +1,24 @@ +package io.quarkiverse.solace.i18n; + +import org.jboss.logging.Messages; +import org.jboss.logging.annotations.Message; +import org.jboss.logging.annotations.MessageBundle; + +/** + * Exceptions for Kafka Connector + * Assigned ID range is 55000-55099 + */ +@MessageBundle(projectCode = "SRMSG", length = 5) +public interface SolaceExceptions { + SolaceExceptions ex = Messages.getBundle(SolaceExceptions.class); + + @Message(id = 18000, value = "`message` does not contain metadata of class %s") + IllegalArgumentException illegalArgument(Class c); + + @Message(id = 18001, value = "Only one subscriber allowed") + IllegalStateException illegalStateOnlyOneSubscriber(); + + @Message(id = 18002, value = "Expecting downstream to consume without back-pressure") + IllegalStateException illegalStateConsumeWithoutBackPressure(); + +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/i18n/SolaceLogging.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/i18n/SolaceLogging.java new file mode 100644 index 0000000..d757ef5 --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/i18n/SolaceLogging.java @@ -0,0 +1,43 @@ +package io.quarkiverse.solace.i18n; + +import org.jboss.logging.BasicLogger; +import org.jboss.logging.Logger; +import org.jboss.logging.annotations.LogMessage; +import org.jboss.logging.annotations.Message; +import org.jboss.logging.annotations.MessageLogger; +import org.jboss.logging.annotations.Once; + +/** + * Logging for Solace PubSub Connector + * Assigned ID range is 55200-55299 + */ +@MessageLogger(projectCode = "SRMSG", length = 5) +public interface SolaceLogging extends BasicLogger { + + SolaceLogging log = Logger.getMessageLogger(SolaceLogging.class, "io.quarkiverse.solace"); + + @Once + @LogMessage(level = Logger.Level.INFO) + @Message(id = 55200, value = "No valid content_type set, failing back to byte[]. If that's wanted, set the content type to application/octet-stream with \"content-type-override\"") + void typeConversionFallback(); + + @LogMessage(level = Logger.Level.DEBUG) + @Message(id = 55201, value = "Message from channel %s sent successfully to Solace topic '%s'") + void successfullyToTopic(String channel, String topic); + + @LogMessage(level = Logger.Level.ERROR) + @Message(id = 55202, value = "A message sent to channel `%s` has been settled, outcome: %s, reason: %s") + void messageSettled(String channel, String outcome, String reason); + + @LogMessage(level = Logger.Level.ERROR) + @Message(id = 55203, value = "Publishing error message to topic %s received from channel `%s` is unsuccessful") + void unsuccessfulToTopic(String topic, String channel); + + @LogMessage(level = Logger.Level.ERROR) + @Message(id = 55204, value = "A exception occurred when publishing to topic %s") + void publishException(String topic); + + @LogMessage(level = Logger.Level.ERROR) + @Message(id = 55205, value = "A exception occurred during shutdown %s") + void shutdownException(String exceptionMessage); +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/OutboundErrorMessageMapper.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/OutboundErrorMessageMapper.java new file mode 100644 index 0000000..e18b0fb --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/OutboundErrorMessageMapper.java @@ -0,0 +1,28 @@ +package io.quarkiverse.solace.incoming; + +import java.util.Properties; + +import com.solace.messaging.config.SolaceProperties; +import com.solace.messaging.publisher.OutboundMessage; +import com.solace.messaging.publisher.OutboundMessageBuilder; +import com.solace.messaging.receiver.InboundMessage; + +import io.quarkiverse.solace.SolaceConnectorIncomingConfiguration; + +class OutboundErrorMessageMapper { + + public OutboundMessage mapError(OutboundMessageBuilder messageBuilder, InboundMessage inputMessage, + SolaceConnectorIncomingConfiguration incomingConfiguration) { + Properties extendedMessageProperties = new Properties(); + + extendedMessageProperties.setProperty(SolaceProperties.MessageProperties.PERSISTENT_DMQ_ELIGIBLE, + Boolean.toString(incomingConfiguration.getConsumerQueueErrorMessageDmqEligible().booleanValue())); + messageBuilder.fromProperties(extendedMessageProperties); + + incomingConfiguration.getConsumerQueueErrorMessageTtl().ifPresent(ttl -> { + messageBuilder.withTimeToLive(incomingConfiguration.getConsumerQueueErrorMessageTtl().get()); + }); + + return messageBuilder.build(inputMessage.getPayloadAsBytes()); + } +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SettleMetadata.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SettleMetadata.java new file mode 100644 index 0000000..0238309 --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SettleMetadata.java @@ -0,0 +1,28 @@ +package io.quarkiverse.solace.incoming; + +import com.solace.messaging.config.MessageAcknowledgementConfiguration; + +class SettleMetadata { + + MessageAcknowledgementConfiguration.Outcome settleOutcome; + + public static SettleMetadata accepted() { + return new SettleMetadata(MessageAcknowledgementConfiguration.Outcome.ACCEPTED); + } + + public static SettleMetadata rejected() { + return new SettleMetadata(MessageAcknowledgementConfiguration.Outcome.REJECTED); + } + + public static SettleMetadata failed() { + return new SettleMetadata(MessageAcknowledgementConfiguration.Outcome.FAILED); + } + + public SettleMetadata(MessageAcknowledgementConfiguration.Outcome settleOutcome) { + this.settleOutcome = settleOutcome; + } + + public MessageAcknowledgementConfiguration.Outcome getOutcome() { + return settleOutcome; + } +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceAckHandler.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceAckHandler.java new file mode 100644 index 0000000..7a2089d --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceAckHandler.java @@ -0,0 +1,23 @@ +package io.quarkiverse.solace.incoming; + +import java.util.concurrent.CompletionStage; + +import com.solace.messaging.receiver.AcknowledgementSupport; + +import io.smallrye.mutiny.Uni; + +class SolaceAckHandler { + + private final AcknowledgementSupport ackSupport; + + public SolaceAckHandler(AcknowledgementSupport ackSupport) { + this.ackSupport = ackSupport; + } + + public CompletionStage handle(SolaceInboundMessage msg) { + return Uni.createFrom().voidItem() + .invoke(() -> ackSupport.ack(msg.getMessage())) + .runSubscriptionOn(msg::runOnMessageContext) + .subscribeAsCompletionStage(); + } +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceErrorTopicPublisherHandler.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceErrorTopicPublisherHandler.java new file mode 100644 index 0000000..d920196 --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceErrorTopicPublisherHandler.java @@ -0,0 +1,64 @@ +package io.quarkiverse.solace.incoming; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.PubSubPlusClientException; +import com.solace.messaging.publisher.OutboundMessage; +import com.solace.messaging.publisher.PersistentMessagePublisher; +import com.solace.messaging.publisher.PersistentMessagePublisher.PublishReceipt; +import com.solace.messaging.resources.Topic; + +import io.quarkiverse.solace.SolaceConnectorIncomingConfiguration; +import io.quarkiverse.solace.i18n.SolaceLogging; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.subscription.UniEmitter; + +class SolaceErrorTopicPublisherHandler implements PersistentMessagePublisher.MessagePublishReceiptListener { + + private final MessagingService solace; + private final String errorTopic; + private final PersistentMessagePublisher publisher; + private final OutboundErrorMessageMapper outboundErrorMessageMapper; + + public SolaceErrorTopicPublisherHandler(MessagingService solace, String errorTopic) { + this.solace = solace; + this.errorTopic = errorTopic; + + publisher = solace.createPersistentMessagePublisherBuilder().build(); + publisher.start(); + outboundErrorMessageMapper = new OutboundErrorMessageMapper(); + } + + public Uni handle(SolaceInboundMessage message, + SolaceConnectorIncomingConfiguration ic) { + OutboundMessage outboundMessage = outboundErrorMessageMapper.mapError(this.solace.messageBuilder(), + message.getMessage(), + ic); + publisher.setMessagePublishReceiptListener(this); + // } + return Uni.createFrom().emitter(e -> { + try { + // always wait for error message publish receipt to ensure it is successfully spooled on broker. + publisher.publish(outboundMessage, Topic.of(errorTopic), e); + } catch (Throwable t) { + SolaceLogging.log.publishException(this.errorTopic); + e.fail(t); + } + }).invoke(() -> System.out.println("")); + } + + @Override + public void onPublishReceipt(PublishReceipt publishReceipt) { + UniEmitter uniEmitter = (UniEmitter) publishReceipt + .getUserContext(); + PubSubPlusClientException exception = publishReceipt.getException(); + if (exception != null) { + SolaceLogging.log.publishException(this.errorTopic); + uniEmitter.fail(exception); + } else { + uniEmitter.complete(publishReceipt); + } + } +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceFailureHandler.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceFailureHandler.java new file mode 100644 index 0000000..1f4cb09 --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceFailureHandler.java @@ -0,0 +1,46 @@ +package io.quarkiverse.solace.incoming; + +import java.util.concurrent.CompletionStage; + +import org.eclipse.microprofile.reactive.messaging.Metadata; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.config.MessageAcknowledgementConfiguration; +import com.solace.messaging.receiver.AcknowledgementSupport; + +import io.quarkiverse.solace.i18n.SolaceLogging; +import io.smallrye.mutiny.Uni; + +class SolaceFailureHandler { + + private final String channel; + private final AcknowledgementSupport ackSupport; + + private final MessagingService solace; + + public SolaceFailureHandler(String channel, AcknowledgementSupport ackSupport, MessagingService solace) { + this.channel = channel; + this.ackSupport = ackSupport; + this.solace = solace; + } + + public CompletionStage handle(SolaceInboundMessage msg, Throwable reason, Metadata metadata, + MessageAcknowledgementConfiguration.Outcome messageOutCome) { + MessageAcknowledgementConfiguration.Outcome outcome; + if (metadata != null) { + outcome = metadata.get(SettleMetadata.class) + .map(SettleMetadata::getOutcome) + .orElseGet(() -> messageOutCome != null ? messageOutCome + : MessageAcknowledgementConfiguration.Outcome.FAILED /* TODO get outcome from reason */); + } else { + outcome = messageOutCome != null ? messageOutCome + : MessageAcknowledgementConfiguration.Outcome.FAILED; + } + + SolaceLogging.log.messageSettled(channel, outcome.toString().toLowerCase(), reason.getMessage()); + return Uni.createFrom().voidItem() + .invoke(() -> ackSupport.settle(msg.getMessage(), outcome)) + .runSubscriptionOn(msg::runOnMessageContext) + .subscribeAsCompletionStage(); + } +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceInboundMessage.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceInboundMessage.java new file mode 100644 index 0000000..6b44a78 --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceInboundMessage.java @@ -0,0 +1,132 @@ +package io.quarkiverse.solace.incoming; + +import static io.smallrye.reactive.messaging.providers.locals.ContextAwareMessage.captureContextMetadata; + +import java.time.Duration; +import java.util.concurrent.CompletionStage; + +import io.smallrye.mutiny.unchecked.Unchecked; +import org.eclipse.microprofile.reactive.messaging.Metadata; + +import com.solace.messaging.config.MessageAcknowledgementConfiguration; +import com.solace.messaging.publisher.PersistentMessagePublisher.PublishReceipt; +import com.solace.messaging.receiver.InboundMessage; + +import io.netty.handler.codec.http.HttpHeaderValues; +import io.quarkiverse.solace.SolaceConnectorIncomingConfiguration; +import io.quarkiverse.solace.i18n.SolaceLogging; +import io.smallrye.mutiny.Uni; +import io.smallrye.reactive.messaging.providers.MetadataInjectableMessage; +import io.smallrye.reactive.messaging.providers.locals.ContextAwareMessage; +import io.vertx.core.buffer.Buffer; + +public class SolaceInboundMessage implements ContextAwareMessage, MetadataInjectableMessage { + + private final InboundMessage msg; + private final SolaceAckHandler ackHandler; + private final SolaceFailureHandler nackHandler; + private final SolaceErrorTopicPublisherHandler solaceErrorTopicPublisherHandler; + private final SolaceConnectorIncomingConfiguration ic; + private final T payload; + private final UnsignedCounterBarrier unacknowledgedMessageTracker; + + private Metadata metadata; + + public SolaceInboundMessage(InboundMessage message, SolaceAckHandler ackHandler, SolaceFailureHandler nackHandler, + SolaceErrorTopicPublisherHandler solaceErrorTopicPublisherHandler, + SolaceConnectorIncomingConfiguration ic, UnsignedCounterBarrier unacknowledgedMessageTracker) { + this.msg = message; + this.unacknowledgedMessageTracker = unacknowledgedMessageTracker; + this.payload = (T) convertPayload(); + this.ackHandler = ackHandler; + this.nackHandler = nackHandler; + this.solaceErrorTopicPublisherHandler = solaceErrorTopicPublisherHandler; + this.ic = ic; + this.metadata = captureContextMetadata(new SolaceInboundMetadata(message)); + } + + public InboundMessage getMessage() { + return msg; + } + + @Override + public T getPayload() { + return this.payload; + } + + private Object convertPayload() { + // Neither of these are guaranteed to be non-null + final String contentType = msg.getRestInteroperabilitySupport().getHTTPContentType(); + final String contentEncoding = msg.getRestInteroperabilitySupport().getHTTPContentEncoding(); + final Buffer body = Buffer.buffer(msg.getPayloadAsBytes()); + + // If there is a content encoding specified, we don't try to unwrap + if (contentEncoding == null || contentEncoding.isBlank()) { + try { + // Do our best with text and json + if (HttpHeaderValues.APPLICATION_JSON.toString().equalsIgnoreCase(contentType)) { + // This could be JsonArray, JsonObject, String etc. depending on buffer contents + return body.toJson(); + } else if (HttpHeaderValues.TEXT_PLAIN.toString().equalsIgnoreCase(contentType)) { + return body.toString(); + } + } catch (Throwable t) { + SolaceLogging.log.typeConversionFallback(); + } + // Otherwise fall back to raw byte array + } else { + // Just silence the warning if we have a binary message + if (!HttpHeaderValues.APPLICATION_OCTET_STREAM.toString().equalsIgnoreCase(contentType)) { + SolaceLogging.log.typeConversionFallback(); + } + } + + this.unacknowledgedMessageTracker.increment(); + return body.getBytes(); + } + + @Override + public Metadata getMetadata() { + return metadata; + } + + @Override + public CompletionStage ack() { + this.unacknowledgedMessageTracker.decrement(); + return ackHandler.handle(this); + } + + @Override + public CompletionStage nack(Throwable reason, Metadata nackMetadata) { + if (solaceErrorTopicPublisherHandler != null) { + PublishReceipt publishReceipt = solaceErrorTopicPublisherHandler.handle(this, ic) + .onFailure().retry().withBackOff(Duration.ofSeconds(1)).atMost(ic.getConsumerQueueErrorMessageMaxDeliveryAttempts()) + .onFailure().transform((throwable -> { + SolaceLogging.log.unsuccessfulToTopic(ic.getConsumerQueueErrorTopic().get(), ic.getChannel()); + throw new RuntimeException(throwable); + })) + .await().atMost(Duration.ofSeconds(30)); + + if (publishReceipt != null) { + this.unacknowledgedMessageTracker.decrement(); + return nackHandler.handle(this, reason, nackMetadata, MessageAcknowledgementConfiguration.Outcome.ACCEPTED); + } + } + + MessageAcknowledgementConfiguration.Outcome outcome = ic.getConsumerQueueEnableNacks() + && ic.getConsumerQueueDiscardMessagesOnFailure() && solaceErrorTopicPublisherHandler == null + ? MessageAcknowledgementConfiguration.Outcome.REJECTED + : MessageAcknowledgementConfiguration.Outcome.FAILED; + if (outcome == MessageAcknowledgementConfiguration.Outcome.REJECTED) { + this.unacknowledgedMessageTracker.decrement(); + } + return ic.getConsumerQueueEnableNacks() + ? nackHandler.handle(this, reason, nackMetadata, outcome) + : Uni.createFrom().voidItem().subscribeAsCompletionStage(); + } + + @Override + public void injectMetadata(Object metadataObject) { + this.metadata = this.metadata.with(metadataObject); + } +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceInboundMetadata.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceInboundMetadata.java new file mode 100644 index 0000000..3ce1272 --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceInboundMetadata.java @@ -0,0 +1,132 @@ +package io.quarkiverse.solace.incoming; + +import java.io.Serializable; +import java.util.Map; + +import com.solace.messaging.PubSubPlusClientException; +import com.solace.messaging.receiver.InboundMessage; +import com.solace.messaging.util.Converter; +import com.solace.messaging.util.InteroperabilitySupport; + +public class SolaceInboundMetadata { + + private final InboundMessage msg; + + public SolaceInboundMetadata(InboundMessage msg) { + this.msg = msg; + } + + public boolean isRedelivered() { + return msg.isRedelivered(); + } + + public InboundMessage.MessageDiscardNotification getMessageDiscardNotification() { + return msg.getMessageDiscardNotification(); + } + + public T getAndConvertPayload(Converter.BytesToObject bytesToObject, Class aClass) + throws PubSubPlusClientException.IncompatibleMessageException { + return msg.getAndConvertPayload(bytesToObject, aClass); + } + + public String getDestinationName() { + return msg.getDestinationName(); + } + + public long getTimeStamp() { + return msg.getTimeStamp(); + } + + public boolean isCached() { + return msg.isCached(); + } + + public InboundMessage.ReplicationGroupMessageId getReplicationGroupMessageId() { + return msg.getReplicationGroupMessageId(); + } + + public int getClassOfService() { + return msg.getClassOfService(); + } + + public Long getSenderTimestamp() { + return msg.getSenderTimestamp(); + } + + public String getSenderId() { + return msg.getSenderId(); + } + + public boolean hasProperty(String s) { + return msg.hasProperty(s); + } + + public String getProperty(String s) { + return msg.getProperty(s); + } + + public String getPayloadAsString() { + return msg.getPayloadAsString(); + } + + // public Object getCorrelationKey() { + // return msg.getCorrelationKey(); + // } + + public long getSequenceNumber() { + return msg.getSequenceNumber(); + } + + public int getPriority() { + return msg.getPriority(); + } + + public boolean hasContent() { + return msg.hasContent(); + } + + public String getApplicationMessageId() { + return msg.getApplicationMessageId(); + } + + public String getApplicationMessageType() { + return msg.getApplicationMessageType(); + } + + public String dump() { + return msg.dump(); + } + + public InteroperabilitySupport.RestInteroperabilitySupport getRestInteroperabilitySupport() { + return msg.getRestInteroperabilitySupport(); + } + + public InboundMessage getMessage() { + return msg; + } + + public String getPayload() { + return msg.getPayloadAsString(); + } + + public byte[] getPayloadAsBytes() { + return msg.getPayloadAsBytes(); + } + + public Object getKey() { + return msg.getCorrelationKey(); + } + + public long getExpiration() { + return msg.getExpiration(); + } + + public String getCorrelationId() { + return msg.getCorrelationId(); + } + + public Map getProperties() { + return msg.getProperties(); + } + +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceIncomingChannel.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceIncomingChannel.java new file mode 100644 index 0000000..65075bc --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceIncomingChannel.java @@ -0,0 +1,217 @@ +package io.quarkiverse.solace.incoming; + +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.eclipse.microprofile.reactive.messaging.Message; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.PersistentMessageReceiverBuilder; +import com.solace.messaging.config.MessageAcknowledgementConfiguration.Outcome; +import com.solace.messaging.config.MissingResourcesCreationConfiguration.MissingResourcesCreationStrategy; +import com.solace.messaging.config.ReceiverActivationPassivationConfiguration; +import com.solace.messaging.config.ReplayStrategy; +import com.solace.messaging.receiver.DirectMessageReceiver; +import com.solace.messaging.receiver.InboundMessage; +import com.solace.messaging.receiver.PersistentMessageReceiver; +import com.solace.messaging.resources.Queue; +import com.solace.messaging.resources.TopicSubscription; + +import io.quarkiverse.solace.SolaceConnectorIncomingConfiguration; +import io.quarkiverse.solace.i18n.SolaceLogging; +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.Uni; +import io.smallrye.reactive.messaging.health.HealthReport; +import io.vertx.core.impl.VertxInternal; +import io.vertx.mutiny.core.Context; +import io.vertx.mutiny.core.Vertx; + +public class SolaceIncomingChannel implements ReceiverActivationPassivationConfiguration.ReceiverStateChangeListener { + + private final String channel; + private final Context context; + private final SolaceAckHandler ackHandler; + private final SolaceFailureHandler failureHandler; + private final AtomicBoolean closed = new AtomicBoolean(false); + private final PersistentMessageReceiver receiver; + private final Flow.Publisher> stream; + private final ExecutorService pollerThread; + private SolaceErrorTopicPublisherHandler solaceErrorTopicPublisherHandler; + private long waitTimeout = -1; + + // Assuming we won't ever exceed the limit of an unsigned long... + private final UnsignedCounterBarrier unacknowledgedMessageTracker = new UnsignedCounterBarrier(); + + public SolaceIncomingChannel(Vertx vertx, SolaceConnectorIncomingConfiguration ic, MessagingService solace) { + this.channel = ic.getChannel(); + this.context = Context.newInstance(((VertxInternal) vertx.getDelegate()).createEventLoopContext()); + this.waitTimeout = ic.getClientShutdownWaitTimeout(); + DirectMessageReceiver r = solace.createDirectMessageReceiverBuilder().build(); + Outcome[] outcomes = new Outcome[] { Outcome.ACCEPTED }; + if (ic.getConsumerQueueEnableNacks()) { + outcomes = new Outcome[] { Outcome.ACCEPTED, Outcome.FAILED, Outcome.REJECTED }; + } + PersistentMessageReceiverBuilder builder = solace.createPersistentMessageReceiverBuilder() + .withMessageClientAcknowledgement() + .withRequiredMessageClientOutcomeOperationSupport(outcomes) + .withActivationPassivationSupport(this); + + ic.getConsumerQueueSelectorQuery().ifPresent(builder::withMessageSelector); + ic.getConsumerQueueReplayStrategy().ifPresent(s -> { + switch (s) { + case "all-messages": + builder.withMessageReplay(ReplayStrategy.allMessages()); + break; + case "time-based": + builder.withMessageReplay(getTimeBasedReplayStrategy(ic)); + break; + case "replication-group-message-id": + builder.withMessageReplay(getGroupMessageIdReplayStrategy(ic)); + break; + } + }); + if (ic.getConsumerQueueAddAdditionalSubscriptions()) { + String subscriptions = ic.getConsumerQueueSubscriptions().orElse(this.channel); + builder.withSubscriptions(Arrays.stream(subscriptions.split(",")) + .map(TopicSubscription::of) + .toArray(TopicSubscription[]::new)); + } + switch (ic.getConsumerQueueMissingResourceCreationStrategy()) { + case "create-on-start": + builder.withMissingResourcesCreationStrategy(MissingResourcesCreationStrategy.CREATE_ON_START); + break; + case "do-not-create": + builder.withMissingResourcesCreationStrategy(MissingResourcesCreationStrategy.DO_NOT_CREATE); + break; + } + + this.receiver = builder.build(getQueue(ic)); + boolean lazyStart = ic.getClientLazyStart(); + this.ackHandler = new SolaceAckHandler(receiver); + this.failureHandler = new SolaceFailureHandler(channel, receiver, solace); + if (ic.getConsumerQueuePublishToErrorTopicOnFailure()) { + ic.getConsumerQueueErrorTopic().ifPresent(errorTopic -> { + this.solaceErrorTopicPublisherHandler = new SolaceErrorTopicPublisherHandler(solace, errorTopic); + }); + } + + Integer timeout = getTimeout(ic.getConsumerQueuePolledWaitTimeInMillis()); + // TODO Here use a subscription receiver.receiveAsync with an internal queue + this.pollerThread = Executors.newSingleThreadExecutor(); + this.stream = Multi.createBy().repeating() + .uni(() -> Uni.createFrom().item(timeout == null ? receiver.receiveMessage() : receiver.receiveMessage(timeout)) + .runSubscriptionOn(pollerThread)) + .until(__ -> closed.get()) + .emitOn(context::runOnContext) + .map(consumed -> new SolaceInboundMessage<>(consumed, ackHandler, failureHandler, + solaceErrorTopicPublisherHandler, ic, unacknowledgedMessageTracker)) + .plug(m -> lazyStart ? m.onSubscription().call(() -> Uni.createFrom().completionStage(receiver.startAsync())) + : m) + .onFailure().retry().withBackOff(Duration.ofSeconds(1)).atMost(3); + if (!lazyStart) { + receiver.start(); + } + } + + private Integer getTimeout(Integer timeoutInMillis) { + Integer realTimeout; + final Long expiry = timeoutInMillis != null + ? timeoutInMillis + System.currentTimeMillis() + : null; + if (expiry != null) { + try { + realTimeout = Math.toIntExact(expiry - System.currentTimeMillis()); + if (realTimeout < 0) { + realTimeout = 0; + } + } catch (ArithmeticException e) { + // Always true: expiry - System.currentTimeMillis() < timeoutInMillis + // So just set it to 0 (no-wait) if we underflow + realTimeout = 0; + } + } else { + realTimeout = null; + } + + return realTimeout; + } + + private static Queue getQueue(SolaceConnectorIncomingConfiguration ic) { + String queueType = ic.getConsumerQueueType(); + switch (queueType) { + case "durable-non-exclusive": + return Queue.durableNonExclusiveQueue(ic.getConsumerQueueName().orElse(ic.getChannel())); + case "durable-exclusive": + return Queue.durableExclusiveQueue(ic.getConsumerQueueName().orElse(ic.getChannel())); + default: + case "non-durable-exclusive": + return ic.getConsumerQueueName().map(Queue::nonDurableExclusiveQueue) + .orElseGet(Queue::nonDurableExclusiveQueue); + + } + } + + private static ReplayStrategy getGroupMessageIdReplayStrategy(SolaceConnectorIncomingConfiguration ic) { + String groupMessageId = ic.getConsumerQueueReplayReplicationGroupMessageId().orElseThrow(); + return ReplayStrategy.replicationGroupMessageIdBased(InboundMessage.ReplicationGroupMessageId.of(groupMessageId)); + } + + private static ReplayStrategy getTimeBasedReplayStrategy(SolaceConnectorIncomingConfiguration ic) { + String zoneDateTime = ic.getConsumerQueueReplayTimebasedStartTime().orElseThrow(); + return ReplayStrategy.timeBased(ZonedDateTime.parse(zoneDateTime)); + } + + public Flow.Publisher> getStream() { + return this.stream; + } + + public void waitForUnAcknowledgedMessages() { + try { + receiver.pause(); + if (!unacknowledgedMessageTracker.awaitEmpty(this.waitTimeout, TimeUnit.MILLISECONDS)) { + SolaceLogging.log.info(String.format("Timed out while waiting for the" + + " remaining messages to be acknowledged.")); + } + } catch (InterruptedException e) { + SolaceLogging.log.info(String.format("Interrupted while waiting for messages to get acknowledged")); + throw new RuntimeException(e); + } + } + + public void close() { + closed.compareAndSet(false, true); + if (this.pollerThread != null) { + this.pollerThread.shutdown(); + try { + this.pollerThread.awaitTermination(3000, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + SolaceLogging.log.shutdownException(e.getMessage()); + throw new RuntimeException(e); + } + } + receiver.terminate(3000); + } + + public void isStarted(HealthReport.HealthReportBuilder builder) { + + } + + public void isReady(HealthReport.HealthReportBuilder builder) { + + } + + public void isAlive(HealthReport.HealthReportBuilder builder) { + + } + + @Override + public void onStateChange(ReceiverState receiverState, ReceiverState receiverState1, long l) { + + } +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/UnsignedCounterBarrier.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/UnsignedCounterBarrier.java new file mode 100644 index 0000000..0f9e21e --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/UnsignedCounterBarrier.java @@ -0,0 +1,103 @@ +package io.quarkiverse.solace.incoming; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +class UnsignedCounterBarrier { + private final AtomicLong counter; // Treated as an unsigned long (i.e. range from 0 -> -1) + private final Lock awaitLock = new ReentrantLock(); + private final Condition isZero = awaitLock.newCondition(); + + private static final Log logger = LogFactory.getLog(UnsignedCounterBarrier.class); + + public UnsignedCounterBarrier(long initialValue) { + counter = new AtomicLong(initialValue); + } + + public UnsignedCounterBarrier() { + this(0); + } + + public void increment() { + // Assuming we won't ever increment past -1 + counter.updateAndGet(c -> Long.compareUnsigned(c, -1) < 0 ? c + 1 : c); + } + + public void decrement() { + // Assuming we won't ever decrement below 0 + if (counter.updateAndGet(c -> Long.compareUnsigned(c, 0) > 0 ? c - 1 : c) == 0) { + awaitLock.lock(); + try { + isZero.signalAll(); + } finally { + awaitLock.unlock(); + } + } + } + + public void reset() { + if (Long.compareUnsigned(counter.getAndSet(0), 0) != 0) { + awaitLock.lock(); + try { + isZero.signalAll(); + } finally { + awaitLock.unlock(); + } + } + } + + /** + * Wait until counter is zero. + * + * @param timeout the maximum wait time. If less than 0, then wait forever. + * @param unit the timeout unit. + * @return true if counter reached zero. False if timed out. + * @throws InterruptedException if the wait was interrupted + */ + public boolean awaitEmpty(long timeout, TimeUnit unit) throws InterruptedException { + awaitLock.lock(); + try { + if (timeout > 0) { + logger.info(String.format("Waiting for %s items, time remaining: %s %s", counter.get(), timeout, unit)); + final long expiry = unit.toMillis(timeout) + System.currentTimeMillis(); + while (isGreaterThanZero()) { + long realTimeout = expiry - System.currentTimeMillis(); + if (realTimeout <= 0) { + return false; + } + isZero.await(realTimeout, TimeUnit.MILLISECONDS); + } + return true; + } else if (timeout < 0) { + while (isGreaterThanZero()) { + logger.info(String.format("Waiting for %s items", counter.get())); + isZero.await(5, TimeUnit.SECONDS); + } + return true; + } else { + return !isGreaterThanZero(); + } + } finally { + awaitLock.unlock(); + } + } + + private boolean isGreaterThanZero() { + return Long.compareUnsigned(counter.get(), 0) > 0; + } + + /** + * Get the unsigned count. + * + * @return The count. + */ + public long getCount() { + return counter.get(); + } +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SenderProcessor.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SenderProcessor.java new file mode 100644 index 0000000..541cbf3 --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SenderProcessor.java @@ -0,0 +1,115 @@ +package io.quarkiverse.solace.outgoing; + +import static io.quarkiverse.solace.i18n.SolaceExceptions.ex; + +import java.util.concurrent.Flow.Processor; +import java.util.concurrent.Flow.Subscriber; +import java.util.concurrent.Flow.Subscription; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.eclipse.microprofile.reactive.messaging.Message; + +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.helpers.Subscriptions; + +class SenderProcessor implements Processor, Message>, Subscription { + + private final long inflights; + private final boolean waitForCompletion; + private final Function, Uni> send; + private final AtomicReference subscription = new AtomicReference<>(); + private final AtomicReference>> downstream = new AtomicReference<>(); + + public SenderProcessor(long inflights, boolean waitForCompletion, Function, Uni> send) { + this.inflights = inflights; + this.waitForCompletion = waitForCompletion; + this.send = send; + } + + @Override + public void subscribe( + Subscriber> subscriber) { + if (!downstream.compareAndSet(null, subscriber)) { + Subscriptions.fail(subscriber, ex.illegalStateOnlyOneSubscriber()); + } else { + if (subscription.get() != null) { + subscriber.onSubscribe(this); + } + } + } + + @Override + public void onSubscribe(Subscription subscription) { + if (this.subscription.compareAndSet(null, subscription)) { + Subscriber> subscriber = downstream.get(); + if (subscriber != null) { + subscriber.onSubscribe(this); + } + } else { + Subscriber> subscriber = downstream.get(); + if (subscriber != null) { + subscriber.onSubscribe(Subscriptions.CANCELLED); + } + } + } + + @Override + public void onNext(Message message) { + if (waitForCompletion) { + send.apply(message) + .subscribe().with( + x -> requestNext(message), + this::onError); + } else { + send.apply(message) + .subscribe().with(x -> { + }, this::onError); + requestNext(message); + } + } + + @Override + public void request(long l) { + if (l != Long.MAX_VALUE) { + throw ex.illegalStateConsumeWithoutBackPressure(); + } + subscription.get().request(inflights); + } + + @Override + public void cancel() { + Subscription s = subscription.getAndSet(Subscriptions.CANCELLED); + if (s != null) { + s.cancel(); + } + } + + private void requestNext(Message message) { + Subscriber> down = downstream.get(); + if (down != null) { + down.onNext(message); + } + Subscription up = this.subscription.get(); + if (up != null && inflights != Long.MAX_VALUE) { + up.request(1); + } + } + + @Override + public void onError(Throwable throwable) { + Subscriber> subscriber = downstream.getAndSet(null); + if (subscriber != null) { + subscriber.onError(throwable); + } + } + + @Override + public void onComplete() { + Subscriber> subscriber = downstream.getAndSet(null); + if (subscriber != null) { + subscriber.onComplete(); + } + } +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutboundMessage.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutboundMessage.java new file mode 100644 index 0000000..02f2057 --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutboundMessage.java @@ -0,0 +1,70 @@ +package io.quarkiverse.solace.outgoing; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.eclipse.microprofile.reactive.messaging.Message; +import org.eclipse.microprofile.reactive.messaging.Metadata; + +public class SolaceOutboundMessage implements Message { + + public static SolaceOutboundMessage of(T value, SolaceOutboundMetadata outboundMetadata, + Supplier> ack, + Function> nack) { + return new SolaceOutboundMessage<>(value, ack, nack, outboundMetadata); + } + + private final T value; + private final Supplier> ack; + private final Function> nack; + private final Metadata metadata; + private final SolaceOutboundMetadata outboundMetadata; + + public SolaceOutboundMessage(T value, + Supplier> ack, + Function> nack, + SolaceOutboundMetadata outboundMetadata) { + this.outboundMetadata = outboundMetadata; + this.value = value; + this.ack = ack; + this.nack = nack; + this.metadata = Metadata.of(outboundMetadata); + } + + @Override + public T getPayload() { + return value; + } + + @Override + public CompletionStage ack() { + if (ack == null) { + return CompletableFuture.completedFuture(null); + } else { + return ack.get(); + } + } + + @Override + public CompletionStage nack(Throwable reason, Metadata metadata) { + return Message.super.nack(reason, metadata); + } + + @Override + public Supplier> getAck() { + return ack; + } + + @Override + public Function> getNack() { + return nack; + } + + @Override + public Metadata getMetadata() { + return metadata; + } + +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutboundMetadata.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutboundMetadata.java new file mode 100644 index 0000000..2b83a19 --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutboundMetadata.java @@ -0,0 +1,150 @@ +package io.quarkiverse.solace.outgoing; + +import java.util.Map; + +public class SolaceOutboundMetadata { + + private final Map httpContentHeaders; + private final Long expiration; + private final Integer priority; + private final String senderId; + private final Map properties; + private final String applicationMessageType; + private final Long timeToLive; + private final String applicationMessageId; + private final Integer classOfService; + private final String dynamicDestination; + + public static PubSubOutboundMetadataBuilder builder() { + return new PubSubOutboundMetadataBuilder(); + } + + public SolaceOutboundMetadata(Map httpContentHeaders, + Long expiration, + Integer priority, + String senderId, + Map properties, + String applicationMessageType, + Long timeToLive, + String applicationMessageId, + Integer classOfService, String dynamicDestination) { + this.httpContentHeaders = httpContentHeaders; + this.expiration = expiration; + this.priority = priority; + this.senderId = senderId; + this.properties = properties; + this.applicationMessageType = applicationMessageType; + this.timeToLive = timeToLive; + this.applicationMessageId = applicationMessageId; + this.classOfService = classOfService; + this.dynamicDestination = dynamicDestination; + } + + public Map getHttpContentHeaders() { + return httpContentHeaders; + } + + public Long getExpiration() { + return expiration; + } + + public Integer getPriority() { + return priority; + } + + public String getSenderId() { + return senderId; + } + + public Map getProperties() { + return properties; + } + + public String getApplicationMessageType() { + return applicationMessageType; + } + + public Long getTimeToLive() { + return timeToLive; + } + + public String getApplicationMessageId() { + return applicationMessageId; + } + + public Integer getClassOfService() { + return classOfService; + } + + public String getDynamicDestination() { + return dynamicDestination; + } + + public static class PubSubOutboundMetadataBuilder { + private Map httpContentHeaders; + private Long expiration; + private Integer priority; + private String senderId; + private Map properties; + private String applicationMessageType; + private Long timeToLive; + private String applicationMessageId; + private Integer classOfService; + private String dynamicDestination; + + public PubSubOutboundMetadataBuilder setHttpContentHeaders(Map httpContentHeader) { + this.httpContentHeaders = httpContentHeaders; + return this; + } + + public PubSubOutboundMetadataBuilder setExpiration(Long expiration) { + this.expiration = expiration; + return this; + } + + public PubSubOutboundMetadataBuilder setPriority(Integer priority) { + this.priority = priority; + return this; + } + + public PubSubOutboundMetadataBuilder setSenderId(String senderId) { + this.senderId = senderId; + return this; + } + + public PubSubOutboundMetadataBuilder setProperties(Map properties) { + this.properties = properties; + return this; + } + + public PubSubOutboundMetadataBuilder setApplicationMessageType(String applicationMessageType) { + this.applicationMessageType = applicationMessageType; + return this; + } + + public PubSubOutboundMetadataBuilder setTimeToLive(Long timeToLive) { + this.timeToLive = timeToLive; + return this; + } + + public PubSubOutboundMetadataBuilder setApplicationMessageId(String applicationMessageId) { + this.applicationMessageId = applicationMessageId; + return this; + } + + public PubSubOutboundMetadataBuilder setClassOfService(Integer classOfService) { + this.classOfService = classOfService; + return this; + } + + public PubSubOutboundMetadataBuilder setDynamicDestination(String dynamicDestination) { + this.dynamicDestination = dynamicDestination; + return this; + } + + public SolaceOutboundMetadata createPubSubOutboundMetadata() { + return new SolaceOutboundMetadata(httpContentHeaders, expiration, priority, senderId, properties, + applicationMessageType, timeToLive, applicationMessageId, classOfService, dynamicDestination); + } + } +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutgoingChannel.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutgoingChannel.java new file mode 100644 index 0000000..8ec1ee9 --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutgoingChannel.java @@ -0,0 +1,228 @@ +package io.quarkiverse.solace.outgoing; + +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.eclipse.microprofile.reactive.messaging.Message; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.PersistentMessagePublisherBuilder; +import com.solace.messaging.PubSubPlusClientException; +import com.solace.messaging.publisher.OutboundMessage; +import com.solace.messaging.publisher.OutboundMessageBuilder; +import com.solace.messaging.publisher.PersistentMessagePublisher; +import com.solace.messaging.publisher.PersistentMessagePublisher.PublishReceipt; +import com.solace.messaging.publisher.PublisherHealthCheck; +import com.solace.messaging.resources.Topic; + +import io.netty.handler.codec.http.HttpHeaderValues; +import io.quarkiverse.solace.SolaceConnectorOutgoingConfiguration; +import io.quarkiverse.solace.i18n.SolaceLogging; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.subscription.UniEmitter; +import io.smallrye.reactive.messaging.OutgoingMessageMetadata; +import io.smallrye.reactive.messaging.health.HealthReport; +import io.smallrye.reactive.messaging.providers.helpers.MultiUtils; +import io.vertx.core.json.Json; +import io.vertx.mutiny.core.Vertx; + +public class SolaceOutgoingChannel + implements PersistentMessagePublisher.MessagePublishReceiptListener, PublisherHealthCheck.PublisherReadinessListener { + + private final PersistentMessagePublisher publisher; + private final String channel; + private final Flow.Subscriber> subscriber; + private final Topic topic; + private final SenderProcessor processor; + private boolean isPublisherReady = true; + + private long waitTimeout = -1; + + // Assuming we won't ever exceed the limit of an unsigned long... + private final UnsignedCounterBarrier publishedMessagesTracker = new UnsignedCounterBarrier(); + + public SolaceOutgoingChannel(Vertx vertx, SolaceConnectorOutgoingConfiguration oc, MessagingService solace) { + this.channel = oc.getChannel(); + PersistentMessagePublisherBuilder builder = solace.createPersistentMessagePublisherBuilder(); + switch (oc.getProducerBackPressureStrategy()) { + case "elastic": + builder.onBackPressureElastic(); + break; + case "wait": + builder.onBackPressureWait(oc.getProducerBackPressureBufferCapacity()); + break; + default: + builder.onBackPressureReject(oc.getProducerBackPressureBufferCapacity()); + break; + } + this.waitTimeout = oc.getClientShutdownWaitTimeout(); + oc.getProducerDeliveryAckTimeout().ifPresent(builder::withDeliveryAckTimeout); + oc.getProducerDeliveryAckWindowSize().ifPresent(builder::withDeliveryAckWindowSize); + this.publisher = builder.build(); + if (oc.getProducerWaitForPublishReceipt()) { + publisher.setMessagePublishReceiptListener(this); + } + boolean lazyStart = oc.getClientLazyStart(); + this.topic = Topic.of(oc.getProducerTopic().orElse(this.channel)); + this.processor = new SenderProcessor(oc.getProducerMaxInflightMessages(), oc.getProducerWaitForPublishReceipt(), + m -> sendMessage(solace, m, oc.getProducerWaitForPublishReceipt())); + this.subscriber = MultiUtils.via(processor, multi -> multi.plug( + m -> lazyStart ? m.onSubscription().call(() -> Uni.createFrom().completionStage(publisher.startAsync())) : m)); + if (!lazyStart) { + this.publisher.start(); + } + + this.publisher.setPublisherReadinessListener(new PublisherHealthCheck.PublisherReadinessListener() { + @Override + public void ready() { + isPublisherReady = true; + } + }); + } + + private Uni sendMessage(MessagingService solace, Message m, boolean waitForPublishReceipt) { + + // TODO - Use isPublisherReady to check if publisher is in ready state before publishing. This is required when back-pressure is set to reject. We need to block this call till isPublisherReady is true + return publishMessage(publisher, m, solace.messageBuilder(), waitForPublishReceipt) + .onItem().transformToUni(receipt -> { + if (receipt != null) { + OutgoingMessageMetadata.setResultOnMessage(m, receipt); + } + return Uni.createFrom().completionStage(m.getAck()); + }) + .onFailure().recoverWithUni(t -> Uni.createFrom().completionStage(m.nack(t))); + + } + + private Uni publishMessage(PersistentMessagePublisher publisher, Message m, + OutboundMessageBuilder msgBuilder, boolean waitForPublishReceipt) { + publishedMessagesTracker.increment(); + AtomicReference topic = new AtomicReference<>(this.topic); + OutboundMessage outboundMessage; + m.getMetadata(SolaceOutboundMetadata.class).ifPresent(metadata -> { + if (metadata.getHttpContentHeaders() != null && !metadata.getHttpContentHeaders().isEmpty()) { + metadata.getHttpContentHeaders().forEach(msgBuilder::withHTTPContentHeader); + } + if (metadata.getProperties() != null && !metadata.getProperties().isEmpty()) { + metadata.getProperties().forEach(msgBuilder::withProperty); + } + if (metadata.getExpiration() != null) { + msgBuilder.withExpiration(metadata.getExpiration()); + } + if (metadata.getPriority() != null) { + msgBuilder.withPriority(metadata.getPriority()); + } + if (metadata.getSenderId() != null) { + msgBuilder.withSenderId(metadata.getSenderId()); + } + if (metadata.getApplicationMessageType() != null) { + msgBuilder.withApplicationMessageType(metadata.getApplicationMessageType()); + } + if (metadata.getTimeToLive() != null) { + msgBuilder.withTimeToLive(metadata.getTimeToLive()); + } + if (metadata.getApplicationMessageId() != null) { + msgBuilder.withApplicationMessageId(metadata.getApplicationMessageId()); + } + if (metadata.getClassOfService() != null) { + msgBuilder.withClassOfService(metadata.getClassOfService()); + } + + if (metadata.getDynamicDestination() != null) { + topic.set(Topic.of(metadata.getDynamicDestination())); + } + }); + Object payload = m.getPayload(); + if (payload instanceof OutboundMessage) { + outboundMessage = (OutboundMessage) payload; + } else if (payload instanceof String) { + outboundMessage = msgBuilder + .withHTTPContentHeader(HttpHeaderValues.TEXT_PLAIN.toString(), "") + .build((String) payload); + } else if (payload instanceof byte[]) { + outboundMessage = msgBuilder.build((byte[]) payload); + } else { + outboundMessage = msgBuilder + .withHTTPContentHeader(HttpHeaderValues.APPLICATION_JSON.toString(), "") + .build(Json.encode(payload)); + } + return Uni.createFrom(). emitter(e -> { + boolean exitExceptionally = false; + try { + if(isPublisherReady) { + if (waitForPublishReceipt) { + publisher.publish(outboundMessage, topic.get(), e); + } else { + publisher.publish(outboundMessage, topic.get()); + e.complete(null); + publishedMessagesTracker.decrement(); + } + } + } catch (PubSubPlusClientException.PublisherOverflowException publisherOverflowException) { + isPublisherReady = false; + exitExceptionally = true; + e.fail(publisherOverflowException); + } catch (Throwable t) { + e.fail(t); + } finally { + if (exitExceptionally) { + publisher.notifyWhenReady(); + } + } + }).invoke(() -> SolaceLogging.log.successfullyToTopic(channel, topic.get().getName())); + } + + public Flow.Subscriber> getSubscriber() { + return this.subscriber; + } + + public void waitForPublishedMessages() { + try { + if (!publishedMessagesTracker.awaitEmpty(this.waitTimeout, TimeUnit.MILLISECONDS)) { + SolaceLogging.log.info(String.format("Timed out while waiting for the" + + " remaining messages to get publish acknowledgment.")); + } + } catch (InterruptedException e) { + SolaceLogging.log.info(String.format("Interrupted while waiting for messages to get acknowledged")); + throw new RuntimeException(e); + } + } + + public void close() { + if (processor != null) { + processor.cancel(); + } + + publisher.terminate(5000); + } + + @Override + public void onPublishReceipt(PublishReceipt publishReceipt) { + UniEmitter uniEmitter = (UniEmitter) publishReceipt.getUserContext(); + PubSubPlusClientException exception = publishReceipt.getException(); + if (exception != null) { + uniEmitter.fail(exception); + } else { + publishedMessagesTracker.decrement(); + uniEmitter.complete(publishReceipt); + } + } + + public void isStarted(HealthReport.HealthReportBuilder builder) { + + } + + public void isReady(HealthReport.HealthReportBuilder builder) { + + } + + public void isAlive(HealthReport.HealthReportBuilder builder) { + + } + + @Override + public void ready() { + isPublisherReady = true; + } +} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/UnsignedCounterBarrier.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/UnsignedCounterBarrier.java new file mode 100644 index 0000000..7afc29e --- /dev/null +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/UnsignedCounterBarrier.java @@ -0,0 +1,103 @@ +package io.quarkiverse.solace.outgoing; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +class UnsignedCounterBarrier { + private final AtomicLong counter; // Treated as an unsigned long (i.e. range from 0 -> -1) + private final Lock awaitLock = new ReentrantLock(); + private final Condition isZero = awaitLock.newCondition(); + + private static final Log logger = LogFactory.getLog(UnsignedCounterBarrier.class); + + public UnsignedCounterBarrier(long initialValue) { + counter = new AtomicLong(initialValue); + } + + public UnsignedCounterBarrier() { + this(0); + } + + public void increment() { + // Assuming we won't ever increment past -1 + counter.updateAndGet(c -> Long.compareUnsigned(c, -1) < 0 ? c + 1 : c); + } + + public void decrement() { + // Assuming we won't ever decrement below 0 + if (counter.updateAndGet(c -> Long.compareUnsigned(c, 0) > 0 ? c - 1 : c) == 0) { + awaitLock.lock(); + try { + isZero.signalAll(); + } finally { + awaitLock.unlock(); + } + } + } + + public void reset() { + if (Long.compareUnsigned(counter.getAndSet(0), 0) != 0) { + awaitLock.lock(); + try { + isZero.signalAll(); + } finally { + awaitLock.unlock(); + } + } + } + + /** + * Wait until counter is zero. + * + * @param timeout the maximum wait time. If less than 0, then wait forever. + * @param unit the timeout unit. + * @return true if counter reached zero. False if timed out. + * @throws InterruptedException if the wait was interrupted + */ + public boolean awaitEmpty(long timeout, TimeUnit unit) throws InterruptedException { + awaitLock.lock(); + try { + if (timeout > 0) { + logger.info(String.format("Waiting for %s items, time remaining: %s %s", counter.get(), timeout, unit)); + final long expiry = unit.toMillis(timeout) + System.currentTimeMillis(); + while (isGreaterThanZero()) { + long realTimeout = expiry - System.currentTimeMillis(); + if (realTimeout <= 0) { + return false; + } + isZero.await(realTimeout, TimeUnit.MILLISECONDS); + } + return true; + } else if (timeout < 0) { + while (isGreaterThanZero()) { + logger.info(String.format("Waiting for %s items", counter.get())); + isZero.await(5, TimeUnit.SECONDS); + } + return true; + } else { + return !isGreaterThanZero(); + } + } finally { + awaitLock.unlock(); + } + } + + private boolean isGreaterThanZero() { + return Long.compareUnsigned(counter.get(), 0) > 0; + } + + /** + * Get the unsigned count. + * + * @return The count. + */ + public long getCount() { + return counter.get(); + } +} diff --git a/pubsub-plus-connector/src/main/resources/META-INF/beans.xml b/pubsub-plus-connector/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000..e69de29 diff --git a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolaceConsumerTest.java b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolaceConsumerTest.java new file mode 100644 index 0000000..26402d6 --- /dev/null +++ b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolaceConsumerTest.java @@ -0,0 +1,297 @@ +package io.quarkiverse.solace; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Properties; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutionException; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.config.profile.ConfigurationProfile; +import io.quarkiverse.solace.base.MessagingServiceProvider; +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import com.solace.messaging.config.SolaceProperties; +import com.solace.messaging.publisher.OutboundMessage; +import com.solace.messaging.publisher.OutboundMessageBuilder; +import com.solace.messaging.publisher.PersistentMessagePublisher; +import com.solace.messaging.receiver.InboundMessage; +import com.solace.messaging.resources.Topic; + +import io.quarkiverse.solace.base.SolaceContainer; +import io.quarkiverse.solace.base.WeldTestBase; +import io.smallrye.reactive.messaging.test.common.config.MapBasedConfig; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class SolaceConsumerTest extends WeldTestBase { + + @Test + @Order(1) + void consumer() { + MapBasedConfig config = new MapBasedConfig() + .with("mp.messaging.incoming.in.connector", "quarkus-solace") + .with("mp.messaging.incoming.in.consumer.queue.name", queue) + .with("mp.messaging.incoming.in.consumer.queue.add-additional-subscriptions", "true") + .with("mp.messaging.incoming.in.consumer.queue.missing-resource-creation-strategy", "create-on-start") + .with("mp.messaging.incoming.in.consumer.queue.subscriptions", topic); + + // Run app that consumes messages + MyConsumer app = runApplication(config, MyConsumer.class); + + // Produce messages + PersistentMessagePublisher publisher = messagingService.createPersistentMessagePublisherBuilder() + .build() + .start(); + Topic tp = Topic.of(topic); + publisher.publish("1", tp); + publisher.publish("2", tp); + publisher.publish("3", tp); + publisher.publish("4", tp); + publisher.publish("5", tp); + + // Assert on published messages + await().untilAsserted(() -> assertThat(app.getReceived()).contains("1", "2", "3", "4", "5")); + } + + @Test + @Order(2) + void consumerReplay() { + MapBasedConfig config = new MapBasedConfig() + .with("mp.messaging.incoming.in.connector", "quarkus-solace") + .with("mp.messaging.incoming.in.consumer.queue.name", queue) + .with("mp.messaging.incoming.in.consumer.queue.type", "durable-exclusive") + .with("mp.messaging.incoming.in.consumer.queue.add-additional-subscriptions", "true") + .with("mp.messaging.incoming.in.consumer.queue.missing-resource-creation-strategy", "create-on-start") + .with("mp.messaging.incoming.in.consumer.queue.subscriptions", topic) + .with("mp.messaging.incoming.in.consumer.queue.replay.strategy", "all-messages"); + + // Run app that consumes messages + MyConsumer app = runApplication(config, MyConsumer.class); + + // Assert on published messages + await().untilAsserted(() -> assertThat(app.getReceived().size()).isEqualTo(5)); + await().untilAsserted(() -> assertThat(app.getReceived()).contains("1", "2", "3", "4", "5")); + } + + @Test + @Order(3) + void consumerWithSelectorQuery() { + MapBasedConfig config = new MapBasedConfig() + .with("mp.messaging.incoming.in.connector", "quarkus-solace") + .with("mp.messaging.incoming.in.consumer.queue.name", queue) + .with("mp.messaging.incoming.in.consumer.queue.add-additional-subscriptions", "true") + .with("mp.messaging.incoming.in.consumer.queue.missing-resource-creation-strategy", "create-on-start") + .with("mp.messaging.incoming.in.consumer.queue.selector-query", "id = '1'") + .with("mp.messaging.incoming.in.consumer.queue.subscriptions", topic); + + // Run app that consumes messages + MyConsumer app = runApplication(config, MyConsumer.class); + + // Produce messages + PersistentMessagePublisher publisher = messagingService.createPersistentMessagePublisherBuilder() + .build() + .start(); + Topic tp = Topic.of(topic); + publisher.publish(messagingService.messageBuilder().withProperty("id", "1").build("1"), tp); + publisher.publish(messagingService.messageBuilder().withProperty("id", "2").build("2"), tp); + publisher.publish(messagingService.messageBuilder().withProperty("id", "3").build("3"), tp); + publisher.publish(messagingService.messageBuilder().withProperty("id", "4").build("4"), tp); + publisher.publish(messagingService.messageBuilder().withProperty("id", "5").build("5"), tp); + + // Assert on published messages + await().untilAsserted(() -> assertThat(app.getReceived().size()).isEqualTo(1)); + await().untilAsserted(() -> assertThat(app.getReceived()).contains("1")); + } + + @Test + @Order(4) + void consumerFailedProcessingPublishToErrorTopic() { + MapBasedConfig config = new MapBasedConfig() + .with("mp.messaging.incoming.in.connector", "quarkus-solace") + .with("mp.messaging.incoming.in.consumer.queue.name", SolaceContainer.INTEGRATION_TEST_QUEUE_NAME) + .with("mp.messaging.incoming.in.consumer.queue.type", "durable-exclusive") + .with("mp.messaging.incoming.in.consumer.queue.publish-to-error-topic-on-failure", true) + .with("mp.messaging.incoming.in.consumer.queue.error.topic", + SolaceContainer.INTEGRATION_TEST_ERROR_QUEUE_SUBSCRIPTION) + .with("mp.messaging.incoming.error-in.connector", "quarkus-solace") + .with("mp.messaging.incoming.error-in.consumer.queue.name", SolaceContainer.INTEGRATION_TEST_ERROR_QUEUE_NAME) + .with("mp.messaging.incoming.error-in.consumer.queue.type", "durable-exclusive"); + + // Run app that consumes messages + MyErrorQueueConsumer app = runApplication(config, MyErrorQueueConsumer.class); + + // Produce messages + PersistentMessagePublisher publisher = messagingService.createPersistentMessagePublisherBuilder() + .build() + .start(); + Topic tp = Topic.of(SolaceContainer.INTEGRATION_TEST_QUEUE_SUBSCRIPTION); + OutboundMessageBuilder messageBuilder = messagingService.messageBuilder(); + OutboundMessage outboundMessage = messageBuilder.build("2"); + publisher.publish(outboundMessage, tp); + + // Assert on published messages + await().untilAsserted(() -> assertThat(app.getReceived().size()).isEqualTo(0)); + await().untilAsserted(() -> assertThat(app.getReceivedFailedMessages().size()).isEqualTo(1)); + try { + Thread.sleep(5000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + @Test + @Order(5) + void consumerFailedProcessingMoveToDMQ() { + MapBasedConfig config = new MapBasedConfig() + .with("mp.messaging.incoming.in.connector", "quarkus-solace") + .with("mp.messaging.incoming.in.consumer.queue.name", SolaceContainer.INTEGRATION_TEST_QUEUE_NAME) + .with("mp.messaging.incoming.in.consumer.queue.type", "durable-exclusive") + .with("mp.messaging.incoming.in.consumer.queue.enable-nacks", "true") + .with("mp.messaging.incoming.in.consumer.queue.discard-messages-on-failure", "true") + .with("mp.messaging.incoming.dmq-in.connector", "quarkus-solace") + .with("mp.messaging.incoming.dmq-in.consumer.queue.name", SolaceContainer.INTEGRATION_TEST_DMQ_NAME) + .with("mp.messaging.incoming.dmq-in.consumer.queue.type", "durable-exclusive"); + + // Run app that consumes messages + MyDMQConsumer app = runApplication(config, MyDMQConsumer.class); + + // Produce messages + PersistentMessagePublisher publisher = messagingService.createPersistentMessagePublisherBuilder() + .build() + .start(); + Topic tp = Topic.of(SolaceContainer.INTEGRATION_TEST_QUEUE_SUBSCRIPTION); + OutboundMessageBuilder messageBuilder = messagingService.messageBuilder(); + messageBuilder.withTimeToLive(0); + Properties properties = new Properties(); + properties.setProperty(SolaceProperties.MessageProperties.PERSISTENT_DMQ_ELIGIBLE, "true"); + messageBuilder.fromProperties(properties); + OutboundMessage outboundMessage = messageBuilder.build("1"); + publisher.publish(outboundMessage, tp); + + // Assert on published messages + await().untilAsserted(() -> assertThat(app.getReceived().size()).isEqualTo(0)); + await().untilAsserted(() -> assertThat(app.getReceivedDMQMessages().size()).isEqualTo(1)); + } + + @Test + @Order(6) + void consumerCreateMissingResourceAddSubscriptionPermissionException() { + MapBasedConfig config = new MapBasedConfig() + .with("mp.messaging.incoming.in.connector", "quarkus-solace") + .with("mp.messaging.incoming.in.consumer.queue.add-additional-subscriptions", "true") + .with("mp.messaging.incoming.in.consumer.queue.missing-resource-creation-strategy", "create-on-start") + .with("mp.messaging.incoming.in.consumer.queue.name", SolaceContainer.INTEGRATION_TEST_QUEUE_NAME) + .with("mp.messaging.incoming.in.consumer.queue.type", "durable-exclusive") + .with("mp.messaging.incoming.in.consumer.queue.subscriptions", topic); + + Exception exception = assertThrows(Exception.class, () -> { + // Run app that consumes messages + MyConsumer app = runApplication(config, MyConsumer.class); + }); + + // Assert on published messages + await().untilAsserted(() -> assertThat(exception.getMessage()) + .contains("com.solacesystems.jcsmp.AccessDeniedException: Permission Not Allowed - Queue '" + + SolaceContainer.INTEGRATION_TEST_QUEUE_NAME + "' - Topic '" + topic)); + } + + @Test + @Order(7) + void consumerPublishToErrorTopicPermissionException() { + MapBasedConfig config = new MapBasedConfig() + .with("mp.messaging.incoming.in.connector", "quarkus-solace") + .with("mp.messaging.incoming.in.consumer.queue.name", SolaceContainer.INTEGRATION_TEST_QUEUE_NAME) + .with("mp.messaging.incoming.in.consumer.queue.type", "durable-exclusive") + .with("mp.messaging.incoming.in.consumer.queue.publish-to-error-topic-on-failure", true) + .with("mp.messaging.incoming.in.consumer.queue.error.topic", + "publish/deny") + .with("mp.messaging.incoming.error-in.connector", "quarkus-solace") + .with("mp.messaging.incoming.error-in.consumer.queue.name", SolaceContainer.INTEGRATION_TEST_ERROR_QUEUE_NAME) + .with("mp.messaging.incoming.error-in.consumer.queue.type", "durable-exclusive"); + + // Run app that consumes messages + MyErrorQueueConsumer app = runApplication(config, MyErrorQueueConsumer.class); + // Produce messages + PersistentMessagePublisher publisher = messagingService.createPersistentMessagePublisherBuilder() + .build() + .start(); + Topic tp = Topic.of(SolaceContainer.INTEGRATION_TEST_QUEUE_SUBSCRIPTION); + OutboundMessageBuilder messageBuilder = messagingService.messageBuilder(); + OutboundMessage outboundMessage = messageBuilder.build("2"); + publisher.publish(outboundMessage, tp); + + await().untilAsserted(() -> assertThat(app.getReceived().size()).isEqualTo(0)); + } + + @ApplicationScoped + static class MyConsumer { + private final List received = new CopyOnWriteArrayList<>(); + + @Incoming("in") + void in(InboundMessage msg) { + received.add(msg.getPayloadAsString()); + } + + public List getReceived() { + return received; + } + } + + @ApplicationScoped + static class MyDMQConsumer { + private final List received = new CopyOnWriteArrayList<>(); + + private List receivedDMQMessages = new CopyOnWriteArrayList<>(); + + @Incoming("in") + void in(String msg) { + received.add(msg); + } + + @Incoming("dmq-in") + void dmqin(InboundMessage msg) { + receivedDMQMessages.add(msg.getPayloadAsString()); + } + + public List getReceived() { + return received; + } + + public List getReceivedDMQMessages() { + return receivedDMQMessages; + } + } + + @ApplicationScoped + static class MyErrorQueueConsumer { + private final List received = new CopyOnWriteArrayList<>(); + private List receivedFailedMessages = new CopyOnWriteArrayList<>(); + + @Incoming("in") + void in(String msg) { + received.add(msg); + } + + @Incoming("error-in") + void errorin(InboundMessage msg) { + receivedFailedMessages.add(msg.getPayloadAsString()); + } + + public List getReceived() { + return received; + } + + public List getReceivedFailedMessages() { + return receivedFailedMessages; + } + } +} diff --git a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolaceProcessorTest.java b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolaceProcessorTest.java new file mode 100644 index 0000000..007f8e4 --- /dev/null +++ b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolaceProcessorTest.java @@ -0,0 +1,84 @@ +package io.quarkiverse.solace; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Outgoing; +import org.junit.jupiter.api.Test; + +import com.solace.messaging.publisher.OutboundMessage; +import com.solace.messaging.publisher.PersistentMessagePublisher; +import com.solace.messaging.receiver.InboundMessage; +import com.solace.messaging.receiver.PersistentMessageReceiver; +import com.solace.messaging.resources.Queue; +import com.solace.messaging.resources.Topic; +import com.solace.messaging.resources.TopicSubscription; + +import io.quarkiverse.solace.base.WeldTestBase; +import io.smallrye.reactive.messaging.test.common.config.MapBasedConfig; + +public class SolaceProcessorTest extends WeldTestBase { + + @Test + void consumer() { + String processedTopic = topic + "-processed"; + MapBasedConfig config = new MapBasedConfig() + .with("mp.messaging.incoming.in.connector", "quarkus-solace") + .with("mp.messaging.incoming.in.consumer.queue.add-additional-subscriptions", "true") + .with("mp.messaging.incoming.in.consumer.queue.missing-resource-creation-strategy", "create-on-start") + .with("mp.messaging.incoming.in.consumer.queue.subscriptions", topic) + .with("mp.messaging.outgoing.out.connector", "quarkus-solace") + .with("mp.messaging.outgoing.out.producer.topic", processedTopic); + + // Run app that processes messages + MyProcessor app = runApplication(config, MyProcessor.class); + + List expected = new CopyOnWriteArrayList<>(); + + // Start listening processed messages + PersistentMessageReceiver receiver = messagingService.createPersistentMessageReceiverBuilder() + .withSubscriptions(TopicSubscription.of(processedTopic)) + .build(Queue.nonDurableExclusiveQueue()); + receiver.receiveAsync(inboundMessage -> expected.add(inboundMessage.getPayloadAsString())); + receiver.start(); + + // Produce messages + PersistentMessagePublisher publisher = messagingService.createPersistentMessagePublisherBuilder() + .build() + .start(); + Topic tp = Topic.of(topic); + publisher.publish("1", tp); + publisher.publish("2", tp); + publisher.publish("3", tp); + publisher.publish("4", tp); + publisher.publish("5", tp); + + // Assert on received messages + await().untilAsserted(() -> assertThat(app.getReceived()).contains("1", "2", "3", "4", "5")); + // Assert on processed messages + await().untilAsserted(() -> assertThat(expected).contains("1", "2", "3", "4", "5")); + } + + @ApplicationScoped + static class MyProcessor { + private final List received = new CopyOnWriteArrayList<>(); + + @Incoming("in") + @Outgoing("out") + OutboundMessage in(InboundMessage msg) { + String payload = msg.getPayloadAsString(); + received.add(payload); + return messagingService.messageBuilder().build(payload); + } + + public List getReceived() { + return received; + } + } +} diff --git a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolacePublisherTest.java b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolacePublisherTest.java new file mode 100644 index 0000000..5e84605 --- /dev/null +++ b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolacePublisherTest.java @@ -0,0 +1,209 @@ +package io.quarkiverse.solace; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +import io.quarkiverse.solace.outgoing.SolaceOutboundMetadata; +import io.quarkiverse.solace.outgoing.SolaceOutgoingChannel; +import io.smallrye.mutiny.Uni; +import io.smallrye.reactive.messaging.MutinyEmitter; +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Message; +import org.eclipse.microprofile.reactive.messaging.Metadata; +import org.eclipse.microprofile.reactive.messaging.Outgoing; +import org.junit.jupiter.api.Test; + +import com.solace.messaging.receiver.PersistentMessageReceiver; +import com.solace.messaging.resources.Queue; +import com.solace.messaging.resources.TopicSubscription; + +import io.quarkiverse.solace.base.WeldTestBase; +import io.smallrye.mutiny.Multi; +import io.smallrye.reactive.messaging.test.common.config.MapBasedConfig; + +public class SolacePublisherTest extends WeldTestBase { + + @Test + void publisher() { + MapBasedConfig config = new MapBasedConfig() + .with("mp.messaging.outgoing.out.connector", "quarkus-solace") + .with("mp.messaging.outgoing.out.producer.topic", topic); + + List expected = new CopyOnWriteArrayList<>(); + + // Start listening first + PersistentMessageReceiver receiver = messagingService.createPersistentMessageReceiverBuilder() + .withSubscriptions(TopicSubscription.of(topic)) + .build(Queue.nonDurableExclusiveQueue()); + receiver.receiveAsync(inboundMessage -> expected.add(inboundMessage.getPayloadAsString())); + receiver.start(); + + // Run app that publish messages + MyApp app = runApplication(config, MyApp.class); + // Assert on published messages + await().untilAsserted(() -> assertThat(app.getAcked()).contains("1", "2", "3", "4", "5")); + // Assert on received messages + await().untilAsserted(() -> assertThat(expected).contains("1", "2", "3", "4", "5")); + } + + @Test + void publisherWithDynamicDestination() { + MapBasedConfig config = new MapBasedConfig() + .with("mp.messaging.outgoing.out.connector", "quarkus-solace") + .with("mp.messaging.outgoing.out.producer.topic", topic); + + List expected = new CopyOnWriteArrayList<>(); + + // Start listening first + PersistentMessageReceiver receiver = messagingService.createPersistentMessageReceiverBuilder() + .withSubscriptions(TopicSubscription.of("quarkus/integration/test/dynamic/topic/*")) + .build(Queue.nonDurableExclusiveQueue()); + receiver.receiveAsync(inboundMessage -> { + expected.add(inboundMessage.getDestinationName()); + }); + receiver.start(); + + // Run app that publish messages + MyDynamicDestinationApp app = runApplication(config, MyDynamicDestinationApp.class); + // Assert on published messages + await().untilAsserted(() -> assertThat(app.getAcked()).contains("1", "2", "3", "4", "5")); + // Assert on received messages + await().untilAsserted(() -> assertThat(expected).contains("quarkus/integration/test/dynamic/topic/1", "quarkus/integration/test/dynamic/topic/2", "quarkus/integration/test/dynamic/topic/3", + "quarkus/integration/test/dynamic/topic/4", "quarkus/integration/test/dynamic/topic/5")); + } + + @Test + void publisherWithBackPressureReject() { + MapBasedConfig config = new MapBasedConfig() + .with("mp.messaging.outgoing.out.connector", "quarkus-solace") + .with("mp.messaging.outgoing.out.producer.topic", topic) + .with("mp.messaging.outgoing.out.producer.back-pressure.buffer-capacity", 1); + + List expected = new CopyOnWriteArrayList<>(); + + // Start listening first + PersistentMessageReceiver receiver = messagingService.createPersistentMessageReceiverBuilder() + .withSubscriptions(TopicSubscription.of("topic")) + .build(Queue.nonDurableExclusiveQueue()); + receiver.receiveAsync(inboundMessage -> { + expected.add(inboundMessage.getPayloadAsString()); + }); + receiver.start(); + + // Run app that publish messages + MyApp app = runApplication(config, MyApp.class); + // Assert on published messages + await().untilAsserted(() -> assertThat(app.getAcked().size()).isLessThan(5)); + } + +// @Test +// void publisherWithBackPressureRejectWaitForPublisherReadiness() { +// MapBasedConfig config = new MapBasedConfig() +// .with("mp.messaging.outgoing.out.connector", "quarkus-solace") +// .with("mp.messaging.outgoing.out.producer.topic", topic) +// .with("mp.messaging.outgoing.out.producer.back-pressure.buffer-capacity", 1); +// +// List expected = new CopyOnWriteArrayList<>(); +// +// // Start listening first +// PersistentMessageReceiver receiver = messagingService.createPersistentMessageReceiverBuilder() +// .withSubscriptions(TopicSubscription.of("topic")) +// .build(Queue.nonDurableExclusiveQueue()); +// receiver.receiveAsync(inboundMessage -> { +// expected.add(inboundMessage.getPayloadAsString()); +// }); +// receiver.start(); +// +// // Run app that publish messages +// MyBackPressureRejectApp app = runApplication(config, MyBackPressureRejectApp.class); +// // Assert on published messages +// await().untilAsserted(() -> assertThat(app.getAcked()).contains("1", "2", "3", "4", "5")); +// } + + @ApplicationScoped + static class MyApp { + private final List acked = new CopyOnWriteArrayList<>(); + + @Outgoing("out") + Multi> out() { + + return Multi.createFrom().items("1", "2", "3", "4", "5") + .map(payload -> Message.of(payload).withAck(() -> { + acked.add(payload); + return CompletableFuture.completedFuture(null); + })); + } + + public List getAcked() { + return acked; + } + } + +// @ApplicationScoped +// static class MyBackPressureRejectApp { +// private final List acked = new CopyOnWriteArrayList<>(); +// public boolean waitForPublisherReadiness = false; +// @Channel("outgoing") +// MutinyEmitter foobar; +// +// void out() { +// List items = new ArrayList<>(); +// items.add("1"); +// items.add("2"); +// items.add("3"); +// items.add("4"); +// items.add("5"); +// items.forEach(payload -> { +// Message message = Message.of(payload).withAck(() -> { +// acked.add(payload); +// return CompletableFuture.completedFuture(null); +// }); +// if (waitForPublisherReadiness) { +// while (SolaceOutgoingChannel.isPublisherReady) { +// foobar.sendMessage(message); +// } +// } else { +// foobar.sendMessage(message); +// } +// }); +// } +// +// public List getAcked() { +// return acked; +// } +// } + + + @ApplicationScoped + static class MyDynamicDestinationApp { + private final List acked = new CopyOnWriteArrayList<>(); + + @Outgoing("out") + Multi> out() { + return Multi.createFrom().items("1", "2", "3", "4", "5") + .map(payload -> { + SolaceOutboundMetadata outboundMetadata = SolaceOutboundMetadata.builder() + .setApplicationMessageId("test").setDynamicDestination("quarkus/integration/test/dynamic/topic/" + payload) + .createPubSubOutboundMetadata(); + Message message = Message.of(payload, Metadata.of(outboundMetadata)); + return message.withAck(() -> { + acked.add(payload); + return CompletableFuture.completedFuture(null); + }); + }); + } + + public List getAcked() { + return acked; + } + } +} diff --git a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/MessagingServiceProvider.java b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/MessagingServiceProvider.java new file mode 100644 index 0000000..1460286 --- /dev/null +++ b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/MessagingServiceProvider.java @@ -0,0 +1,17 @@ +package io.quarkiverse.solace.base; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; + +import com.solace.messaging.MessagingService; + +@ApplicationScoped +public class MessagingServiceProvider { + + static MessagingService messagingService; + + @Produces + MessagingService messagingService() { + return messagingService; + } +} diff --git a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceBaseTest.java b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceBaseTest.java new file mode 100644 index 0000000..cc96e72 --- /dev/null +++ b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceBaseTest.java @@ -0,0 +1,59 @@ +package io.quarkiverse.solace.base; + +import java.lang.reflect.Method; +import java.util.Properties; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.extension.ExtendWith; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.config.SolaceProperties; +import com.solace.messaging.config.profile.ConfigurationProfile; + +@ExtendWith(SolaceBrokerExtension.class) +public class SolaceBaseTest { + + public static SolaceContainer solace; + + public static MessagingService messagingService; + + public String topic; + + public String queue; + + @BeforeEach + public void initTopic(TestInfo testInfo) { + String cn = testInfo.getTestClass().map(Class::getSimpleName).orElse(UUID.randomUUID().toString()); + String mn = testInfo.getTestMethod().map(Method::getName).orElse(UUID.randomUUID().toString()); +// topic = cn + "/" + mn + "/" + UUID.randomUUID().getMostSignificantBits(); + topic = "quarkus/integration/test/default/topic"; + } + + @BeforeEach + public void initQueueName(TestInfo testInfo) { + String cn = testInfo.getTestClass().map(Class::getSimpleName).orElse(UUID.randomUUID().toString()); + String mn = testInfo.getTestMethod().map(Method::getName).orElse(UUID.randomUUID().toString()); + queue = cn + "." + mn + "." + UUID.randomUUID().getMostSignificantBits(); + + } + + @BeforeAll + static void init(SolaceContainer container) { + solace = container; + Properties properties = new Properties(); + properties.put(SolaceProperties.TransportLayerProperties.HOST, solace.getOrigin(SolaceContainer.Service.SMF)); + properties.put(SolaceProperties.ServiceProperties.VPN_NAME, solace.getVpn()); + properties.put(SolaceProperties.AuthenticationProperties.SCHEME_BASIC_USER_NAME, solace.getUsername()); + properties.put(SolaceProperties.AuthenticationProperties.SCHEME_BASIC_PASSWORD, solace.getPassword()); + + messagingService = MessagingService.builder(ConfigurationProfile.V1) + .fromProperties(properties) + .build(); + messagingService.connect(); + MessagingServiceProvider.messagingService = messagingService; + } + +} diff --git a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceBrokerExtension.java b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceBrokerExtension.java new file mode 100644 index 0000000..4e6a37b --- /dev/null +++ b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceBrokerExtension.java @@ -0,0 +1,126 @@ +package io.quarkiverse.solace.base; + +import static org.junit.jupiter.api.extension.ExtensionContext.Namespace.GLOBAL; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + +import java.time.Duration; + +import org.jboss.logging.Logger; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Store.CloseableResource; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; + +public class SolaceBrokerExtension implements BeforeAllCallback, ParameterResolver, CloseableResource { + + public static final Logger LOGGER = Logger.getLogger(SolaceBrokerExtension.class.getName()); + private static final String SOLACE_READY_MESSAGE = ".*Primary Virtual Router is now active.*"; + private static final String SOLACE_IMAGE = "solace/solace-pubsub-standard:latest"; + + protected SolaceContainer solace; + + @Override + public void beforeAll(ExtensionContext context) throws Exception { + ExtensionContext.Store globalStore = context.getRoot().getStore(GLOBAL); + SolaceBrokerExtension extension = (SolaceBrokerExtension) globalStore.get(SolaceBrokerExtension.class); + if (extension == null) { + LOGGER.info("Starting Solace broker"); + startSolaceBroker(); + globalStore.put(SolaceBrokerExtension.class, this); + } + } + + @Override + public void close() throws Throwable { + + } + + public static SolaceContainer createSolaceContainer() { + return new SolaceContainer(SOLACE_IMAGE); + } + + public void startSolaceBroker() { + solace = createSolaceContainer() + .withCredentials("user", "pass") + .withExposedPorts(SolaceContainer.Service.SMF.getPort()) + .withPublishTopic("quarkus/integration/test/default/topic", SolaceContainer.Service.SMF) + .withPublishTopic("quarkus/integration/test/provisioned/queue/topic", SolaceContainer.Service.SMF) + .withPublishTopic("quarkus/integration/test/provisioned/queue/error/topic", SolaceContainer.Service.SMF) + .withPublishTopic("quarkus/integration/test/dynamic/topic/1", SolaceContainer.Service.SMF) + .withPublishTopic("quarkus/integration/test/dynamic/topic/2", SolaceContainer.Service.SMF) + .withPublishTopic("quarkus/integration/test/dynamic/topic/3", SolaceContainer.Service.SMF) + .withPublishTopic("quarkus/integration/test/dynamic/topic/4", SolaceContainer.Service.SMF) + .withPublishTopic("quarkus/integration/test/dynamic/topic/5", SolaceContainer.Service.SMF) + .withPublishTopic("quarkus/integration/test/default/topic-processed", SolaceContainer.Service.SMF); + solace.start(); + LOGGER.info("Solace broker started: " + solace.getOrigin(SolaceContainer.Service.SMF)); + await().until(() -> solace.isRunning()); + } + + /** + * We need to restart the broker on the same exposed port. + * Test Containers makes this unnecessarily complicated, but well, let's go for a hack. + * See https://github.com/testcontainers/testcontainers-java/issues/256. + * + * @param solace the broker that will be closed + * @param gracePeriodInSecond number of seconds to wait before restarting + * @return the new broker + */ + public static SolaceContainer restart(SolaceContainer solace, int gracePeriodInSecond) { + int port = solace.getMappedPort(SolaceContainer.Service.SMF.getPort()); + try { + solace.close(); + } catch (Exception e) { + // Ignore me. + } + await().until(() -> !solace.isRunning()); + sleep(Duration.ofSeconds(gracePeriodInSecond)); + + return startSolaceBroker(port); + } + + public static SolaceContainer startSolaceBroker(int port) { + SolaceContainer solace = createSolaceContainer(); + solace.start(); + await().until(solace::isRunning); + return solace; + } + + private static void sleep(Duration duration) { + try { + Thread.sleep(duration.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + public void stopSolaceBroker() { + if (solace != null) { + try { + solace.stop(); + } catch (Exception e) { + // Ignore it. + } + await().until(() -> !solace.isRunning()); + } + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + return parameterContext.getParameter().getType().equals(SolaceContainer.class); + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + ExtensionContext.Store globalStore = extensionContext.getRoot().getStore(GLOBAL); + SolaceBrokerExtension extension = (SolaceBrokerExtension) globalStore.get(SolaceBrokerExtension.class); + if (parameterContext.getParameter().getType().equals(SolaceContainer.class)) { + return extension.solace; + } + return null; + } +} diff --git a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceContainer.java b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceContainer.java new file mode 100644 index 0000000..91fca33 --- /dev/null +++ b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceContainer.java @@ -0,0 +1,467 @@ +package io.quarkiverse.solace.base; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.shaded.org.apache.commons.lang3.tuple.Pair; +import org.testcontainers.shaded.org.awaitility.Awaitility; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.Ulimit; + +public class SolaceContainer extends GenericContainer { + + public static final String INTEGRATION_TEST_QUEUE_NAME = "integration-test-queue"; + public static final String INTEGRATION_TEST_QUEUE_SUBSCRIPTION = "quarkus/integration/test/provisioned/queue/topic"; + public static final String INTEGRATION_TEST_DMQ_NAME = "integration-test-queue-dmq"; + public static final String INTEGRATION_TEST_ERROR_QUEUE_NAME = "integration-test-error-queue"; + public static final String INTEGRATION_TEST_ERROR_QUEUE_SUBSCRIPTION = "quarkus/integration/test/provisioned/queue/error/topic"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("solace/solace-pubsub-standard"); + + private static final String DEFAULT_VPN = "default"; + + private static final String DEFAULT_USERNAME = "default"; + + private static final String SOLACE_READY_MESSAGE = ".*Running pre-startup checks:.*"; + + private static final String SOLACE_ACTIVE_MESSAGE = "Primary Virtual Router is now active"; + + private static final String TMP_SCRIPT_LOCATION = "/tmp/script.cli"; + + private static final Long SHM_SIZE = (long) Math.pow(1024, 3); + + private String username = "root"; + + private String password = "password"; + + private String vpn = DEFAULT_VPN; + + private final List> publishTopicsConfiguration = new ArrayList<>(); + private final List> subscribeTopicsConfiguration = new ArrayList<>(); + + private boolean withClientCert; + + /** + * Create a new solace container with the specified image name. + * + * @param dockerImageName the image name that should be used. + */ + public SolaceContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public SolaceContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withCreateContainerCmdModifier(cmd -> { + cmd.getHostConfig().withShmSize(SHM_SIZE).withUlimits(new Ulimit[] { new Ulimit("nofile", 2448L, 6592L) }); + }); + this.waitStrategy = Wait.forLogMessage(SOLACE_READY_MESSAGE, 1).withStartupTimeout(Duration.ofSeconds(60)); + withExposedPorts(8080); + withEnv("system_scaling_maxconnectioncount", "100"); + withEnv("logging_system_output", "all"); + withEnv("username_admin_globalaccesslevel", "admin"); + withEnv("username_admin_password", "admin"); + } + + @Override + protected void configure() { + withCopyToContainer(createConfigurationScript(), TMP_SCRIPT_LOCATION); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + if (withClientCert) { + executeCommand("cp", "/tmp/solace.pem", "/usr/sw/jail/certs/solace.pem"); + executeCommand("cp", "/tmp/rootCA.crt", "/usr/sw/jail/certs/rootCA.crt"); + } + executeCommand("cp", TMP_SCRIPT_LOCATION, "/usr/sw/jail/cliscripts/script.cli"); + waitOnCommandResult(SOLACE_ACTIVE_MESSAGE, "grep", "-R", SOLACE_ACTIVE_MESSAGE, "/usr/sw/jail/logs/system.log"); + executeCommand("/usr/sw/loads/currentload/bin/cli", "-A", "-es", "script.cli"); + } + + private Transferable createConfigurationScript() { + StringBuilder scriptBuilder = new StringBuilder(); + updateConfigScript(scriptBuilder, "enable"); + updateConfigScript(scriptBuilder, "configure"); + + // create replay log + updateConfigScript(scriptBuilder, "message-spool message-vpn default"); + updateConfigScript(scriptBuilder, "create replay-log integration-test-replay-log"); + updateConfigScript(scriptBuilder, "max-spool-usage 10"); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "exit"); + updateConfigScript(scriptBuilder, "exit"); + + // create Error queue, DMQ and a queue. Assign DMQ to queue + + // Error Queue + updateConfigScript(scriptBuilder, "message-spool message-vpn default"); + updateConfigScript(scriptBuilder, "create queue " + INTEGRATION_TEST_ERROR_QUEUE_NAME); + updateConfigScript(scriptBuilder, "access-type exclusive"); + updateConfigScript(scriptBuilder, "max-spool-usage 300"); + updateConfigScript(scriptBuilder, "subscription topic " + INTEGRATION_TEST_ERROR_QUEUE_SUBSCRIPTION); + updateConfigScript(scriptBuilder, "permission all consume"); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "exit"); + updateConfigScript(scriptBuilder, "exit"); + + // DMQ + updateConfigScript(scriptBuilder, "message-spool message-vpn default"); + updateConfigScript(scriptBuilder, "create queue " + INTEGRATION_TEST_DMQ_NAME); + updateConfigScript(scriptBuilder, "access-type exclusive"); + updateConfigScript(scriptBuilder, "max-spool-usage 300"); + updateConfigScript(scriptBuilder, "permission all consume"); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "exit"); + updateConfigScript(scriptBuilder, "exit"); + + // Queue with DMQ assigned + updateConfigScript(scriptBuilder, "message-spool message-vpn default"); + updateConfigScript(scriptBuilder, "create queue " + INTEGRATION_TEST_QUEUE_NAME); + updateConfigScript(scriptBuilder, "access-type exclusive"); + updateConfigScript(scriptBuilder, "subscription topic " + INTEGRATION_TEST_QUEUE_SUBSCRIPTION); + updateConfigScript(scriptBuilder, "max-spool-usage 300"); + updateConfigScript(scriptBuilder, "permission all consume"); + updateConfigScript(scriptBuilder, "dead-message-queue " + INTEGRATION_TEST_DMQ_NAME); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "exit"); + updateConfigScript(scriptBuilder, "exit"); + +// // Integration test user acl +// updateConfigScript(scriptBuilder, +// "create acl-profile integration-test-user-acl message-vpn " + vpn + " allow-client-connect"); +// updateConfigScript(scriptBuilder, "exit"); +// +// updateConfigScript(scriptBuilder, "acl-profile integration-test-user-acl message-vpn " + vpn); +// updateConfigScript(scriptBuilder, "publish-topic exceptions smf list quarkus/integration/test"); +// updateConfigScript(scriptBuilder, "exit"); +// +// // Integration test user +// updateConfigScript(scriptBuilder, "create client-username " + "int_user" + " message-vpn " + vpn); +// updateConfigScript(scriptBuilder, "password " + "int_pass"); +// updateConfigScript(scriptBuilder, "acl-profile integration-test-user-acl"); +// updateConfigScript(scriptBuilder, "client-profile default"); +// updateConfigScript(scriptBuilder, "no shutdown"); +// updateConfigScript(scriptBuilder, "exit"); +// +// updateConfigScript(scriptBuilder, "client-profile default"); +// updateConfigScript(scriptBuilder, "allow-guaranteed-message-receive"); +// updateConfigScript(scriptBuilder, "allow-guaranteed-message-send"); +// updateConfigScript(scriptBuilder, "exit"); + + // Create VPN if not default + if (!vpn.equals(DEFAULT_VPN)) { + updateConfigScript(scriptBuilder, "create message-vpn " + vpn); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "exit"); + } + + // Configure username and password + if (username.equals(DEFAULT_USERNAME)) { + throw new RuntimeException("Cannot override password for default client"); + } + updateConfigScript(scriptBuilder, "create client-username " + username + " message-vpn " + vpn); + updateConfigScript(scriptBuilder, "password " + password); + updateConfigScript(scriptBuilder, "acl-profile default"); + updateConfigScript(scriptBuilder, "client-profile default"); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "exit"); + + if (withClientCert) { + // Client certificate authority configuration + updateConfigScript(scriptBuilder, "authentication"); + updateConfigScript(scriptBuilder, "create client-certificate-authority RootCA"); + updateConfigScript(scriptBuilder, "certificate file rootCA.crt"); + updateConfigScript(scriptBuilder, "show client-certificate-authority ca-name *"); + updateConfigScript(scriptBuilder, "end"); + + // Server certificates configuration + updateConfigScript(scriptBuilder, "configure"); + updateConfigScript(scriptBuilder, "ssl"); + updateConfigScript(scriptBuilder, "server-certificate solace.pem"); + updateConfigScript(scriptBuilder, "cipher-suite msg-backbone name AES128-SHA"); + updateConfigScript(scriptBuilder, "exit"); + + updateConfigScript(scriptBuilder, "message-vpn " + vpn); + // Enable client certificate authentication + updateConfigScript(scriptBuilder, "authentication client-certificate"); + updateConfigScript(scriptBuilder, "allow-api-provided-username"); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "end"); + } else { + // Configure VPN Basic authentication + updateConfigScript(scriptBuilder, "message-vpn " + vpn); + updateConfigScript(scriptBuilder, "authentication basic auth-type internal"); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "end"); + } + + if (!publishTopicsConfiguration.isEmpty() || !subscribeTopicsConfiguration.isEmpty()) { + // Enable services + updateConfigScript(scriptBuilder, "configure"); + // Configure default ACL + updateConfigScript(scriptBuilder, "acl-profile default message-vpn " + vpn); + // Configure default action to disallow + if(!subscribeTopicsConfiguration.isEmpty()) { + updateConfigScript(scriptBuilder, "subscribe-topic default-action disallow"); + } + if(!publishTopicsConfiguration.isEmpty()) { + updateConfigScript(scriptBuilder, "publish-topic default-action disallow"); + } + updateConfigScript(scriptBuilder, "exit"); + + updateConfigScript(scriptBuilder, "message-vpn " + vpn); + updateConfigScript(scriptBuilder, "service"); + for (Pair topicConfig : publishTopicsConfiguration) { + Service service = topicConfig.getValue(); + String topicName = topicConfig.getKey(); + updateConfigScript(scriptBuilder, service.getName()); + if (service.isSupportSSL()) { + if (withClientCert) { + updateConfigScript(scriptBuilder, "ssl"); + } else { + updateConfigScript(scriptBuilder, "plain-text"); + } + } + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "end"); + // Add publish/subscribe topic exceptions + updateConfigScript(scriptBuilder, "configure"); + updateConfigScript(scriptBuilder, "acl-profile default message-vpn " + vpn); + updateConfigScript( + scriptBuilder, + String.format("publish-topic exceptions %s list %s", service.getName(), topicName)); + updateConfigScript(scriptBuilder, "end"); + } + + updateConfigScript(scriptBuilder, "configure"); + updateConfigScript(scriptBuilder, "message-vpn " + vpn); + updateConfigScript(scriptBuilder, "service"); + for (Pair topicConfig : subscribeTopicsConfiguration) { + Service service = topicConfig.getValue(); + String topicName = topicConfig.getKey(); + updateConfigScript(scriptBuilder, service.getName()); + if (service.isSupportSSL()) { + if (withClientCert) { + updateConfigScript(scriptBuilder, "ssl"); + } else { + updateConfigScript(scriptBuilder, "plain-text"); + } + } + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "end"); + // Add publish/subscribe topic exceptions + updateConfigScript(scriptBuilder, "configure"); + updateConfigScript(scriptBuilder, "acl-profile default message-vpn " + vpn); + updateConfigScript( + scriptBuilder, + String.format("subscribe-topic exceptions %s list %s", service.getName(), topicName)); + updateConfigScript(scriptBuilder, "end"); + } + } + return Transferable.of(scriptBuilder.toString()); + } + + private void executeCommand(String... command) { + try { + ExecResult execResult = execInContainer(command); + if (execResult.getExitCode() != 0) { + logCommandError(execResult.getStderr(), command); + } + } catch (IOException | InterruptedException e) { + logCommandError(e.getMessage(), command); + } + } + + private void updateConfigScript(StringBuilder scriptBuilder, String command) { + scriptBuilder.append(command).append("\n"); + } + + private void waitOnCommandResult(String waitingFor, String... command) { + Awaitility + .await() + .pollInterval(Duration.ofMillis(500)) + .timeout(Duration.ofSeconds(30)) + .until(() -> { + try { + return execInContainer(command).getStdout().contains(waitingFor); + } catch (IOException | InterruptedException e) { + logCommandError(e.getMessage(), command); + return true; + } + }); + } + + private void logCommandError(String error, String... command) { + logger().error("Could not execute command {}: {}", command, error); + } + + /** + * Sets the client credentials + * + * @param username Client username + * @param password Client password + * @return This container. + */ + public SolaceContainer withCredentials(final String username, final String password) { + this.username = username; + this.password = password; + return this; + } + + /** + * Adds the topic configuration + * + * @param topic Name of the topic + * @param service Service to be supported on provided topic + * @return This container. + */ +// public SolaceContainer withTopic(String topic, Service service) { +// topicsConfiguration.add(Pair.of(topic, service)); +// addExposedPort(service.getPort()); +// return this; +// } + + /** + * Adds the publish topic exceptions configuration + * + * @param topic Name of the topic + * @param service Service to be supported on provided topic + * @return This container. + */ + public SolaceContainer withPublishTopic(String topic, Service service) { + publishTopicsConfiguration.add(Pair.of(topic, service)); + addExposedPort(service.getPort()); + return this; + } + + /** + * Adds the subscribe topic exceptions configuration + * + * @param topic Name of the topic + * @param service Service to be supported on provided topic + * @return This container. + */ + public SolaceContainer withSubscribeTopic(String topic, Service service) { + subscribeTopicsConfiguration.add(Pair.of(topic, service)); + addExposedPort(service.getPort()); + return this; + } + + /** + * Sets the VPN name + * + * @param vpn VPN name + * @return This container. + */ + public SolaceContainer withVpn(String vpn) { + this.vpn = vpn; + return this; + } + + /** + * Sets the solace server ceritificates + * + * @param certFile Server certificate + * @param caFile Certified Authority ceritificate + * @return This container. + */ + public SolaceContainer withClientCert(final MountableFile certFile, final MountableFile caFile) { + this.withClientCert = true; + return withCopyFileToContainer(certFile, "/tmp/solace.pem").withCopyFileToContainer(caFile, "/tmp/rootCA.crt"); + } + + /** + * Configured VPN + * + * @return the configured VPN that should be used for connections + */ + public String getVpn() { + return this.vpn; + } + + /** + * Host address for provided service + * + * @param service - service for which host needs to be retrieved + * @return host address exposed from the container + */ + public String getOrigin(Service service) { + return String.format("%s://%s:%s", service.getProtocol(), getHost(), getMappedPort(service.getPort())); + } + + /** + * Configured username + * + * @return the standard username that should be used for connections + */ + public String getUsername() { + return this.username; + } + + /** + * Configured password + * + * @return the standard password that should be used for connections + */ + public String getPassword() { + return this.password; + } + + public enum Service { + AMQP("amqp", 5672, "amqp", false), + MQTT("mqtt", 1883, "tcp", false), + REST("rest", 9000, "http", false), + SMF("smf", 55555, "tcp", true), + SMF_SSL("smf", 55443, "tcps", true); + + private final String name; + private final Integer port; + private final String protocol; + private final boolean supportSSL; + + Service(String name, Integer port, String protocol, boolean supportSSL) { + this.name = name; + this.port = port; + this.protocol = protocol; + this.supportSSL = supportSSL; + } + + /** + * @return Port assigned for the service + */ + public Integer getPort() { + return this.port; + } + + /** + * @return Protocol of the service + */ + public String getProtocol() { + return this.protocol; + } + + /** + * @return Name of the service + */ + public String getName() { + return this.name; + } + + /** + * @return Is SSL for this service supported ? + */ + public boolean isSupportSSL() { + return this.supportSSL; + } + } +} diff --git a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/WeldTestBase.java b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/WeldTestBase.java new file mode 100644 index 0000000..6642470 --- /dev/null +++ b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/WeldTestBase.java @@ -0,0 +1,141 @@ +package io.quarkiverse.solace.base; + +import jakarta.enterprise.inject.spi.BeanManager; + +import org.eclipse.microprofile.config.ConfigProvider; +import org.eclipse.microprofile.reactive.messaging.spi.ConnectorLiteral; +import org.jboss.weld.environment.se.Weld; +import org.jboss.weld.environment.se.WeldContainer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import io.quarkiverse.solace.SolaceConnector; +import io.quarkiverse.solace.converters.SolaceMessageConverter; +import io.smallrye.config.SmallRyeConfigProviderResolver; +import io.smallrye.config.inject.ConfigExtension; +import io.smallrye.reactive.messaging.providers.MediatorFactory; +import io.smallrye.reactive.messaging.providers.connectors.ExecutionHolder; +import io.smallrye.reactive.messaging.providers.connectors.WorkerPoolRegistry; +import io.smallrye.reactive.messaging.providers.extension.ChannelProducer; +import io.smallrye.reactive.messaging.providers.extension.EmitterFactoryImpl; +import io.smallrye.reactive.messaging.providers.extension.HealthCenter; +import io.smallrye.reactive.messaging.providers.extension.LegacyEmitterFactoryImpl; +import io.smallrye.reactive.messaging.providers.extension.MediatorManager; +import io.smallrye.reactive.messaging.providers.extension.MutinyEmitterFactoryImpl; +import io.smallrye.reactive.messaging.providers.extension.ReactiveMessagingExtension; +import io.smallrye.reactive.messaging.providers.impl.ConfiguredChannelFactory; +import io.smallrye.reactive.messaging.providers.impl.ConnectorFactories; +import io.smallrye.reactive.messaging.providers.impl.InternalChannelRegistry; +import io.smallrye.reactive.messaging.providers.metrics.MetricDecorator; +import io.smallrye.reactive.messaging.providers.metrics.MicrometerDecorator; +import io.smallrye.reactive.messaging.providers.wiring.Wiring; +import io.smallrye.reactive.messaging.test.common.config.MapBasedConfig; + +public class WeldTestBase extends SolaceBaseTest { + + protected Weld weld; + protected WeldContainer container; + + @BeforeEach + public void initWeld() { + weld = new Weld(); + + // SmallRye config + ConfigExtension extension = new ConfigExtension(); + weld.addExtension(extension); + + weld.addBeanClass(MediatorFactory.class); + weld.addBeanClass(MediatorManager.class); + weld.addBeanClass(InternalChannelRegistry.class); + weld.addBeanClass(ConnectorFactories.class); + weld.addBeanClass(ConfiguredChannelFactory.class); + weld.addBeanClass(ChannelProducer.class); + weld.addBeanClass(ExecutionHolder.class); + weld.addBeanClass(WorkerPoolRegistry.class); + weld.addBeanClass(HealthCenter.class); + weld.addBeanClass(Wiring.class); + weld.addExtension(new ReactiveMessagingExtension()); + + weld.addBeanClass(EmitterFactoryImpl.class); + weld.addBeanClass(MutinyEmitterFactoryImpl.class); + weld.addBeanClass(LegacyEmitterFactoryImpl.class); + + weld.addBeanClass(SolaceConnector.class); + weld.addBeanClass(MetricDecorator.class); + weld.addBeanClass(MicrometerDecorator.class); + weld.addBeanClass(SolaceMessageConverter.class); + + weld.addBeanClass(MessagingServiceProvider.class); + weld.disableDiscovery(); + } + + @AfterEach + public void stopContainer() { + if (container != null) { + getBeanManager().createInstance() + .select(SolaceConnector.class, ConnectorLiteral.of("quarkus-solace")).get(); + container.close(); + } + // Release the config objects + SmallRyeConfigProviderResolver.instance().releaseConfig(ConfigProvider.getConfig()); + } + + public BeanManager getBeanManager() { + if (container == null) { + runApplication(new MapBasedConfig()); + } + return container.getBeanManager(); + } + + public void addBeans(Class... clazzes) { + weld.addBeanClasses(clazzes); + } + + public T get(Class clazz) { + return getBeanManager().createInstance().select(clazz).get(); + } + + public T runApplication(MapBasedConfig config, Class clazz) { + weld.addBeanClass(clazz); + runApplication(config); + return get(clazz); + } + + public void runApplication(MapBasedConfig config) { + if (config != null) { + config.write(); + } else { + MapBasedConfig.cleanup(); + } + + container = weld.initialize(); + } + + public static void addConfig(MapBasedConfig config) { + if (config != null) { + config.write(); + } else { + MapBasedConfig.cleanup(); + } + } + + public HealthCenter getHealth() { + if (container == null) { + throw new IllegalStateException("Application not started"); + } + return container.getBeanManager().createInstance().select(HealthCenter.class).get(); + } + + public boolean isStarted() { + return getHealth().getStartup().isOk(); + } + + public boolean isReady() { + return getHealth().getReadiness().isOk(); + } + + public boolean isAlive() { + return getHealth().getLiveness().isOk(); + } + +} diff --git a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/locals/LocalPropagationAckTest.java b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/locals/LocalPropagationAckTest.java new file mode 100644 index 0000000..4cb56f7 --- /dev/null +++ b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/locals/LocalPropagationAckTest.java @@ -0,0 +1,106 @@ +package io.quarkiverse.solace.locals; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Message; +import org.junit.jupiter.api.Test; + +import com.solace.messaging.publisher.PersistentMessagePublisher; +import com.solace.messaging.resources.Topic; + +import io.netty.handler.codec.http.HttpHeaderValues; +import io.quarkiverse.solace.SolaceConnector; +import io.quarkiverse.solace.base.WeldTestBase; +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.Uni; +import io.smallrye.reactive.messaging.providers.locals.LocalContextMetadata; +import io.smallrye.reactive.messaging.test.common.config.MapBasedConfig; + +public class LocalPropagationAckTest extends WeldTestBase { + + private MapBasedConfig dataconfig() { + return new MapBasedConfig() + .with("mp.messaging.incoming.data.connector", SolaceConnector.CONNECTOR_NAME) + .with("mp.messaging.incoming.data.consumer.queue.subscriptions", topic) + .with("mp.messaging.incoming.data.consumer.queue.add-additional-subscriptions", "true") + .with("mp.messaging.incoming.data.consumer.queue.missing-resource-creation-strategy", "create-on-start") + .with("mp.messaging.incoming.data.consumer.queue.name", topic); + } + + private void sendMessages() { + PersistentMessagePublisher publisher = messagingService.createPersistentMessagePublisherBuilder() + .build() + .start(); + Topic tp = Topic.of(topic); + for (int i = 0; i < 5; i++) { + publisher.publish(messagingService.messageBuilder() + .withHTTPContentHeader(HttpHeaderValues.TEXT_PLAIN.toString(), "") + .build(String.valueOf(i + 1)), + tp); + } + } + + @Test + public void testChannelWithAckOnMessageContext() { + IncomingChannelWithAckOnMessageContext bean = runApplication(dataconfig(), + IncomingChannelWithAckOnMessageContext.class); + bean.process(i -> i + 1); + sendMessages(); + + await().until(() -> bean.getResults().size() >= 5); + assertThat(bean.getResults()).containsExactly(2, 3, 4, 5, 6); + } + + @Test + public void testIncomingChannelWithNackOnMessageContextFailStop() { + IncomingChannelWithAckOnMessageContext bean = runApplication(dataconfig(), + IncomingChannelWithAckOnMessageContext.class); + bean.process(i -> { + throw new RuntimeException("boom"); + }); + sendMessages(); + + await().until(() -> bean.getResults().size() >= 5); + assertThat(bean.getResults()).contains(1, 2, 3, 4, 5); + } + + @ApplicationScoped + public static class IncomingChannelWithAckOnMessageContext { + + private final List list = new CopyOnWriteArrayList<>(); + + @Inject + @Channel("data") + Multi> incoming; + + void process(Function mapper) { + incoming.map(m -> m.withPayload(Integer.parseInt(m.getPayload()))) + .onItem() + .transformToUniAndConcatenate(msg -> Uni.createFrom() + .item(() -> msg.withPayload(mapper.apply(msg.getPayload()))) + .chain(m -> Uni.createFrom().completionStage(m.ack()).replaceWith(m)) + .onFailure().recoverWithUni(t -> Uni.createFrom().completionStage(msg.nack(t)) + .onItemOrFailure().transform((unused, throwable) -> msg))) + .subscribe().with(m -> { + System.out.println(m + " " + m.getMetadata(LocalContextMetadata.class)); + m.getMetadata(LocalContextMetadata.class).map(LocalContextMetadata::context).ifPresent(context -> { + list.add(m.getPayload()); + }); + }); + } + + List getResults() { + return list; + } + } + +} diff --git a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/locals/LocalPropagationTest.java b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/locals/LocalPropagationTest.java new file mode 100644 index 0000000..dfea61a --- /dev/null +++ b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/locals/LocalPropagationTest.java @@ -0,0 +1,592 @@ +package io.quarkiverse.solace.locals; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.reactive.messaging.Acknowledgment; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Message; +import org.eclipse.microprofile.reactive.messaging.Outgoing; +import org.junit.jupiter.api.Test; + +import com.solace.messaging.publisher.PersistentMessagePublisher; +import com.solace.messaging.resources.Topic; + +import io.netty.handler.codec.http.HttpHeaderValues; +import io.quarkiverse.solace.SolaceConnector; +import io.quarkiverse.solace.base.WeldTestBase; +import io.smallrye.common.vertx.ContextLocals; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.infrastructure.Infrastructure; +import io.smallrye.reactive.messaging.annotations.Blocking; +import io.smallrye.reactive.messaging.annotations.Broadcast; +import io.smallrye.reactive.messaging.annotations.Merge; +import io.smallrye.reactive.messaging.providers.locals.LocalContextMetadata; +import io.smallrye.reactive.messaging.test.common.config.MapBasedConfig; +import io.vertx.core.impl.ConcurrentHashSet; +import io.vertx.mutiny.core.Vertx; + +public class LocalPropagationTest extends WeldTestBase { + + private MapBasedConfig dataconfig() { + return new MapBasedConfig() + .with("mp.messaging.incoming.data.connector", SolaceConnector.CONNECTOR_NAME) + .with("mp.messaging.incoming.data.consumer.queue.subscriptions", topic) + .with("mp.messaging.incoming.data.consumer.queue.add-additional-subscriptions", "true") + .with("mp.messaging.incoming.data.consumer.queue.missing-resource-creation-strategy", "create-on-start") + .with("mp.messaging.incoming.data.consumer.queue.name", queue); + } + + private void sendMessages() { + PersistentMessagePublisher publisher = messagingService.createPersistentMessagePublisherBuilder() + .build() + .start(); + Topic tp = Topic.of(topic); + for (int i = 0; i < 5; i++) { + publisher.publish(messagingService.messageBuilder() + .withHTTPContentHeader(HttpHeaderValues.TEXT_PLAIN.toString(), "") + .build(String.valueOf(i + 1)), + tp, 1000); + } + } + + @Test + public void testLinearPipeline() { + LinearPipeline bean = runApplication(dataconfig(), LinearPipeline.class); + sendMessages(); + await().until(() -> bean.getResults().size() >= 5); + assertThat(bean.getResults()).containsExactly(2, 3, 4, 5, 6); + } + + @Test + public void testPipelineWithABlockingStage() { + PipelineWithABlockingStage bean = runApplication(dataconfig(), PipelineWithABlockingStage.class); + sendMessages(); + await().until(() -> bean.getResults().size() >= 5); + assertThat(bean.getResults()).containsExactly(2, 3, 4, 5, 6); + + } + + @Test + public void testPipelineWithAnUnorderedBlockingStage() { + PipelineWithAnUnorderedBlockingStage bean = runApplication(dataconfig(), PipelineWithAnUnorderedBlockingStage.class); + sendMessages(); + await().until(() -> bean.getResults().size() >= 5); + assertThat(bean.getResults()).containsExactlyInAnyOrder(2, 3, 4, 5, 6); + } + + @Test + public void testPipelineWithMultipleBlockingStages() { + PipelineWithMultipleBlockingStages bean = runApplication(dataconfig(), PipelineWithMultipleBlockingStages.class); + sendMessages(); + await().until(() -> bean.getResults().size() >= 5); + assertThat(bean.getResults()).containsExactlyInAnyOrder(2, 3, 4, 5, 6); + } + + @Test + public void testPipelineWithBroadcastAndMerge() { + PipelineWithBroadcastAndMerge bean = runApplication(dataconfig(), PipelineWithBroadcastAndMerge.class); + sendMessages(); + await().until(() -> bean.getResults().size() >= 10); + assertThat(bean.getResults()).hasSize(10).contains(2, 3, 4, 5, 6); + } + + @Test + public void testLinearPipelineWithAckOnCustomThread() { + LinearPipelineWithAckOnCustomThread bean = runApplication(dataconfig(), LinearPipelineWithAckOnCustomThread.class); + sendMessages(); + await().until(() -> bean.getResults().size() >= 5); + assertThat(bean.getResults()).containsExactly(2, 3, 4, 5, 6); + + } + + @Test + public void testPipelineWithAnAsyncStage() { + PipelineWithAnAsyncStage bean = runApplication(dataconfig(), PipelineWithAnAsyncStage.class); + sendMessages(); + await().until(() -> bean.getResults().size() >= 5); + assertThat(bean.getResults()).containsExactly(2, 3, 4, 5, 6); + + } + + @ApplicationScoped + public static class LinearPipeline { + + private final List list = new CopyOnWriteArrayList<>(); + private final Set uuids = new ConcurrentHashSet<>(); + + @Incoming("data") + @Outgoing("process") + @Acknowledgment(Acknowledgment.Strategy.MANUAL) + public Message process(Message input) { + String value = UUID.randomUUID().toString(); + + int payload = Integer.parseInt(input.getPayload()); + Vertx.currentContext().putLocal("uuid", value); + Vertx.currentContext().putLocal("input", payload); + + return input.withPayload(payload + 1); + } + + @Incoming("process") + @Outgoing("after-process") + public Integer handle(int payload) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + assertThat(uuids.add(uuid)).isTrue(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(payload); + return payload; + } + + @Incoming("after-process") + @Outgoing("sink") + public Integer afterProcess(int payload) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(payload); + return payload; + } + + @Incoming("sink") + public void sink(int val) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(val); + list.add(val); + } + + public List getResults() { + return list; + } + } + + @ApplicationScoped + public static class LinearPipelineWithAckOnCustomThread { + + private final List list = new CopyOnWriteArrayList<>(); + private final Set uuids = new ConcurrentHashSet<>(); + + private final Executor executor = Executors.newFixedThreadPool(4); + + @Incoming("data") + @Outgoing("process") + @Acknowledgment(Acknowledgment.Strategy.MANUAL) + public Message process(Message input) { + String value = UUID.randomUUID().toString(); + assertThat((String) Vertx.currentContext().getLocal("uuid")).isNull(); + int payload = Integer.parseInt(input.getPayload()); + Vertx.currentContext().putLocal("uuid", value); + Vertx.currentContext().putLocal("input", payload); + + return input.withPayload(payload + 1) + .withAck(() -> { + CompletableFuture cf = new CompletableFuture<>(); + executor.execute(() -> { + cf.complete(null); + }); + return cf; + }); + } + + @Incoming("process") + @Outgoing("after-process") + public Integer handle(int payload) { + try { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + assertThat(uuids.add(uuid)).isTrue(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(payload); + } catch (Exception e) { + e.printStackTrace(); + } + return payload; + } + + @Incoming("after-process") + @Outgoing("sink") + public Integer afterProcess(int payload) { + try { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(payload); + } catch (Exception e) { + e.printStackTrace(); + } + return payload; + } + + @Incoming("sink") + public void sink(int val) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(val); + list.add(val); + } + + public List getResults() { + return list; + } + } + + @ApplicationScoped + public static class PipelineWithABlockingStage { + + private final List list = new CopyOnWriteArrayList<>(); + private final Set uuids = new ConcurrentHashSet<>(); + + @Incoming("data") + @Outgoing("process") + @Acknowledgment(Acknowledgment.Strategy.MANUAL) + public Message process(Message input) { + String value = UUID.randomUUID().toString(); + assertThat((String) Vertx.currentContext().getLocal("uuid")).isNull(); + int payload = Integer.parseInt(input.getPayload()); + Vertx.currentContext().putLocal("uuid", value); + Vertx.currentContext().putLocal("input", payload); + + assertThat(input.getMetadata(LocalContextMetadata.class)).isPresent(); + + return input.withPayload(payload + 1); + } + + @Incoming("process") + @Outgoing("after-process") + @Blocking + public Integer handle(int payload) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + assertThat(uuids.add(uuid)).isTrue(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(payload); + return payload; + } + + @Incoming("after-process") + @Outgoing("sink") + public Integer afterProcess(int payload) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(payload); + return payload; + } + + @Incoming("sink") + public void sink(int val) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(val); + list.add(val); + } + + public List getResults() { + return list; + } + } + + @ApplicationScoped + public static class PipelineWithAnUnorderedBlockingStage { + + private final List list = new CopyOnWriteArrayList<>(); + private final Set uuids = new ConcurrentHashSet<>(); + + @Incoming("data") + @Outgoing("process") + @Acknowledgment(Acknowledgment.Strategy.MANUAL) + public Message process(Message input) { + String value = UUID.randomUUID().toString(); + assertThat((String) Vertx.currentContext().getLocal("uuid")).isNull(); + int payload = Integer.parseInt(input.getPayload()); + Vertx.currentContext().putLocal("uuid", value); + Vertx.currentContext().putLocal("input", payload); + + assertThat(input.getMetadata(LocalContextMetadata.class)).isPresent(); + + return input.withPayload(payload + 1); + } + + private final Random random = new Random(); + + @Incoming("process") + @Outgoing("after-process") + @Blocking(ordered = false) + public Integer handle(int payload) throws InterruptedException { + Thread.sleep(random.nextInt(10)); + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + assertThat(uuids.add(uuid)).isTrue(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(payload); + return payload; + } + + @Incoming("after-process") + @Outgoing("sink") + public Integer afterProcess(int payload) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(payload); + return payload; + } + + @Incoming("sink") + public void sink(int val) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(val); + list.add(val); + } + + public List getResults() { + return list; + } + } + + @ApplicationScoped + public static class PipelineWithMultipleBlockingStages { + + private final List list = new CopyOnWriteArrayList<>(); + private final Set uuids = new ConcurrentHashSet<>(); + + @Incoming("data") + @Outgoing("process") + @Acknowledgment(Acknowledgment.Strategy.MANUAL) + public Message process(Message input) { + String value = UUID.randomUUID().toString(); + assertThat((String) Vertx.currentContext().getLocal("uuid")).isNull(); + int payload = Integer.parseInt(input.getPayload()); + Vertx.currentContext().putLocal("uuid", value); + Vertx.currentContext().putLocal("input", payload); + + assertThat(input.getMetadata(LocalContextMetadata.class)).isPresent(); + + return input.withPayload(payload + 1); + } + + private final Random random = new Random(); + + @Incoming("process") + @Outgoing("second-blocking") + @Blocking(ordered = false) + public Integer handle(int payload) throws InterruptedException { + Thread.sleep(random.nextInt(10)); + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + assertThat(uuids.add(uuid)).isTrue(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(payload); + return payload; + } + + @Incoming("second-blocking") + @Outgoing("after-process") + @Blocking + public Integer handle2(int payload) throws InterruptedException { + Thread.sleep(random.nextInt(10)); + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(payload); + return payload; + } + + @Incoming("after-process") + @Outgoing("sink") + public Integer afterProcess(int payload) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(payload); + return payload; + } + + @Incoming("sink") + public void sink(int val) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(val); + list.add(val); + } + + public List getResults() { + return list; + } + } + + @ApplicationScoped + public static class PipelineWithBroadcastAndMerge { + + private final List list = new CopyOnWriteArrayList<>(); + private final Set branch1 = new ConcurrentHashSet<>(); + private final Set branch2 = new ConcurrentHashSet<>(); + + @Incoming("data") + @Outgoing("process") + @Acknowledgment(Acknowledgment.Strategy.MANUAL) + @Broadcast(2) + public Message process(Message input) { + String value = UUID.randomUUID().toString(); + assertThat((String) Vertx.currentContext().getLocal("uuid")).isNull(); + int payload = Integer.parseInt(input.getPayload()); + Vertx.currentContext().putLocal("uuid", value); + Vertx.currentContext().putLocal("input", payload); + + assertThat(input.getMetadata(LocalContextMetadata.class)).isPresent(); + + return input.withPayload(payload + 1); + } + + private final Random random = new Random(); + + @Incoming("process") + @Outgoing("after-process") + public Integer branch1(int payload) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + assertThat(branch1.add(uuid)).isTrue(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(payload); + return payload; + } + + @Incoming("process") + @Outgoing("after-process") + public Integer branch2(int payload) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + assertThat(branch2.add(uuid)).isTrue(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(payload); + return payload; + } + + @Incoming("after-process") + @Outgoing("sink") + @Merge + public Integer afterProcess(int payload) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(payload); + return payload; + } + + @Incoming("sink") + public void sink(int val) { + String uuid = Vertx.currentContext().getLocal("uuid"); + assertThat(uuid).isNotNull(); + + int p = Vertx.currentContext().getLocal("input"); + assertThat(p + 1).isEqualTo(val); + list.add(val); + } + + public List getResults() { + return list; + } + } + + @ApplicationScoped + public static class PipelineWithAnAsyncStage { + + private final List list = new CopyOnWriteArrayList<>(); + private final Set uuids = new ConcurrentHashSet<>(); + + @Incoming("data") + @Outgoing("process") + @Acknowledgment(Acknowledgment.Strategy.MANUAL) + public Message process(Message input) { + String value = UUID.randomUUID().toString(); + assertThat((String) ContextLocals.get("uuid", null)).isNull(); + int payload = Integer.parseInt(input.getPayload()); + ContextLocals.put("uuid", value); + ContextLocals.put("input", payload); + + assertThat(input.getMetadata(LocalContextMetadata.class)).isPresent(); + + return input.withPayload(payload + 1); + } + + @Incoming("process") + @Outgoing("after-process") + public Uni handle(int payload) { + String uuid = ContextLocals.get("uuid", null); + assertThat(uuid).isNotNull(); + + assertThat(uuids.add(uuid)).isTrue(); + + int p = ContextLocals.get("input", null); + assertThat(p + 1).isEqualTo(payload); + return Uni.createFrom().item(() -> payload).runSubscriptionOn(Infrastructure.getDefaultExecutor()); + } + + @Incoming("after-process") + @Outgoing("sink") + public Integer afterProcess(int payload) { + String uuid = ContextLocals.get("uuid", null); + assertThat(uuid).isNotNull(); + + int p = ContextLocals.get("input", null); + assertThat(p + 1).isEqualTo(payload); + return payload; + } + + @Incoming("sink") + public void sink(int val) { + String uuid = ContextLocals.get("uuid", null); + assertThat(uuid).isNotNull(); + + int p = ContextLocals.get("input", null); + assertThat(p + 1).isEqualTo(val); + list.add(val); + } + + public List getResults() { + return list; + } + } + +} diff --git a/pubsub-plus-connector/src/test/resources/log4j.properties b/pubsub-plus-connector/src/test/resources/log4j.properties new file mode 100644 index 0000000..a2cd02f --- /dev/null +++ b/pubsub-plus-connector/src/test/resources/log4j.properties @@ -0,0 +1,7 @@ +log4j.rootLogger=INFO, stdout +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.Target=System.out +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=%d{HH:mm:ss,SSS} %-5p [%c] - %m%n + +log4j.logger.com.solacesystems=INFO diff --git a/runtime/pom.xml b/runtime/pom.xml new file mode 100644 index 0000000..890b1df --- /dev/null +++ b/runtime/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + io.quarkiverse.solace + quarkus-solace-parent + 999-SNAPSHOT + + quarkus-solace + Quarkus Solace - Runtime + + + io.quarkus + quarkus-arc + + + com.solace + solace-messaging-client + + + + io.quarkus + quarkus-micrometer + true + + + io.quarkus + quarkus-smallrye-health + true + + + + + + io.quarkus + quarkus-extension-maven-plugin + ${quarkus.version} + + + compile + + extension-descriptor + + + ${project.groupId}:${project.artifactId}-deployment:${project.version} + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/runtime/src/main/java/io/quarkiverse/solace/MessagingServiceClientCustomizer.java b/runtime/src/main/java/io/quarkiverse/solace/MessagingServiceClientCustomizer.java new file mode 100644 index 0000000..2caecff --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/solace/MessagingServiceClientCustomizer.java @@ -0,0 +1,26 @@ +package io.quarkiverse.solace; + +import com.solace.messaging.MessagingServiceClientBuilder; + +/** + * Interface implemented by CDI beans that want to customize the Solace client configuration. + *

+ * Only one bean exposing this interface is allowed in the application. + */ +public interface MessagingServiceClientCustomizer { + + /** + * Customizes the Solace client configuration. + *

+ * This method is called during the creation of the Solace client instance. + * The Quarkus configuration had already been processed. + * It gives you the opportunity to extend that configuration or override the processed configuration. + *

+ * Implementation can decide to ignore the passed builder to build their own. + * However, this should be used with caution as it can lead to unexpected results. + * + * @param builder the builder to customize the Solace client instance + * @return the builder to use to create the Solace client instance + */ + MessagingServiceClientBuilder customize(MessagingServiceClientBuilder builder); +} diff --git a/runtime/src/main/java/io/quarkiverse/solace/runtime/SolaceConfig.java b/runtime/src/main/java/io/quarkiverse/solace/runtime/SolaceConfig.java new file mode 100644 index 0000000..4ab42e4 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/solace/runtime/SolaceConfig.java @@ -0,0 +1,30 @@ +package io.quarkiverse.solace.runtime; + +import java.util.Map; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithParentName; + +@ConfigMapping(prefix = "quarkus.solace") +@ConfigRoot(phase = ConfigPhase.RUN_TIME) +public interface SolaceConfig { + + /** + * The Solace host (hostname:port) + */ + String host(); + + /** + * The Solace VPN + */ + String vpn(); + + /** + * Any extra parameters to pass to the Solace client + */ + @WithParentName + Map extra(); + +} diff --git a/runtime/src/main/java/io/quarkiverse/solace/runtime/SolaceRecorder.java b/runtime/src/main/java/io/quarkiverse/solace/runtime/SolaceRecorder.java new file mode 100644 index 0000000..b3b20ae --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/solace/runtime/SolaceRecorder.java @@ -0,0 +1,68 @@ +package io.quarkiverse.solace.runtime; + +import java.util.Map; +import java.util.Properties; +import java.util.function.Function; + +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.util.TypeLiteral; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.MessagingServiceClientBuilder; +import com.solace.messaging.config.SolaceProperties; +import com.solace.messaging.config.profile.ConfigurationProfile; + +import io.quarkiverse.solace.MessagingServiceClientCustomizer; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.runtime.ShutdownContext; +import io.quarkus.runtime.annotations.Recorder; + +@Recorder +public class SolaceRecorder { + + private static final TypeLiteral> CUSTOMIZER = new TypeLiteral<>() { + }; + + public Function, MessagingService> init(SolaceConfig config, + ShutdownContext shutdown) { + return new Function<>() { + @Override + public MessagingService apply(SyntheticCreationalContext context) { + Properties properties = new Properties(); + properties.put(SolaceProperties.TransportLayerProperties.HOST, config.host()); + properties.put(SolaceProperties.ServiceProperties.VPN_NAME, config.vpn()); + for (Map.Entry entry : config.extra().entrySet()) { + properties.put(entry.getKey(), entry.getValue()); + if (!entry.getKey().startsWith("solace.messaging.")) { + properties.put("solace.messaging." + entry.getKey(), entry.getValue()); + } + } + + MessagingServiceClientBuilder builder = MessagingService.builder(ConfigurationProfile.V1) + .fromProperties(properties); + + Instance reference = context.getInjectedReference(CUSTOMIZER); + + MessagingService service; + if (reference.isUnsatisfied()) { + service = builder.build(); + } else { + if (!reference.isResolvable()) { + throw new IllegalStateException("Multiple MessagingServiceClientCustomizer instances found"); + } else { + service = reference.get().customize(builder).build(); + } + } + + var tmp = service; + shutdown.addShutdownTask(() -> { + if (tmp.isConnected()) { + tmp.disconnect(); + } + }); + return service.connect(); + } + }; + } + +} diff --git a/runtime/src/main/java/io/quarkiverse/solace/runtime/observability/SolaceHealthCheck.java b/runtime/src/main/java/io/quarkiverse/solace/runtime/observability/SolaceHealthCheck.java new file mode 100644 index 0000000..ce475db --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/solace/runtime/observability/SolaceHealthCheck.java @@ -0,0 +1,26 @@ +package io.quarkiverse.solace.runtime.observability; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.Liveness; + +import com.solace.messaging.MessagingService; + +@Liveness +@ApplicationScoped +public class SolaceHealthCheck implements HealthCheck { + + @Inject + MessagingService solace; + + @Override + public HealthCheckResponse call() { + if (!solace.isConnected()) { + return HealthCheckResponse.down("solace"); + } + return HealthCheckResponse.up("solace"); + } +} diff --git a/runtime/src/main/java/io/quarkiverse/solace/runtime/observability/SolaceMetricBinder.java b/runtime/src/main/java/io/quarkiverse/solace/runtime/observability/SolaceMetricBinder.java new file mode 100644 index 0000000..3f8e0f9 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/solace/runtime/observability/SolaceMetricBinder.java @@ -0,0 +1,25 @@ +package io.quarkiverse.solace.runtime.observability; + +import jakarta.enterprise.inject.spi.CDI; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.util.Manageable; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.quarkus.runtime.annotations.Recorder; + +@Recorder +public class SolaceMetricBinder { + + public void initMetrics() { + var solace = CDI.current().select(MessagingService.class).get(); + var registry = CDI.current().select(MeterRegistry.class).get(); + Manageable.ApiMetrics metrics = solace.metrics(); + for (Manageable.ApiMetrics.Metric metric : Manageable.ApiMetrics.Metric.values()) { + Gauge.builder("solace." + metric.name().toLowerCase().replace("_", "."), () -> metrics.getValue(metric)) + .register(registry); + } + } + +} diff --git a/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000..b72b827 --- /dev/null +++ b/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,9 @@ +name: Solace +#description: Do something useful. +metadata: +# keywords: +# - solace +# guide: https://quarkiverse.github.io/quarkiverse-docs/solace/dev/ # To create and publish this guide, see https://github.com/quarkiverse/quarkiverse/wiki#documenting-your-extension +# categories: +# - "miscellaneous" +# status: "preview" diff --git a/samples/hello-connector-solace/pom.xml b/samples/hello-connector-solace/pom.xml new file mode 100644 index 0000000..b6a2708 --- /dev/null +++ b/samples/hello-connector-solace/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + + io.quarkiverse.solace + quarkus-solace-parent + 999-SNAPSHOT + ../../pom.xml + + quarkus-solace-sample-connector-hello + Quarkus Solace - Sample - Connector Hello + + + io.quarkus + quarkus-resteasy-reactive-jackson + + + io.quarkiverse.solace + quarkus-solace + ${project.version} + + + io.quarkus + quarkus-micrometer-registry-prometheus + + + io.quarkus + quarkus-smallrye-health + + + io.quarkiverse.solace + quarkus-solace-messaging-connector + ${project.version} + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + + build + + + + + + maven-compiler-plugin + + + maven-surefire-plugin + 3.2.1 + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + + + native + + + native + + + + + + maven-failsafe-plugin + 3.2.1 + + + + integration-test + verify + + + + ${project.build.directory}/${project.build.finalName}-runner + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + + + native + + + + diff --git a/samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/HelloConsumer.java b/samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/HelloConsumer.java new file mode 100644 index 0000000..4044804 --- /dev/null +++ b/samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/HelloConsumer.java @@ -0,0 +1,70 @@ +package io.quarkiverse.solace.samples; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.reactive.messaging.*; + +import io.quarkiverse.solace.incoming.SolaceInboundMessage; +import io.quarkiverse.solace.outgoing.SolaceOutboundMetadata; +import io.quarkus.logging.Log; + +@ApplicationScoped +public class HelloConsumer { + /** + * Publish a simple string from using TryMe in Solace broker and you should see the message published to topic + * + * @param p + */ + @Incoming("hello-in") + @Outgoing("hello-out") + @Acknowledgment(Acknowledgment.Strategy.MANUAL) + Message consumeAndPublish(SolaceInboundMessage p) { + Log.infof("Received message: %s", new String(p.getMessage().getPayloadAsBytes(), StandardCharsets.UTF_8)); + SolaceOutboundMetadata outboundMetadata = SolaceOutboundMetadata.builder() + .createPubSubOutboundMetadata(); + Message outboundMessage = Message.of(p.getPayload(), Metadata.of(outboundMetadata), () -> { + CompletableFuture completableFuture = new CompletableFuture(); + p.ack(); + completableFuture.complete(null); + return completableFuture; + }, (throwable) -> { + CompletableFuture completableFuture = new CompletableFuture(); + p.nack(throwable, p.getMetadata()); + completableFuture.complete(null); + return completableFuture; + }); + return outboundMessage; + } + + /** + * Publish a simple string from using TryMe in Solace broker and you should see the message published to dynamic destination + * topic + * + * @param p + */ + @Incoming("dynamic-destination-in") + @Outgoing("dynamic-destination-out") + @Acknowledgment(Acknowledgment.Strategy.MANUAL) + Message consumeAndPublishToDynamicTopic(SolaceInboundMessage p) { + Log.infof("Received message: %s", new String(p.getMessage().getPayloadAsBytes(), StandardCharsets.UTF_8)); + SolaceOutboundMetadata outboundMetadata = SolaceOutboundMetadata.builder() + .setApplicationMessageId("test").setDynamicDestination("hello/foobar/" + p.getMessage().getSenderId()) // make sure senderId is available on incoming message + .createPubSubOutboundMetadata(); + Message outboundMessage = Message.of(p.getPayload(), Metadata.of(outboundMetadata), () -> { + CompletableFuture completableFuture = new CompletableFuture(); + p.ack(); + completableFuture.complete(null); + return completableFuture; + }, (throwable) -> { + CompletableFuture completableFuture = new CompletableFuture(); + p.nack(throwable, p.getMetadata()); + completableFuture.complete(null); + return completableFuture; + }); + return outboundMessage; + } + +} diff --git a/samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/Person.java b/samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/Person.java new file mode 100644 index 0000000..2c7838a --- /dev/null +++ b/samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/Person.java @@ -0,0 +1,15 @@ +package io.quarkiverse.solace.samples; + +public class Person { + + public String name; + public int age; + + public Person() { + } + + public Person(String name, int age) { + this.name = name; + this.age = age; + } +} diff --git a/samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/PublisherResource.java b/samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/PublisherResource.java new file mode 100644 index 0000000..687f0f2 --- /dev/null +++ b/samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/PublisherResource.java @@ -0,0 +1,50 @@ +package io.quarkiverse.solace.samples; + +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Message; +import org.eclipse.microprofile.reactive.messaging.Metadata; + +import io.quarkiverse.solace.outgoing.SolaceOutboundMetadata; +import io.smallrye.mutiny.Uni; +import io.smallrye.reactive.messaging.MutinyEmitter; + +@Path("/hello") +public class PublisherResource { + + @Channel("hello") + MutinyEmitter foobar; + + /** + * Publishes to static topic configured in application.properties + * + * @param person + * @return + */ + @POST + @Path("/publish") + public Uni publish(Person person) { + return foobar.send(person); + } + + /** + * Publishes to dynamic topic test/topic/ publishToDynamicTopic(Person person) { + + SolaceOutboundMetadata outboundMetadata = SolaceOutboundMetadata.builder() + .setApplicationMessageId("test").setDynamicDestination("test/topic/" + person.name) + .createPubSubOutboundMetadata(); + Message personMessage = Message.of(person, Metadata.of(outboundMetadata)); + return foobar.sendMessage(personMessage); + } + +} diff --git a/samples/hello-connector-solace/src/main/resources/application.properties b/samples/hello-connector-solace/src/main/resources/application.properties new file mode 100644 index 0000000..7f1fcf2 --- /dev/null +++ b/samples/hello-connector-solace/src/main/resources/application.properties @@ -0,0 +1,29 @@ +quarkus.solace.host= +quarkus.solace.vpn= +quarkus.solace.authentication.basic.username= +quarkus.solace.authentication.basic.password= + +mp.messaging.outgoing.hello-out.connector=quarkus-solace +mp.messaging.outgoing.hello-out.producer.topic= +#mp.messaging.outgoing.hello-out.producer.back-pressure.strategy=wait +#mp.messaging.outgoing.hello-out.producer.back-pressure.buffer-capacity=1 +#mp.messaging.outgoing.hello-out.producer.waitForPublishReceipt=false + +mp.messaging.incoming.hello-in.connector=quarkus-solace +mp.messaging.incoming.hello-in.consumer.queue.enable-nacks=true +mp.messaging.incoming.hello-in.consumer.queue.name= +mp.messaging.incoming.hello-in.consumer.queue.type=durable-exclusive +mp.messaging.incoming.hello-in.consumer.queue.discard-messages-on-failure=false +mp.messaging.incoming.hello-in.consumer.queue.publish-to-error-topic-on-failure=true +mp.messaging.incoming.hello-in.consumer.queue.error.topic=solace/quarkus/error + +mp.messaging.incoming.dynamic-destination-in.connector=quarkus-solace +mp.messaging.incoming.dynamic-destination-in.consumer.queue.enable-nacks=true +mp.messaging.incoming.dynamic-destination-in.consumer.queue.name= +mp.messaging.incoming.dynamic-destination-in.consumer.queue.type=durable-exclusive +mp.messaging.incoming.dynamic-destination-in.consumer.queue.discard-messages-on-failure=false +mp.messaging.incoming.dynamic-destination-in.consumer.queue.publish-to-error-topic-on-failure=true +mp.messaging.incoming.dynamic-destination-in.consumer.queue.error.topic=solace/quarkus/error + +mp.messaging.outgoing.dynamic-destination-out.connector=quarkus-solace +mp.messaging.outgoing.dynamic-destination-out.producer.topic= diff --git a/samples/hello-solace/docker-compose.yaml b/samples/hello-solace/docker-compose.yaml new file mode 100644 index 0000000..ce93c61 --- /dev/null +++ b/samples/hello-solace/docker-compose.yaml @@ -0,0 +1,62 @@ +# docker-compose -f PubSubStandard_singleNode.yml up +version: '3.3' + +services: + primary: + container_name: pubSubStandardSingleNode + image: solace/solace-pubsub-standard:latest + volumes: + - "storage-group:/var/lib/solace" + shm_size: 1g + ulimits: + core: -1 + nofile: + soft: 2448 + hard: 6592 + deploy: + restart_policy: + condition: on-failure + max_attempts: 1 + ports: + #Port Mappings: With the exception of SMF, ports are mapped straight + #through from host to container. This may result in port collisions on + #commonly used ports that will cause failure of the container to start. + #Web transport + - '8008:8008' + #Web transport over TLS + - '1443:1443' + #SEMP over TLS + - '1943:1943' + #MQTT Default VPN + - '1883:1883' + #AMQP Default VPN over TLS + - '5671:5671' + #AMQP Default VPN + - '5672:5672' + #MQTT Default VPN over WebSockets + - '8000:8000' + #MQTT Default VPN over WebSockets / TLS + - '8443:8443' + #MQTT Default VPN over TLS + - '8883:8883' + #SEMP / PubSub+ Manager + - '8090:8080' + #REST Default VPN + - '9000:9000' + #REST Default VPN over TLS + - '9443:9443' + #SMF + - '55554:55555' + #SMF Compressed + - '55003:55003' + #SMF over TLS + - '55443:55443' + #SSH connection to CLI + - '2222:2222' + environment: + - username_admin_globalaccesslevel=admin + - username_admin_password=admin + - system_scaling_maxconnectioncount=100 + +volumes: + storage-group: \ No newline at end of file diff --git a/samples/hello-solace/pom.xml b/samples/hello-solace/pom.xml new file mode 100644 index 0000000..d7df196 --- /dev/null +++ b/samples/hello-solace/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + + io.quarkiverse.solace + quarkus-solace-parent + 999-SNAPSHOT + ../../pom.xml + + quarkus-solace-sample-hello + Quarkus Solace - Sample - Hello + + + io.quarkus + quarkus-resteasy-reactive-jackson + + + io.quarkiverse.solace + quarkus-solace + ${project.version} + + + io.quarkus + quarkus-micrometer-registry-prometheus + + + io.quarkus + quarkus-smallrye-health + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + + build + + + + + + maven-compiler-plugin + + + maven-surefire-plugin + 3.2.1 + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + + + native + + + native + + + + + + maven-failsafe-plugin + 3.2.1 + + + + integration-test + verify + + + + ${project.build.directory}/${project.build.finalName}-runner + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + + + native + + + + diff --git a/samples/hello-solace/src/main/java/io/quarkiverse/solace/samples/HelloConsumer.java b/samples/hello-solace/src/main/java/io/quarkiverse/solace/samples/HelloConsumer.java new file mode 100644 index 0000000..4d5010f --- /dev/null +++ b/samples/hello-solace/src/main/java/io/quarkiverse/solace/samples/HelloConsumer.java @@ -0,0 +1,34 @@ +package io.quarkiverse.solace.samples; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.receiver.DirectMessageReceiver; +import com.solace.messaging.resources.TopicSubscription; + +import io.quarkus.logging.Log; +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupEvent; + +@ApplicationScoped +public class HelloConsumer { + + @Inject + MessagingService solace; + + private DirectMessageReceiver receiver; + + public void init(@Observes StartupEvent ev) { + receiver = solace.createDirectMessageReceiverBuilder() + .withSubscriptions(TopicSubscription.of("hello/foobar")).build().start(); + receiver.receiveAsync(m -> { + Log.infof("Received message: %s", m.getPayloadAsString()); + }); + } + + public void stop(@Observes ShutdownEvent ev) { + receiver.terminate(1); + } +} diff --git a/samples/hello-solace/src/main/java/io/quarkiverse/solace/samples/PublisherResource.java b/samples/hello-solace/src/main/java/io/quarkiverse/solace/samples/PublisherResource.java new file mode 100644 index 0000000..554c453 --- /dev/null +++ b/samples/hello-solace/src/main/java/io/quarkiverse/solace/samples/PublisherResource.java @@ -0,0 +1,33 @@ +package io.quarkiverse.solace.samples; + +import jakarta.enterprise.event.Observes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; + +import com.solace.messaging.MessagingService; +import com.solace.messaging.publisher.PersistentMessagePublisher; +import com.solace.messaging.resources.Topic; + +import io.quarkus.runtime.ShutdownEvent; + +@Path("/hello") +public class PublisherResource { + + private final PersistentMessagePublisher publisher; + + public PublisherResource(MessagingService solace) { + publisher = solace.createPersistentMessagePublisherBuilder() + .onBackPressureWait(1) + .build().start(); + } + + @POST + public void publish(String message) { + String topicString = "hello/foobar"; + publisher.publish(message, Topic.of(topicString)); + } + + public void stop(@Observes ShutdownEvent ev) { + publisher.terminate(1); + } +} diff --git a/samples/hello-solace/src/main/resources/application.properties b/samples/hello-solace/src/main/resources/application.properties new file mode 100644 index 0000000..3470917 --- /dev/null +++ b/samples/hello-solace/src/main/resources/application.properties @@ -0,0 +1,4 @@ +#quarkus.solace.host=localhost:55554 +#quarkus.solace.vpn=default +#quarkus.solace.authentication.basic.username=admin +#quarkus.solace.authentication.basic.password=admin \ No newline at end of file From fbdc1f899ddb323303f6d8a8f42b5a79816aea5b Mon Sep 17 00:00:00 2001 From: SravanThotakura05 <83568543+SravanThotakura05@users.noreply.github.com> Date: Mon, 18 Dec 2023 13:03:49 +0530 Subject: [PATCH 2/3] Code refactoring --- .github/CODEOWNERS | 14 ++ .github/dependabot.yml | 11 ++ .github/project.yml | 4 + .github/workflows/build.yml | 59 ++++++++ .github/workflows/pre-release.yml | 33 +++++ .github/workflows/quarkus-snapshot.yaml | 60 ++++++++ .github/workflows/release.yml | 74 ++++++++++ .../SolaceErrorTopicPublisherHandler.java | 5 +- .../solace/incoming/SolaceInboundMessage.java | 4 +- .../solace/outgoing/SenderProcessor.java | 1 - .../outgoing/SolaceOutgoingChannel.java | 2 +- .../solace/SolaceConsumerTest.java | 6 +- .../solace/SolacePublisherTest.java | 128 +++++++++--------- .../solace/base/SolaceBaseTest.java | 2 +- .../solace/base/SolaceContainer.java | 36 +---- .../solace/samples/HelloConsumer.java | 40 +++--- 16 files changed, 349 insertions(+), 130 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/project.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/pre-release.yml create mode 100644 .github/workflows/quarkus-snapshot.yaml create mode 100644 .github/workflows/release.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..46b24d7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,14 @@ +# Lines starting with '#' are comments. +# Each line is a file pattern followed by one or more owners. + +# More details are here: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners + +# The '*' pattern is global owners. + +# Order is important. The last matching pattern has the most precedence. +# The folders are ordered as follows: + +# In each subsection folders are ordered first by depth, then alphabetically. +# This should make it easy to add new rules without breaking existing ones. + +* @quarkiverse/quarkiverse-solace diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5b06320 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "maven" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "daily" diff --git a/.github/project.yml b/.github/project.yml new file mode 100644 index 0000000..494c229 --- /dev/null +++ b/.github/project.yml @@ -0,0 +1,4 @@ +release: + current-version: "0" + next-version: "999-SNAPSHOT" + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..16f6ff8 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,59 @@ +name: Build + +on: + push: + branches: + - "main" + paths-ignore: + - '.gitignore' + - 'CODEOWNERS' + - 'LICENSE' + - '*.md' + - '*.adoc' + - '*.txt' + - '.all-contributorsrc' + pull_request: + paths-ignore: + - '.gitignore' + - 'CODEOWNERS' + - 'LICENSE' + - '*.md' + - '*.adoc' + - '*.txt' + - '.all-contributorsrc' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + build: + name: Build on ${{ matrix.os }} - ${{ matrix.java }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + java: [11, 17, 21] + runs-on: ${{ matrix.os }} + steps: + - name: Prepare git + run: git config --global core.autocrlf false + if: startsWith(matrix.os, 'windows') + + - uses: actions/checkout@v3 + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: ${{ matrix.java }} + cache: 'maven' + + - name: Build with Maven + run: mvn -B clean install -Dno-format + + - name: Build with Maven (Native) + run: mvn -B install -Dnative -Dquarkus.native.container-build -Dnative.surefire.skip \ No newline at end of file diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml new file mode 100644 index 0000000..0a9e64e --- /dev/null +++ b/.github/workflows/pre-release.yml @@ -0,0 +1,33 @@ +name: Quarkiverse Pre Release + +on: + pull_request: + paths: + - '.github/project.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + release: + runs-on: ubuntu-latest + name: pre release + + steps: + - uses: radcortez/project-metadata-action@master + name: retrieve project metadata + id: metadata + with: + github-token: ${{secrets.GITHUB_TOKEN}} + metadata-file-path: '.github/project.yml' + + - name: Validate version + if: contains(steps.metadata.outputs.current-version, 'SNAPSHOT') + run: | + echo '::error::Cannot release a SNAPSHOT version.' + exit 1 \ No newline at end of file diff --git a/.github/workflows/quarkus-snapshot.yaml b/.github/workflows/quarkus-snapshot.yaml new file mode 100644 index 0000000..ad9077d --- /dev/null +++ b/.github/workflows/quarkus-snapshot.yaml @@ -0,0 +1,60 @@ +name: "Quarkus ecosystem CI" +on: + workflow_dispatch: + watch: + types: [started] + + # For this CI to work, ECOSYSTEM_CI_TOKEN needs to contain a GitHub with rights to close the Quarkus issue that the user/bot has opened, + # while 'ECOSYSTEM_CI_REPO_PATH' needs to be set to the corresponding path in the 'quarkusio/quarkus-ecosystem-ci' repository + +env: + ECOSYSTEM_CI_REPO: quarkusio/quarkus-ecosystem-ci + ECOSYSTEM_CI_REPO_FILE: context.yaml + JAVA_VERSION: 11 + + ######################### + # Repo specific setting # + ######################### + + ECOSYSTEM_CI_REPO_PATH: quarkiverse-solace + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + build: + name: "Build against latest Quarkus snapshot" + runs-on: ubuntu-latest + # Allow to manually launch the ecosystem CI in addition to the bots + if: github.actor == 'quarkusbot' || github.actor == 'quarkiversebot' || github.actor == '' + + steps: + - name: Install yq + uses: dcarbone/install-yq-action@v1.0.1 + + - name: Set up Java + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - name: Checkout repo + uses: actions/checkout@v3 + with: + path: current-repo + + - name: Checkout Ecosystem + uses: actions/checkout@v3 + with: + repository: ${{ env.ECOSYSTEM_CI_REPO }} + path: ecosystem-ci + + - name: Setup and Run Tests + run: ./ecosystem-ci/setup-and-test + env: + ECOSYSTEM_CI_TOKEN: ${{ secrets.ECOSYSTEM_CI_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0a3894f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Quarkiverse Release + +on: + pull_request: + types: [closed] + paths: + - '.github/project.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + release: + runs-on: ubuntu-latest + name: release + if: ${{github.event.pull_request.merged == true}} + + steps: + - uses: radcortez/project-metadata-action@main + name: Retrieve project metadata + id: metadata + with: + github-token: ${{secrets.GITHUB_TOKEN}} + metadata-file-path: '.github/project.yml' + + - uses: actions/checkout@v3 + + - name: Import GPG key + id: import_gpg + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 11 + cache: 'maven' + server-id: ossrh + server-username: MAVEN_USERNAME + server-password: MAVEN_PASSWORD + + - name: Configure Git author + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + - name: Update latest release version in docs + run: | + mvn -B -ntp -pl docs -am generate-resources -Denforcer.skip -Dformatter.skip -Dimpsort.skip + if ! git diff --quiet docs/modules/ROOT/pages/includes/attributes.adoc; then + git add docs/modules/ROOT/pages/includes/attributes.adoc + git commit -m "Update the latest release version ${{steps.metadata.outputs.current-version}} in documentation" + fi + + - name: Maven release ${{steps.metadata.outputs.current-version}} + run: | + mvn -B release:prepare -Prelease -DreleaseVersion=${{steps.metadata.outputs.current-version}} -DdevelopmentVersion=${{steps.metadata.outputs.next-version}} + mvn -B release:perform -Darguments=-DperformRelease -DperformRelease -Prelease + env: + MAVEN_USERNAME: ${{ secrets.OSSRH_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.OSSRH_TOKEN }} + + - name: Push changes to ${{github.base_ref}} branch + run: | + git push + git push origin ${{steps.metadata.outputs.current-version}} diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceErrorTopicPublisherHandler.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceErrorTopicPublisherHandler.java index d920196..6e160d8 100644 --- a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceErrorTopicPublisherHandler.java +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceErrorTopicPublisherHandler.java @@ -1,8 +1,5 @@ package io.quarkiverse.solace.incoming; -import java.time.Duration; -import java.util.concurrent.CompletableFuture; - import com.solace.messaging.MessagingService; import com.solace.messaging.PubSubPlusClientException; import com.solace.messaging.publisher.OutboundMessage; @@ -38,7 +35,7 @@ public Uni handle(SolaceInboundMessage message, ic); publisher.setMessagePublishReceiptListener(this); // } - return Uni.createFrom().emitter(e -> { + return Uni.createFrom(). emitter(e -> { try { // always wait for error message publish receipt to ensure it is successfully spooled on broker. publisher.publish(outboundMessage, Topic.of(errorTopic), e); diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceInboundMessage.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceInboundMessage.java index 6b44a78..712a8ea 100644 --- a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceInboundMessage.java +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/incoming/SolaceInboundMessage.java @@ -5,7 +5,6 @@ import java.time.Duration; import java.util.concurrent.CompletionStage; -import io.smallrye.mutiny.unchecked.Unchecked; import org.eclipse.microprofile.reactive.messaging.Metadata; import com.solace.messaging.config.MessageAcknowledgementConfiguration; @@ -100,7 +99,8 @@ public CompletionStage ack() { public CompletionStage nack(Throwable reason, Metadata nackMetadata) { if (solaceErrorTopicPublisherHandler != null) { PublishReceipt publishReceipt = solaceErrorTopicPublisherHandler.handle(this, ic) - .onFailure().retry().withBackOff(Duration.ofSeconds(1)).atMost(ic.getConsumerQueueErrorMessageMaxDeliveryAttempts()) + .onFailure().retry().withBackOff(Duration.ofSeconds(1)) + .atMost(ic.getConsumerQueueErrorMessageMaxDeliveryAttempts()) .onFailure().transform((throwable -> { SolaceLogging.log.unsuccessfulToTopic(ic.getConsumerQueueErrorTopic().get(), ic.getChannel()); throw new RuntimeException(throwable); diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SenderProcessor.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SenderProcessor.java index 541cbf3..8aebbef 100644 --- a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SenderProcessor.java +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SenderProcessor.java @@ -7,7 +7,6 @@ import java.util.concurrent.Flow.Subscription; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; -import java.util.function.Supplier; import org.eclipse.microprofile.reactive.messaging.Message; diff --git a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutgoingChannel.java b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutgoingChannel.java index 8ec1ee9..008e6df 100644 --- a/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutgoingChannel.java +++ b/pubsub-plus-connector/src/main/java/io/quarkiverse/solace/outgoing/SolaceOutgoingChannel.java @@ -150,7 +150,7 @@ private Uni publishMessage(PersistentMessagePublisher publisher, return Uni.createFrom(). emitter(e -> { boolean exitExceptionally = false; try { - if(isPublisherReady) { + if (isPublisherReady) { if (waitForPublishReceipt) { publisher.publish(outboundMessage, topic.get(), e); } else { diff --git a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolaceConsumerTest.java b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolaceConsumerTest.java index 26402d6..4bf5951 100644 --- a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolaceConsumerTest.java +++ b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolaceConsumerTest.java @@ -7,11 +7,7 @@ import java.util.List; import java.util.Properties; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.ExecutionException; -import com.solace.messaging.MessagingService; -import com.solace.messaging.config.profile.ConfigurationProfile; -import io.quarkiverse.solace.base.MessagingServiceProvider; import jakarta.enterprise.context.ApplicationScoped; import org.eclipse.microprofile.reactive.messaging.Incoming; @@ -213,7 +209,7 @@ void consumerPublishToErrorTopicPermissionException() { .with("mp.messaging.incoming.in.consumer.queue.type", "durable-exclusive") .with("mp.messaging.incoming.in.consumer.queue.publish-to-error-topic-on-failure", true) .with("mp.messaging.incoming.in.consumer.queue.error.topic", - "publish/deny") + "publish/deny") .with("mp.messaging.incoming.error-in.connector", "quarkus-solace") .with("mp.messaging.incoming.error-in.consumer.queue.name", SolaceContainer.INTEGRATION_TEST_ERROR_QUEUE_NAME) .with("mp.messaging.incoming.error-in.consumer.queue.type", "durable-exclusive"); diff --git a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolacePublisherTest.java b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolacePublisherTest.java index 5e84605..669d51a 100644 --- a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolacePublisherTest.java +++ b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/SolacePublisherTest.java @@ -3,20 +3,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; -import java.time.Duration; -import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.TimeUnit; -import io.quarkiverse.solace.outgoing.SolaceOutboundMetadata; -import io.quarkiverse.solace.outgoing.SolaceOutgoingChannel; -import io.smallrye.mutiny.Uni; -import io.smallrye.reactive.messaging.MutinyEmitter; import jakarta.enterprise.context.ApplicationScoped; -import org.eclipse.microprofile.reactive.messaging.Channel; import org.eclipse.microprofile.reactive.messaging.Message; import org.eclipse.microprofile.reactive.messaging.Metadata; import org.eclipse.microprofile.reactive.messaging.Outgoing; @@ -27,6 +19,7 @@ import com.solace.messaging.resources.TopicSubscription; import io.quarkiverse.solace.base.WeldTestBase; +import io.quarkiverse.solace.outgoing.SolaceOutboundMetadata; import io.smallrye.mutiny.Multi; import io.smallrye.reactive.messaging.test.common.config.MapBasedConfig; @@ -77,7 +70,8 @@ void publisherWithDynamicDestination() { // Assert on published messages await().untilAsserted(() -> assertThat(app.getAcked()).contains("1", "2", "3", "4", "5")); // Assert on received messages - await().untilAsserted(() -> assertThat(expected).contains("quarkus/integration/test/dynamic/topic/1", "quarkus/integration/test/dynamic/topic/2", "quarkus/integration/test/dynamic/topic/3", + await().untilAsserted(() -> assertThat(expected).contains("quarkus/integration/test/dynamic/topic/1", + "quarkus/integration/test/dynamic/topic/2", "quarkus/integration/test/dynamic/topic/3", "quarkus/integration/test/dynamic/topic/4", "quarkus/integration/test/dynamic/topic/5")); } @@ -105,29 +99,29 @@ void publisherWithBackPressureReject() { await().untilAsserted(() -> assertThat(app.getAcked().size()).isLessThan(5)); } -// @Test -// void publisherWithBackPressureRejectWaitForPublisherReadiness() { -// MapBasedConfig config = new MapBasedConfig() -// .with("mp.messaging.outgoing.out.connector", "quarkus-solace") -// .with("mp.messaging.outgoing.out.producer.topic", topic) -// .with("mp.messaging.outgoing.out.producer.back-pressure.buffer-capacity", 1); -// -// List expected = new CopyOnWriteArrayList<>(); -// -// // Start listening first -// PersistentMessageReceiver receiver = messagingService.createPersistentMessageReceiverBuilder() -// .withSubscriptions(TopicSubscription.of("topic")) -// .build(Queue.nonDurableExclusiveQueue()); -// receiver.receiveAsync(inboundMessage -> { -// expected.add(inboundMessage.getPayloadAsString()); -// }); -// receiver.start(); -// -// // Run app that publish messages -// MyBackPressureRejectApp app = runApplication(config, MyBackPressureRejectApp.class); -// // Assert on published messages -// await().untilAsserted(() -> assertThat(app.getAcked()).contains("1", "2", "3", "4", "5")); -// } + // @Test + // void publisherWithBackPressureRejectWaitForPublisherReadiness() { + // MapBasedConfig config = new MapBasedConfig() + // .with("mp.messaging.outgoing.out.connector", "quarkus-solace") + // .with("mp.messaging.outgoing.out.producer.topic", topic) + // .with("mp.messaging.outgoing.out.producer.back-pressure.buffer-capacity", 1); + // + // List expected = new CopyOnWriteArrayList<>(); + // + // // Start listening first + // PersistentMessageReceiver receiver = messagingService.createPersistentMessageReceiverBuilder() + // .withSubscriptions(TopicSubscription.of("topic")) + // .build(Queue.nonDurableExclusiveQueue()); + // receiver.receiveAsync(inboundMessage -> { + // expected.add(inboundMessage.getPayloadAsString()); + // }); + // receiver.start(); + // + // // Run app that publish messages + // MyBackPressureRejectApp app = runApplication(config, MyBackPressureRejectApp.class); + // // Assert on published messages + // await().untilAsserted(() -> assertThat(app.getAcked()).contains("1", "2", "3", "4", "5")); + // } @ApplicationScoped static class MyApp { @@ -148,40 +142,39 @@ public List getAcked() { } } -// @ApplicationScoped -// static class MyBackPressureRejectApp { -// private final List acked = new CopyOnWriteArrayList<>(); -// public boolean waitForPublisherReadiness = false; -// @Channel("outgoing") -// MutinyEmitter foobar; -// -// void out() { -// List items = new ArrayList<>(); -// items.add("1"); -// items.add("2"); -// items.add("3"); -// items.add("4"); -// items.add("5"); -// items.forEach(payload -> { -// Message message = Message.of(payload).withAck(() -> { -// acked.add(payload); -// return CompletableFuture.completedFuture(null); -// }); -// if (waitForPublisherReadiness) { -// while (SolaceOutgoingChannel.isPublisherReady) { -// foobar.sendMessage(message); -// } -// } else { -// foobar.sendMessage(message); -// } -// }); -// } -// -// public List getAcked() { -// return acked; -// } -// } - + // @ApplicationScoped + // static class MyBackPressureRejectApp { + // private final List acked = new CopyOnWriteArrayList<>(); + // public boolean waitForPublisherReadiness = false; + // @Channel("outgoing") + // MutinyEmitter foobar; + // + // void out() { + // List items = new ArrayList<>(); + // items.add("1"); + // items.add("2"); + // items.add("3"); + // items.add("4"); + // items.add("5"); + // items.forEach(payload -> { + // Message message = Message.of(payload).withAck(() -> { + // acked.add(payload); + // return CompletableFuture.completedFuture(null); + // }); + // if (waitForPublisherReadiness) { + // while (SolaceOutgoingChannel.isPublisherReady) { + // foobar.sendMessage(message); + // } + // } else { + // foobar.sendMessage(message); + // } + // }); + // } + // + // public List getAcked() { + // return acked; + // } + // } @ApplicationScoped static class MyDynamicDestinationApp { @@ -192,7 +185,8 @@ Multi> out() { return Multi.createFrom().items("1", "2", "3", "4", "5") .map(payload -> { SolaceOutboundMetadata outboundMetadata = SolaceOutboundMetadata.builder() - .setApplicationMessageId("test").setDynamicDestination("quarkus/integration/test/dynamic/topic/" + payload) + .setApplicationMessageId("test") + .setDynamicDestination("quarkus/integration/test/dynamic/topic/" + payload) .createPubSubOutboundMetadata(); Message message = Message.of(payload, Metadata.of(outboundMetadata)); return message.withAck(() -> { diff --git a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceBaseTest.java b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceBaseTest.java index cc96e72..e154ed0 100644 --- a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceBaseTest.java +++ b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceBaseTest.java @@ -28,7 +28,7 @@ public class SolaceBaseTest { public void initTopic(TestInfo testInfo) { String cn = testInfo.getTestClass().map(Class::getSimpleName).orElse(UUID.randomUUID().toString()); String mn = testInfo.getTestMethod().map(Method::getName).orElse(UUID.randomUUID().toString()); -// topic = cn + "/" + mn + "/" + UUID.randomUUID().getMostSignificantBits(); + // topic = cn + "/" + mn + "/" + UUID.randomUUID().getMostSignificantBits(); topic = "quarkus/integration/test/default/topic"; } diff --git a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceContainer.java b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceContainer.java index 91fca33..6e0b6c8 100644 --- a/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceContainer.java +++ b/pubsub-plus-connector/src/test/java/io/quarkiverse/solace/base/SolaceContainer.java @@ -136,28 +136,6 @@ private Transferable createConfigurationScript() { updateConfigScript(scriptBuilder, "exit"); updateConfigScript(scriptBuilder, "exit"); -// // Integration test user acl -// updateConfigScript(scriptBuilder, -// "create acl-profile integration-test-user-acl message-vpn " + vpn + " allow-client-connect"); -// updateConfigScript(scriptBuilder, "exit"); -// -// updateConfigScript(scriptBuilder, "acl-profile integration-test-user-acl message-vpn " + vpn); -// updateConfigScript(scriptBuilder, "publish-topic exceptions smf list quarkus/integration/test"); -// updateConfigScript(scriptBuilder, "exit"); -// -// // Integration test user -// updateConfigScript(scriptBuilder, "create client-username " + "int_user" + " message-vpn " + vpn); -// updateConfigScript(scriptBuilder, "password " + "int_pass"); -// updateConfigScript(scriptBuilder, "acl-profile integration-test-user-acl"); -// updateConfigScript(scriptBuilder, "client-profile default"); -// updateConfigScript(scriptBuilder, "no shutdown"); -// updateConfigScript(scriptBuilder, "exit"); -// -// updateConfigScript(scriptBuilder, "client-profile default"); -// updateConfigScript(scriptBuilder, "allow-guaranteed-message-receive"); -// updateConfigScript(scriptBuilder, "allow-guaranteed-message-send"); -// updateConfigScript(scriptBuilder, "exit"); - // Create VPN if not default if (!vpn.equals(DEFAULT_VPN)) { updateConfigScript(scriptBuilder, "create message-vpn " + vpn); @@ -211,10 +189,10 @@ private Transferable createConfigurationScript() { // Configure default ACL updateConfigScript(scriptBuilder, "acl-profile default message-vpn " + vpn); // Configure default action to disallow - if(!subscribeTopicsConfiguration.isEmpty()) { + if (!subscribeTopicsConfiguration.isEmpty()) { updateConfigScript(scriptBuilder, "subscribe-topic default-action disallow"); } - if(!publishTopicsConfiguration.isEmpty()) { + if (!publishTopicsConfiguration.isEmpty()) { updateConfigScript(scriptBuilder, "publish-topic default-action disallow"); } updateConfigScript(scriptBuilder, "exit"); @@ -325,11 +303,11 @@ public SolaceContainer withCredentials(final String username, final String passw * @param service Service to be supported on provided topic * @return This container. */ -// public SolaceContainer withTopic(String topic, Service service) { -// topicsConfiguration.add(Pair.of(topic, service)); -// addExposedPort(service.getPort()); -// return this; -// } + // public SolaceContainer withTopic(String topic, Service service) { + // topicsConfiguration.add(Pair.of(topic, service)); + // addExposedPort(service.getPort()); + // return this; + // } /** * Adds the publish topic exceptions configuration diff --git a/samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/HelloConsumer.java b/samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/HelloConsumer.java index 4044804..8ab752e 100644 --- a/samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/HelloConsumer.java +++ b/samples/hello-connector-solace/src/main/java/io/quarkiverse/solace/samples/HelloConsumer.java @@ -18,26 +18,26 @@ public class HelloConsumer { * * @param p */ - @Incoming("hello-in") - @Outgoing("hello-out") - @Acknowledgment(Acknowledgment.Strategy.MANUAL) - Message consumeAndPublish(SolaceInboundMessage p) { - Log.infof("Received message: %s", new String(p.getMessage().getPayloadAsBytes(), StandardCharsets.UTF_8)); - SolaceOutboundMetadata outboundMetadata = SolaceOutboundMetadata.builder() - .createPubSubOutboundMetadata(); - Message outboundMessage = Message.of(p.getPayload(), Metadata.of(outboundMetadata), () -> { - CompletableFuture completableFuture = new CompletableFuture(); - p.ack(); - completableFuture.complete(null); - return completableFuture; - }, (throwable) -> { - CompletableFuture completableFuture = new CompletableFuture(); - p.nack(throwable, p.getMetadata()); - completableFuture.complete(null); - return completableFuture; - }); - return outboundMessage; - } + @Incoming("hello-in") + @Outgoing("hello-out") + @Acknowledgment(Acknowledgment.Strategy.MANUAL) + Message consumeAndPublish(SolaceInboundMessage p) { + Log.infof("Received message: %s", new String(p.getMessage().getPayloadAsBytes(), StandardCharsets.UTF_8)); + SolaceOutboundMetadata outboundMetadata = SolaceOutboundMetadata.builder() + .createPubSubOutboundMetadata(); + Message outboundMessage = Message.of(p.getPayload(), Metadata.of(outboundMetadata), () -> { + CompletableFuture completableFuture = new CompletableFuture(); + p.ack(); + completableFuture.complete(null); + return completableFuture; + }, (throwable) -> { + CompletableFuture completableFuture = new CompletableFuture(); + p.nack(throwable, p.getMetadata()); + completableFuture.complete(null); + return completableFuture; + }); + return outboundMessage; + } /** * Publish a simple string from using TryMe in Solace broker and you should see the message published to dynamic destination From 723df4948c127e598552edff352684ecb4d6cbd6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Dec 2023 07:35:19 +0000 Subject: [PATCH 3/3] Bump quarkus.version from 3.2.8.Final to 3.6.3 Bumps `quarkus.version` from 3.2.8.Final to 3.6.3. Updates `io.quarkus:quarkus-bom` from 3.2.8.Final to 3.6.3 - [Release notes](https://github.com/quarkusio/quarkus/releases) - [Commits](https://github.com/quarkusio/quarkus/compare/3.2.8.Final...3.6.3) Updates `io.quarkus:quarkus-maven-plugin` from 3.2.8.Final to 3.6.3 Updates `io.quarkus:quarkus-extension-processor` from 3.2.8.Final to 3.6.3 Updates `io.quarkus:quarkus-extension-maven-plugin` from 3.2.8.Final to 3.6.3 - [Release notes](https://github.com/quarkusio/quarkus/releases) - [Commits](https://github.com/quarkusio/quarkus/compare/3.2.8.Final...3.6.3) --- updated-dependencies: - dependency-name: io.quarkus:quarkus-bom dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.quarkus:quarkus-maven-plugin dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.quarkus:quarkus-extension-processor dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.quarkus:quarkus-extension-maven-plugin dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8622bac..0c1707f 100644 --- a/pom.xml +++ b/pom.xml @@ -34,7 +34,7 @@ 11 UTF-8 UTF-8 - 3.2.8.Final + 3.6.3 1.4.0