diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..3f597165 --- /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-reactive-messsaging-nats-jetstream diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..5b063201 --- /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 00000000..494c229a --- /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 00000000..5da57b27 --- /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 }} + strategy: + fail-fast: false + matrix: +# os: [windows-latest, macos-latest, ubuntu-latest] + os: [ubuntu-latest] + 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 11 + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 11 + 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 00000000..0a9e64ea --- /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 00000000..dc3a5615 --- /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-reactive-messsaging-nats-jetstream + +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 00000000..0a3894f1 --- /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/.gitignore b/.gitignore new file mode 100644 index 00000000..a19c3c0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see https://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +# Eclipse +.project +.classpath +.settings/ +bin/ + +# IntelliJ +.idea +*.ipr +*.iml +*.iws + +# NetBeans +nb-configuration.xml + +# Visual Studio Code +.vscode +.factorypath + +# OSX +.DS_Store + +# Vim +*.swp +*.swo + +# patch +*.orig +*.rej + +# Gradle +.gradle/ +build/ + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +release.properties diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..261eeb9e --- /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/README.md b/README.md index 081be385..efddc1f3 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,15 @@ [![Version](https://img.shields.io/maven-central/v/io.quarkiverse/quarkus-reactive-messsaging-nats-jetstream?logo=apache-maven&style=flat-square)](https://search.maven.org/artifact/io.quarkiverse/quarkus-reactive-messsaging-nats-jetstream) -## Welcome to Quarkiverse! +## Introduction -Congratulations and thank you for creating a new Quarkus extension project in Quarkiverse! +This extension allow usage of [NATS JetStream](https://docs.nats.io/nats-concepts/jetstream) inside a Quarkus App, in JVM and Native mode. -Feel free to replace this content with the proper description of your new project and necessary instructions how to use and contribute to it. +The extension implements a new connector type **quarkus-jetstream** in [SmallRye Reactive Messaging](https://smallrye.io/smallrye-reactive-messaging) that will use the [NATS client](https://github.com/nats-io/nats.java). -You can find the basic info, Quarkiverse policies and conventions in [the Quarkiverse wiki](https://github.com/quarkiverse/quarkiverse/wiki). +For more information about installation and configuration please read the documentation +[here](https://quarkiverse.github.io/quarkiverse-docs/quarkus-reactive-messaging-nats-jetstream/dev/index.html). -In case you are creating a Quarkus extension project for the first time, please follow [Building My First Extension](https://quarkus.io/guides/building-my-first-extension) guide. +## Contributing -Other useful articles related to Quarkus extension development can be found under the [Writing Extensions](https://quarkus.io/guides/#writing-extensions) guide category on the [Quarkus.io](https://quarkus.io) website. - -Thanks again, good luck and have fun! - -## Documentation - -The documentation for this extension should be maintained as part of this repository and it is stored in the `docs/` directory. - -The layout should follow the [Antora's Standard File and Directory Set](https://docs.antora.org/antora/2.3/standard-directories/). - -Once the docs are ready to be published, please open a PR including this repository in the [Quarkiverse Docs Antora playbook](https://github.com/quarkiverse/quarkiverse-docs/blob/main/antora-playbook.yml#L7). See an example [here](https://github.com/quarkiverse/quarkiverse-docs/pull/1). +Feel free to contribute to this project by submitting issues or pull requests. \ No newline at end of file diff --git a/deployment/pom.xml b/deployment/pom.xml new file mode 100644 index 00000000..6f328bed --- /dev/null +++ b/deployment/pom.xml @@ -0,0 +1,96 @@ + + + 4.0.0 + + io.quarkiverse + quarkus-reactive-messsaging-nats-jetstream-parent + 999-SNAPSHOT + + quarkus-reactive-messsaging-nats-jetstream-deployment + Quarkus Reactive Messsaging Nats Jetstream - Deployment + + + io.quarkus + quarkus-arc-deployment + + + io.quarkus + quarkus-smallrye-reactive-messaging-deployment + + + io.quarkus + quarkus-jackson-deployment + + + io.quarkus + quarkus-opentelemetry-deployment + + + io.quarkiverse + quarkus-reactive-messsaging-nats-jetstream + ${project.version} + + + io.quarkus + quarkus-devservices-deployment + + + org.testcontainers + testcontainers + + + junit + junit + + + + + io.quarkus + quarkus-junit5-internal + test + + + io.quarkus + quarkus-resteasy-reactive-jackson-deployment + test + + + io.quarkus + quarkus-smallrye-health + test + + + io.rest-assured + rest-assured + test + + + org.assertj + assertj-core + test + + + org.awaitility + awaitility + test + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/deployment/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/deployment/NatsJetStreamContainer.java b/deployment/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/deployment/NatsJetStreamContainer.java new file mode 100644 index 00000000..519e3e95 --- /dev/null +++ b/deployment/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/deployment/NatsJetStreamContainer.java @@ -0,0 +1,51 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.deployment; + +import java.time.Duration; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +public class NatsJetStreamContainer extends GenericContainer { + public static final DockerImageName NATS_IMAGE = DockerImageName.parse("nats:2.10"); + + static final Integer NATS_PORT = 4222; + static final Integer NATS_HTTP_PORT = 8222; + + static final String USERNAME = "guest"; + static final String PASSWORD = "guest"; + + public NatsJetStreamContainer() { + this(NATS_IMAGE); + } + + public NatsJetStreamContainer(DockerImageName imageName) { + super(imageName); + + super.withNetworkAliases("nats"); + super.waitingFor(Wait.forHttp("/healthz").forPort(NATS_HTTP_PORT)); + super.withStartupTimeout(Duration.ofSeconds(180L)); + super.withExposedPorts(NATS_PORT, NATS_HTTP_PORT); + super.withCommand("--jetstream", "--user", USERNAME, "--pass", PASSWORD, "--http_port", NATS_HTTP_PORT.toString()); + } + + public String getServerUrl() { + return String.format("nats://%s:%s", getHost(), getMappedPort(NATS_PORT)); + } + + public String getUsername() { + return USERNAME; + } + + public String getPassword() { + return PASSWORD; + } + + public NatsJetStreamContainer withPort(final int fixedPort) { + if (fixedPort <= 0) { + throw new IllegalArgumentException("The fixed port must be greater than 0"); + } + addFixedExposedPort(fixedPort, NATS_PORT); + return self(); + } +} diff --git a/deployment/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/deployment/ReactiveMesssagingNatsJetstreamDevServicesBuildTimeConfig.java b/deployment/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/deployment/ReactiveMesssagingNatsJetstreamDevServicesBuildTimeConfig.java new file mode 100644 index 00000000..8d48a78b --- /dev/null +++ b/deployment/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/deployment/ReactiveMesssagingNatsJetstreamDevServicesBuildTimeConfig.java @@ -0,0 +1,62 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.deployment; + +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigMapping(prefix = "quarkus.reactive-messaging.nats.jet-stream.devservices") +@ConfigRoot(phase = ConfigPhase.BUILD_TIME) +public interface ReactiveMesssagingNatsJetstreamDevServicesBuildTimeConfig { + + /** + * If Dev Services for NATS JetStream has been explicitly enabled or disabled. Dev Services are generally enabled + * by default, unless there is an existing configuration present. + */ + Optional enabled(); + + /** + * Optional fixed port the dev service will listen to. + *

+ * If not defined, the port will be chosen randomly. + */ + Optional port(); + + /** + * The image to use. + * Note that only NATS.io images are supported. + * + * Check https://hub.docker.com/_/nats to find the available versions. + */ + @WithDefault("nats:2.10") + String imageName(); + + /** + * Indicates if the NATS JetStream 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 NATS JetStream starts a new container. + *

+ * The discovery uses the {@code quarkus-dev-service-jetstream} 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-jetstream} 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 NATS JetStream looks for a container with the + * {@code quarkus-dev-service-jetstream} 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-jetstream} label set to the specified value. + *

+ * This property is used when you need multiple shared NATS JetStream brokers. + */ + @WithDefault("nats") + String serviceName(); +} diff --git a/deployment/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/deployment/ReactiveMesssagingNatsJetstreamDevServicesProcessor.java b/deployment/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/deployment/ReactiveMesssagingNatsJetstreamDevServicesProcessor.java new file mode 100644 index 00000000..2629eb25 --- /dev/null +++ b/deployment/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/deployment/ReactiveMesssagingNatsJetstreamDevServicesProcessor.java @@ -0,0 +1,210 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.deployment; + +import static io.quarkiverse.reactive.messsaging.nats.jetstream.deployment.ReactiveMesssagingNatsJetstreamProcessor.FEATURE; + +import java.io.Closeable; +import java.time.Duration; +import java.util.HashMap; +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.Network; +import org.testcontainers.utility.DockerImageName; + +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.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.ContainerLocator; +import io.quarkus.runtime.LaunchMode; + +/** + * Starts a NATS JetStream broker as dev service if needed. + * It uses https://hub.docker.com/_/nats as image. + */ +@BuildSteps(onlyIfNot = IsNormal.class, onlyIf = GlobalDevServicesConfig.Enabled.class) +public class ReactiveMesssagingNatsJetstreamDevServicesProcessor { + private static final Logger logger = Logger.getLogger(ReactiveMesssagingNatsJetstreamDevServicesProcessor.class); + + /** + * Label to add to shared Dev Service for pulsar 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-jetstream"; + + private static final ContainerLocator jetStreamContainerLocator = new ContainerLocator(DEV_SERVICE_LABEL, + NatsJetStreamContainer.NATS_PORT); + + static volatile RunningDevService devService; + static volatile boolean first = true; + static volatile JetStreamDevServiceCfg cfg; + + @BuildStep + public DevServicesResultBuildItem startJetStreamDevService( + DockerStatusBuildItem dockerStatusBuildItem, + LaunchModeBuildItem launchMode, + ReactiveMesssagingNatsJetstreamDevServicesBuildTimeConfig devServicesBuildTimeConfig, + Optional consoleInstalledBuildItem, + CuratedApplicationShutdownBuildItem closeBuildItem, + LoggingSetupBuildItem loggingSetupBuildItem, + GlobalDevServicesConfig devServicesConfig) { + + JetStreamDevServiceCfg configuration = new JetStreamDevServiceCfg(devServicesBuildTimeConfig); + + if (devService != null) { + boolean shouldShutdownTheBroker = !configuration.equals(cfg); + if (!shouldShutdownTheBroker) { + return devService.toBuildItem(); + } + shutdownBroker(); + cfg = null; + } + + StartupLogCompressor compressor = new StartupLogCompressor( + (launchMode.isTest() ? "(test) " : "") + "NATS JetStream Dev Services Starting:", consoleInstalledBuildItem, + loggingSetupBuildItem); + try { + DevServicesResultBuildItem.RunningDevService newDevService = startJetStreamContainer(dockerStatusBuildItem, + configuration, launchMode, + devServicesConfig.timeout); + if (newDevService != null) { + devService = newDevService; + if (newDevService.isOwner()) { + logger.info("Dev Services for NATS JetStream started."); + } + } + if (devService == null) { + compressor.closeAndDumpCaptured(); + } else { + compressor.close(); + } + } catch (Throwable t) { + compressor.closeAndDumpCaptured(); + throw new RuntimeException(t); + } + + if (devService == null) { + return null; + } + + // Configure the watch dog + if (first) { + first = false; + Runnable closeTask = () -> { + if (devService != null) { + shutdownBroker(); + + logger.info("Dev Services for NATS JetStream shut down."); + } + first = true; + devService = null; + cfg = null; + }; + closeBuildItem.addCloseTask(closeTask, true); + } + cfg = configuration; + return devService.toBuildItem(); + } + + private void shutdownBroker() { + if (devService != null) { + try { + devService.close(); + } catch (Throwable e) { + logger.error("Failed to stop the NATS JetStream broker", e); + } finally { + devService = null; + } + } + } + + private RunningDevService startJetStreamContainer(DockerStatusBuildItem dockerStatusBuildItem, + JetStreamDevServiceCfg config, + LaunchModeBuildItem launchMode, Optional timeout) { + if (!config.devServicesEnabled) { + // explicitly disabled + logger.debug("Not starting Dev Services for NATS JetStream, as it has been disabled in the config."); + return null; + } + + if (!dockerStatusBuildItem.isDockerAvailable()) { + logger.warn("Docker isn't working, please configure the NATS JetStream broker location."); + return null; + } + + final Supplier defaultJetStreamBrokerSupplier = () -> { + // Starting the broker + NatsJetStreamContainer container = new NatsJetStreamContainer(DockerImageName.parse(config.imageName) + .asCompatibleSubstituteFor("nats")) + .withNetwork(Network.SHARED); + if (launchMode.getLaunchMode() == LaunchMode.DEVELOPMENT) { // Only adds the label in dev mode. + container.withLabel(DEV_SERVICE_LABEL, config.serviceName); + } + if (config.fixedExposedPort != 0) { + container.withPort(config.fixedExposedPort); + } + timeout.ifPresent(container::withStartupTimeout); + container.start(); + + return getRunningService(container.getContainerId(), container::close, container.getServerUrl()); + }; + + return jetStreamContainerLocator.locateContainer(config.serviceName, config.shared, launchMode.getLaunchMode()) + .map(containerAddress -> getRunningService(containerAddress.getId(), null, + String.format("nats://%s:%s", containerAddress.getHost(), containerAddress.getPort()))) + .orElseGet(defaultJetStreamBrokerSupplier); + } + + private RunningDevService getRunningService(String containerId, Closeable closeable, String serverUrl) { + Map configMap = new HashMap<>(); + configMap.put("quarkus.reactive-messaging.nats.servers", serverUrl); + configMap.put("quarkus.reactive-messaging.nats.username", NatsJetStreamContainer.USERNAME); + configMap.put("quarkus.reactive-messaging.nats.password", NatsJetStreamContainer.PASSWORD); + configMap.put("quarkus.reactive-messaging.nats.ssl-enabled", "false"); + return new RunningDevService(FEATURE, containerId, closeable, configMap); + } + + private static final class JetStreamDevServiceCfg { + private final boolean devServicesEnabled; + private final String imageName; + private final Integer fixedExposedPort; + private final boolean shared; + private final String serviceName; + + public JetStreamDevServiceCfg(ReactiveMesssagingNatsJetstreamDevServicesBuildTimeConfig devServicesConfig) { + this.devServicesEnabled = devServicesConfig.enabled().orElse(true); + this.imageName = devServicesConfig.imageName(); + this.fixedExposedPort = devServicesConfig.port().orElse(0); + this.shared = devServicesConfig.shared(); + this.serviceName = devServicesConfig.serviceName(); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + JetStreamDevServiceCfg that = (JetStreamDevServiceCfg) o; + return devServicesEnabled == that.devServicesEnabled && shared == that.shared + && Objects.equals(imageName, that.imageName) && Objects.equals(fixedExposedPort, that.fixedExposedPort) + && Objects.equals(serviceName, that.serviceName); + } + + @Override + public int hashCode() { + return Objects.hash(devServicesEnabled, imageName, fixedExposedPort, shared, serviceName); + } + } +} diff --git a/deployment/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/deployment/ReactiveMesssagingNatsJetstreamProcessor.java b/deployment/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/deployment/ReactiveMesssagingNatsJetstreamProcessor.java new file mode 100644 index 00000000..79e94293 --- /dev/null +++ b/deployment/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/deployment/ReactiveMesssagingNatsJetstreamProcessor.java @@ -0,0 +1,65 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.deployment; + +import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; + +import io.nats.client.Options; +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamBuildConfiguration; +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamConnector; +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamRecorder; +import io.quarkiverse.reactive.messsaging.nats.jetstream.administration.MessageResolver; +import io.quarkiverse.reactive.messsaging.nats.jetstream.mapper.PayloadMapper; +import io.quarkiverse.reactive.messsaging.nats.jetstream.tracing.JetStreamInstrumenter; +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.ExtensionSslNativeSupportBuildItem; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; +import io.smallrye.reactive.messaging.providers.connectors.ExecutionHolder; + +class ReactiveMesssagingNatsJetstreamProcessor { + + static final String FEATURE = "reactive-messsaging-nats-jetstream"; + + @BuildStep + FeatureBuildItem feature() { + return new FeatureBuildItem(FEATURE); + } + + @BuildStep + void registerReflectiveClasses(BuildProducer producer) { + producer.produce(ReflectiveClassBuildItem.builder(Options.DEFAULT_DATA_PORT_TYPE).build()); + } + + @BuildStep + ExtensionSslNativeSupportBuildItem activateSslNativeSupport() { + return new ExtensionSslNativeSupportBuildItem(FEATURE); + } + + @BuildStep + void initializeSecureRandomRelatedClassesAtRuntime( + BuildProducer runtimeInitializedClasses) { + runtimeInitializedClasses.produce(new RuntimeInitializedClassBuildItem("io.nats.client.support.RandomUtils")); + runtimeInitializedClasses.produce(new RuntimeInitializedClassBuildItem("io.nats.client.NUID")); + } + + @BuildStep + void createNatsConnector(BuildProducer buildProducer) { + buildProducer.produce(AdditionalBeanBuildItem.unremovableOf(JetStreamConnector.class)); + buildProducer.produce(AdditionalBeanBuildItem.unremovableOf(MessageResolver.class)); + buildProducer.produce(AdditionalBeanBuildItem.unremovableOf(PayloadMapper.class)); + buildProducer.produce(AdditionalBeanBuildItem.unremovableOf(JetStreamInstrumenter.class)); + buildProducer.produce(AdditionalBeanBuildItem.unremovableOf(ExecutionHolder.class)); + } + + @BuildStep + @Record(RUNTIME_INIT) + public void configureJetStream(JetStreamRecorder recorder, + JetStreamBuildConfiguration buildConfig) { + if (buildConfig.autoConfigure()) { + recorder.setupStreams(); + } + } +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/Advisory.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/Advisory.java new file mode 100644 index 00000000..8da024b0 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/Advisory.java @@ -0,0 +1,70 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +public class Advisory { + private String type; + private String id; + private String timestamp; + private String stream; + private String consumer; + private long stream_seq; + private long deliveries; + + public Advisory() { + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getTimestamp() { + return timestamp; + } + + public void setTimestamp(String timestamp) { + this.timestamp = timestamp; + } + + public String getStream() { + return stream; + } + + public void setStream(String stream) { + this.stream = stream; + } + + public String getConsumer() { + return consumer; + } + + public void setConsumer(String consumer) { + this.consumer = consumer; + } + + public long getStream_seq() { + return stream_seq; + } + + public void setStream_seq(long stream_seq) { + this.stream_seq = stream_seq; + } + + public long getDeliveries() { + return deliveries; + } + + public void setDeliveries(long deliveries) { + this.deliveries = deliveries; + } +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/Data.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/Data.java new file mode 100644 index 00000000..2854ae68 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/Data.java @@ -0,0 +1,28 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +public class Data { + private String data; + private String resourceId; + private String messageId; + + public Data(String data, String resourceId, String messageId) { + this.data = data; + this.resourceId = resourceId; + this.messageId = messageId; + } + + public Data() { + } + + public String getData() { + return data; + } + + public String getResourceId() { + return resourceId; + } + + public String getMessageId() { + return messageId; + } +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DataConsumingBean.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DataConsumingBean.java new file mode 100644 index 00000000..8fbed55b --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DataConsumingBean.java @@ -0,0 +1,40 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +import java.util.Optional; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Message; +import org.jboss.logging.Logger; + +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamIncomingMessageMetadata; +import io.smallrye.mutiny.Uni; +import io.smallrye.reactive.messaging.annotations.Blocking; + +@ApplicationScoped +public class DataConsumingBean { + private final static Logger logger = Logger.getLogger(DataConsumingBean.class); + + volatile Optional lastData = Optional.empty(); + + @Blocking + @Incoming("data-consumer") + public Uni data(Message message) { + return Uni.createFrom().item(message) + .onItem().invoke(this::handleData) + .onItem().ignore().andContinueWithNull(); + } + + public Optional getLast() { + return lastData; + } + + private void handleData(Message message) { + logger.infof("Received message: %s", message); + message.getMetadata(JetStreamIncomingMessageMetadata.class) + .ifPresent(metadata -> lastData = Optional.of( + new Data(message.getPayload(), metadata.headers().get("RESOURCE_ID").get(0), metadata.messageId()))); + message.ack(); + } +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DataResource.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DataResource.java new file mode 100644 index 00000000..d8e8f266 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DataResource.java @@ -0,0 +1,47 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +import java.util.HashMap; +import java.util.List; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; + +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.eclipse.microprofile.reactive.messaging.Message; +import org.eclipse.microprofile.reactive.messaging.Metadata; + +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamOutgoingMessageMetadata; +import io.smallrye.mutiny.Uni; + +@Path("/data") +@Produces("application/json") +public class DataResource { + + @Inject + DataConsumingBean bean; + + @Channel("data") + Emitter emitter; + + @GET + @Path("/last") + public Data getLast() { + return bean.getLast().orElseGet(Data::new); + } + + @POST + @Path("/{id}/{data}") + public Uni produceData(@PathParam("id") String id, @PathParam("data") String data) { + return Uni.createFrom().item(() -> emitData(id, data)) + .onItem().ignore().andContinueWithNull(); + } + + private Message emitData(String id, String data) { + final var headers = new HashMap>(); + headers.put("RESOURCE_ID", List.of(data)); + final var message = Message.of(data, Metadata.of(new JetStreamOutgoingMessageMetadata(id, headers))); + emitter.send(message); + return message; + } +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DeadLetterConsumingBean.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DeadLetterConsumingBean.java new file mode 100644 index 00000000..3e7288fb --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DeadLetterConsumingBean.java @@ -0,0 +1,51 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +import java.util.Optional; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Message; +import org.jboss.logging.Logger; + +import io.quarkiverse.reactive.messsaging.nats.jetstream.administration.MessageResolver; +import io.smallrye.mutiny.Uni; + +@ApplicationScoped +public class DeadLetterConsumingBean { + private final static Logger logger = Logger.getLogger(DeadLetterConsumingBean.class); + + volatile Optional lastData = Optional.empty(); + + @Inject + MessageResolver resolver; + + public Optional getLast() { + return lastData; + } + + @Incoming("unstable-data-consumer") + public Uni durableConsumer(Message message) { + return Uni.createFrom().item(message) + .onItem().invoke(() -> { + logger.infof("Received message on unstable-data-consumer channel: %s", message); + message.nack(new RuntimeException("Failed to deliver")); + }) + .onFailure().invoke(message::nack) + .onItem().ignore().andContinueWithNull(); + } + + @Incoming("dead-letter-consumer") + public Uni deadLetter(Message message) { + logger.infof("Received dead letter on dead-letter-consumer channel: %s", message); + final var advisory = message.getPayload(); + return resolver. resolve(advisory.getStream(), advisory.getStream_seq()) + .onItem().invoke(deadLetter -> { + lastData = Optional.of(deadLetter.getPayload()); + message.ack(); + }) + .onFailure().invoke(message::nack) + .replaceWithVoid(); + } +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DeadLetterResource.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DeadLetterResource.java new file mode 100644 index 00000000..cde6ed4b --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DeadLetterResource.java @@ -0,0 +1,34 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; + +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; + +import io.smallrye.mutiny.Uni; + +@Path("/dead-letter") +@Produces("application/json") +public class DeadLetterResource { + + @Channel("unstable-data") + Emitter dataEmitter; + + @Inject + DeadLetterConsumingBean bean; + + @POST + @Path("/data") + @Consumes("application/json") + public Uni produce(Data data) { + return Uni.createFrom().item(() -> dataEmitter.send(data)).onItem().ignore().andContinueWithNull(); + } + + @GET + @Path("last") + public Data getLast() { + return bean.getLast().orElseGet(Data::new); + } + +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DurableConsumingBean.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DurableConsumingBean.java new file mode 100644 index 00000000..14e91297 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DurableConsumingBean.java @@ -0,0 +1,54 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Message; +import org.jboss.logging.Logger; + +import io.smallrye.mutiny.Uni; + +@ApplicationScoped +public class DurableConsumingBean { + private final static Logger logger = Logger.getLogger(DurableConsumingBean.class); + + private final AtomicReference> values = new AtomicReference<>(new ArrayList<>()); + + public List getValues() { + return values.get(); + } + + @Incoming("durable-consumer-1") + public Uni durableConsumer1(Message message) { + return Uni.createFrom().item(message) + .onItem().invoke(() -> { + logger.infof("Received message on durable-consumer-1 channel: %s", message); + values.updateAndGet(values -> { + values.add(message.getPayload()); + return values; + }); + message.ack(); + }) + .onFailure().invoke(message::nack) + .onItem().ignore().andContinueWithNull(); + } + + @Incoming("durable-consumer-2") + public Uni durableConsumer2(Message message) { + return Uni.createFrom().item(message) + .onItem().invoke(() -> { + logger.infof("Received message on durable-consumer-2 channel: %s", message); + values.updateAndGet(values -> { + values.add(message.getPayload()); + return values; + }); + message.ack(); + }) + .onFailure().invoke(message::nack) + .onItem().ignore().andContinueWithNull(); + } +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DurableResource.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DurableResource.java new file mode 100644 index 00000000..125e385b --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/DurableResource.java @@ -0,0 +1,35 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +import java.util.List; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; + +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; + +import io.smallrye.mutiny.Uni; + +@Path("/durable") +@Produces("application/json") +public class DurableResource { + + @Channel("durable-consumer") + Emitter emitter; + + @Inject + DurableConsumingBean bean; + + @POST + @Path("/{data}") + public Uni produce(@PathParam("data") Integer data) { + return Uni.createFrom().item(() -> emitter.send(data)).onItem().ignore().andContinueWithNull(); + } + + @GET + @Path("values") + public Uni> values() { + return Uni.createFrom().item(() -> bean.getValues()); + } + +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ReactiveMesssagingNatsJetstreamDevModeTest.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ReactiveMesssagingNatsJetstreamDevModeTest.java new file mode 100644 index 00000000..902f3154 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ReactiveMesssagingNatsJetstreamDevModeTest.java @@ -0,0 +1,44 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +import static io.restassured.RestAssured.get; +import static org.awaitility.Awaitility.await; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusDevModeTest; + +public class ReactiveMesssagingNatsJetstreamDevModeTest { + + @RegisterExtension + static QuarkusDevModeTest devModeTest = new QuarkusDevModeTest() + .withApplicationRoot((jar) -> jar + .addClasses(ValueConsumingBean.class, ValueProducingBean.class, ValueResource.class, + TestSpanExporter.class, Data.class, DataResource.class, DataConsumingBean.class, + Advisory.class, DeadLetterResource.class, DeadLetterConsumingBean.class, + DurableResource.class, DurableConsumingBean.class, RedeliveryResource.class, + RedeliveryConsumingBean.class) + .addAsResource("application.properties")); + + @Test + public void testCodeUpdate() { + await() + .atMost(1, TimeUnit.MINUTES) + .until(() -> { + String value = get("/value/last").asString(); + return value.equalsIgnoreCase("20"); + }); + + devModeTest.modifySourceFile(ValueProducingBean.class, s -> s.replace("* 2", "* 3")); + + await() + .atMost(1, TimeUnit.MINUTES) + .until(() -> { + String value = get("/value/last").asString(); + return value.equalsIgnoreCase("30"); + }); + + } +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ReactiveMesssagingNatsJetstreamTest.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ReactiveMesssagingNatsJetstreamTest.java new file mode 100644 index 00000000..3c603de1 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ReactiveMesssagingNatsJetstreamTest.java @@ -0,0 +1,91 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +import static io.restassured.RestAssured.*; +import static org.awaitility.Awaitility.await; + +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.parsing.Parser; + +public class ReactiveMesssagingNatsJetstreamTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest().setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .addClasses(ValueConsumingBean.class, ValueProducingBean.class, ValueResource.class, + TestSpanExporter.class, Data.class, DataResource.class, DataConsumingBean.class, + Advisory.class, DeadLetterResource.class, DeadLetterConsumingBean.class, + DurableResource.class, DurableConsumingBean.class, RedeliveryResource.class, + RedeliveryConsumingBean.class)) + .withConfigurationResource("application.properties"); + + @BeforeEach + public void setup() { + defaultParser = Parser.JSON; + } + + @Test + public void health() { + given().get("/q/health/ready").then().statusCode(200); + given().get("/q/health/live").then().statusCode(200); + } + + @Test + public void metadata() { + final var messageId = "9a99811a-ef82-468e-9f0b-7879f7be16a9"; + final var data = "N6cXzM"; + + given().pathParam("id", messageId).pathParam("data", data).post("/data/{id}/{data}").then().statusCode(204); + + await().atMost(1, TimeUnit.MINUTES).until(() -> { + final var dataValue = get("/data/last").as(Data.class); + return data.equals(dataValue.getData()) && data.equals(dataValue.getResourceId()) + && messageId.equals(dataValue.getMessageId()); + }); + } + + @Test + public void deadLetter() { + final var messageId = "342646ee-acc5-4acd-b35d-a222568a127f"; + final var data = "6UFqFISmfk"; + + given().header("Content-Type", "application/json").body(new Data(data, messageId, messageId)) + .post("/dead-letter/data").then().statusCode(204); + + await().atMost(1, TimeUnit.MINUTES).until(() -> { + final var dataValue = get("/dead-letter/last").as(Data.class); + return data.equals(dataValue.getData()) && messageId.equals(dataValue.getResourceId()) + && messageId.equals(dataValue.getMessageId()); + }); + } + + @Test + void durableConsumer() { + for (int i = 1; i <= 5; i++) { + given().pathParam("data", i).post("/durable/{data}").then().statusCode(204); + } + await().atMost(1, TimeUnit.MINUTES).until(() -> { + final var values = Arrays.asList(get("/durable/values").as(Integer[].class)); + return values.size() == 5 && values.contains(1) && values.contains(2) && values.contains(3) && values.contains(4) + && values.contains(5); + }); + } + + @Test + void redelivery() { + given().pathParam("data", 42).post("/redelivery/{data}").then().statusCode(204); + await().atMost(1, TimeUnit.MINUTES).until(() -> { + final var value = get("/redelivery/last").asString(); + return value.equalsIgnoreCase("42"); + }); + } + +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ReactiveMesssagingNatsJetstreamTracingTest.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ReactiveMesssagingNatsJetstreamTracingTest.java new file mode 100644 index 00000000..9091f9ec --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ReactiveMesssagingNatsJetstreamTracingTest.java @@ -0,0 +1,59 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.stream.Collectors; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.opentelemetry.api.trace.SpanId; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; +import io.restassured.parsing.Parser; + +public class ReactiveMesssagingNatsJetstreamTracingTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest().setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .addClasses(TestSpanExporter.class, Data.class, DataResource.class, DataConsumingBean.class)) + .withConfigurationResource("application-tracing.properties"); + + @Inject + TestSpanExporter spanExporter; + + @BeforeEach + public void setup() { + RestAssured.defaultParser = Parser.JSON; + spanExporter.reset(); + } + + @Test + public void tracing() { + final var messageId = "c923ca9b-27ac-4dc3-ad61-8c6733f93b11"; + final var data = "N6cXzadfafM"; + + RestAssured.given().pathParam("id", messageId).pathParam("data", data).post("/data/{id}/{data}").then().statusCode(204); + + final var spans = spanExporter.getFinishedSpanItems(3); + assertThat(spans).isNotEmpty(); + + List parentSpans = spans.stream().filter(spanData -> spanData.getParentSpanId().equals(SpanId.getInvalid())) + .collect(Collectors.toList()); + assertEquals(1, parentSpans.size()); + + for (var parentSpan : parentSpans) { + assertThat(spans.stream().filter(spanData -> spanData.getParentSpanId().equals(parentSpan.getSpanId())).count()) + .isEqualTo(1); + } + } +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/RedeliveryConsumingBean.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/RedeliveryConsumingBean.java new file mode 100644 index 00000000..1df199c4 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/RedeliveryConsumingBean.java @@ -0,0 +1,39 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +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 io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamIncomingMessageMetadata; +import io.smallrye.mutiny.Uni; +import io.smallrye.reactive.messaging.annotations.Blocking; + +@ApplicationScoped +public class RedeliveryConsumingBean { + volatile Integer lastValue = -1; + + @Incoming("unstable") + @Acknowledgment(Acknowledgment.Strategy.MANUAL) + @Blocking + public Uni unstable(Message message) { + return Uni.createFrom().item(message) + .onItem().invoke(m -> { + final var metadata = message.getMetadata(JetStreamIncomingMessageMetadata.class) + .orElseThrow(() -> new RuntimeException("No metadata")); + if (metadata.deliveredCount() < 3) { + message.nack(new Exception("Redeliver message")); + } else { + lastValue = message.getPayload(); + message.ack(); + } + }) + .onFailure().invoke(message::nack) + .onItem().ignore().andContinueWithNull(); + } + + public Integer getLast() { + return lastValue; + } +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/RedeliveryResource.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/RedeliveryResource.java new file mode 100644 index 00000000..63e49383 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/RedeliveryResource.java @@ -0,0 +1,33 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; + +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; + +import io.smallrye.mutiny.Uni; + +@Path("/redelivery") +@Produces("application/json") +public class RedeliveryResource { + + @Channel("unstable-producer") + Emitter unstableEmitter; + + @Inject + RedeliveryConsumingBean bean; + + @POST + @Path("{data}") + public Uni produce(@PathParam("data") Integer data) { + return Uni.createFrom().item(() -> unstableEmitter.send(data)).onItem().ignore().andContinueWithNull(); + } + + @GET + @Path("last") + public Uni last() { + return Uni.createFrom().item(() -> bean.getLast()); + } + +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/TestSpanExporter.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/TestSpanExporter.java new file mode 100644 index 00000000..d94e7bfb --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/TestSpanExporter.java @@ -0,0 +1,66 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +import static java.util.Comparator.comparingLong; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import io.quarkus.arc.Unremovable; + +@Unremovable +@ApplicationScoped +public class TestSpanExporter implements SpanExporter { + private final List finishedSpanItems = new CopyOnWriteArrayList<>(); + private volatile boolean isStopped = false; + + /** + * Careful when retrieving the list of finished spans. There is a chance when the response is already sent to the + * client and Vert.x still writing the end of the spans. This means that a response is available to assert from the + * test side but not all spans may be available yet. For this reason, this method requires the number of expected + * spans. + */ + public List getFinishedSpanItems(int spanCount) { + assertSpanCount(spanCount); + return finishedSpanItems.stream().sorted(comparingLong(SpanData::getStartEpochNanos).reversed()) + .collect(Collectors.toList()); + } + + public void assertSpanCount(int spanCount) { + await().atMost(30, SECONDS).untilAsserted(() -> assertEquals(spanCount, finishedSpanItems.size())); + } + + public void reset() { + finishedSpanItems.clear(); + } + + @Override + public CompletableResultCode export(Collection spans) { + if (isStopped) { + return CompletableResultCode.ofFailure(); + } + finishedSpanItems.addAll(spans); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + finishedSpanItems.clear(); + isStopped = true; + return CompletableResultCode.ofSuccess(); + } +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ValueConsumingBean.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ValueConsumingBean.java new file mode 100644 index 00000000..89c2938c --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ValueConsumingBean.java @@ -0,0 +1,21 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.reactive.messaging.Incoming; + +@ApplicationScoped +public class ValueConsumingBean { + + volatile long lastValue = -1; + + @Incoming("in") + public void consume(long content) { + lastValue = content; + } + + public long getLastValue() { + return lastValue; + } + +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ValueProducingBean.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ValueProducingBean.java new file mode 100644 index 00000000..9f36669e --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ValueProducingBean.java @@ -0,0 +1,25 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +import java.time.Duration; +import java.util.concurrent.Flow; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.reactive.messaging.Outgoing; + +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.Uni; + +@ApplicationScoped +public class ValueProducingBean { + + @Outgoing("source") + public Flow.Publisher generate() { + return Multi.createFrom().range(1, 11) + .map(Integer::longValue) + .map(i -> i * 2) + .onItem() + .transformToUniAndConcatenate(l -> Uni.createFrom().item(l).onItem().delayIt().by(Duration.ofMillis(10))); + } + +} diff --git a/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ValueResource.java b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ValueResource.java new file mode 100644 index 00000000..7bb9e075 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/test/ValueResource.java @@ -0,0 +1,19 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.test; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +@Path("/value") +public class ValueResource { + + @Inject + ValueConsumingBean bean; + + @GET + @Path("/last") + public long getLast() { + return bean.getLastValue(); + } + +} diff --git a/deployment/src/test/resources/application-tracing.properties b/deployment/src/test/resources/application-tracing.properties new file mode 100644 index 00000000..e0194567 --- /dev/null +++ b/deployment/src/test/resources/application-tracing.properties @@ -0,0 +1,7 @@ +mp.messaging.outgoing.data.connector=quarkus-jetstream +mp.messaging.outgoing.data.stream=test +mp.messaging.outgoing.data.subject=data + +mp.messaging.incoming.data-consumer.connector=quarkus-jetstream +mp.messaging.incoming.data-consumer.stream=test +mp.messaging.incoming.data-consumer.subject=data \ No newline at end of file diff --git a/deployment/src/test/resources/application.properties b/deployment/src/test/resources/application.properties new file mode 100644 index 00000000..43d19623 --- /dev/null +++ b/deployment/src/test/resources/application.properties @@ -0,0 +1,58 @@ +mp.messaging.outgoing.source.connector=quarkus-jetstream +mp.messaging.outgoing.source.stream=test +mp.messaging.outgoing.source.subject=values + +mp.messaging.incoming.in.connector=quarkus-jetstream +mp.messaging.incoming.in.stream=test +mp.messaging.incoming.in.subject=values + +mp.messaging.outgoing.data.connector=quarkus-jetstream +mp.messaging.outgoing.data.stream=test +mp.messaging.outgoing.data.subject=data + +mp.messaging.incoming.data-consumer.connector=quarkus-jetstream +mp.messaging.incoming.data-consumer.stream=test +mp.messaging.incoming.data-consumer.subject=data + +mp.messaging.outgoing.unstable-data.connector=quarkus-jetstream +mp.messaging.outgoing.unstable-data.stream=test +mp.messaging.outgoing.unstable-data.subject=unstable-data + +mp.messaging.incoming.unstable-data-consumer.connector=quarkus-jetstream +mp.messaging.incoming.unstable-data-consumer.subject=unstable-data +mp.messaging.incoming.unstable-data-consumer.stream=test +mp.messaging.incoming.unstable-data-consumer.max-deliver=1 +mp.messaging.incoming.unstable-data-consumer.durable=unstable-data-consumer + +mp.messaging.incoming.dead-letter-consumer.connector=quarkus-jetstream +mp.messaging.incoming.dead-letter-consumer.subject=$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.test.unstable-data-consumer +mp.messaging.incoming.dead-letter-consumer.stream=test +mp.messaging.incoming.dead-letter-consumer.payload-type=io.quarkiverse.reactive.messsaging.nats.jetstream.test.Advisory + +mp.messaging.outgoing.durable-consumer.connector=quarkus-jetstream +mp.messaging.outgoing.durable-consumer.subject=dc +mp.messaging.outgoing.durable-consumer.stream=test + +mp.messaging.incoming.durable-consumer-1.connector=quarkus-jetstream +mp.messaging.incoming.durable-consumer-1.subject=dc +mp.messaging.incoming.durable-consumer-1.stream=test +mp.messaging.incoming.durable-consumer-1.max-deliver=1 +mp.messaging.incoming.durable-consumer-1.durable=consumer1 +mp.messaging.incoming.durable-consumer-1.deliver-group=test-queue + +mp.messaging.incoming.durable-consumer-2.connector=quarkus-jetstream +mp.messaging.incoming.durable-consumer-2.subject=dc +mp.messaging.incoming.durable-consumer-2.stream=test +mp.messaging.incoming.durable-consumer-2.max-deliver=1 +mp.messaging.incoming.durable-consumer-2.durable=consumer1 +mp.messaging.incoming.durable-consumer-2.deliver-group=test-queue + +mp.messaging.outgoing.unstable-producer.connector=quarkus-jetstream +mp.messaging.outgoing.unstable-producer.subject=redelivery-data +mp.messaging.outgoing.unstable-producer.stream=test + +mp.messaging.incoming.unstable.connector=quarkus-jetstream +mp.messaging.incoming.unstable.subject=redelivery-data +mp.messaging.incoming.unstable.stream=test +mp.messaging.incoming.unstable.max-deliver=5 +mp.messaging.incoming.unstable.back-off=PT1S,PT5S diff --git a/docs/antora.yml b/docs/antora.yml new file mode 100644 index 00000000..55634f24 --- /dev/null +++ b/docs/antora.yml @@ -0,0 +1,5 @@ +name: quarkus-reactive-messsaging-nats-jetstream +title: Reactive Messsaging Nats Jetstream +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 00000000..e69de29b diff --git a/docs/modules/ROOT/examples/.keepme b/docs/modules/ROOT/examples/.keepme new file mode 100644 index 00000000..e69de29b diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc new file mode 100644 index 00000000..d2454cdb --- /dev/null +++ b/docs/modules/ROOT/nav.adoc @@ -0,0 +1 @@ +* xref:index.adoc[Getting started] diff --git a/docs/modules/ROOT/pages/includes/attributes.adoc b/docs/modules/ROOT/pages/includes/attributes.adoc new file mode 100644 index 00000000..19a687e2 --- /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/config.adoc b/docs/modules/ROOT/pages/includes/config.adoc new file mode 100644 index 00000000..e69de29b diff --git a/docs/modules/ROOT/pages/index.adoc b/docs/modules/ROOT/pages/index.adoc new file mode 100644 index 00000000..a8776823 --- /dev/null +++ b/docs/modules/ROOT/pages/index.adoc @@ -0,0 +1,75 @@ += Quarkus Reactive Messsaging Nats Jetstream + +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:quarkus-reactive-messsaging-nats-jetstream` extension first to your build file. + +For instance, with Maven, add the following dependency to your POM file: + +[source,xml,subs=attributes+] +---- + + io.quarkiverse + quarkus-reactive-messsaging-nats-jetstream + {project-version} + +---- + +Then configure your application by adding the NATS JetStream connector type: + +---- +# Inbound +mp.messaging.incoming.[channel-name].connector=quarkus-jetstream + +# Outbound +mp.messaging.outgoing.[channel-name].connector=quarkus-jetstream +---- + +== Receiving messages from NATS JetStream + +Let’s imagine you have a NATS JetStream broker running, and accessible using the localhost:4242 address. Configure your application to receive NATS messages on the data channel from the stream named: test and the subject named: data as follows: + +---- +quarkus.quarkus.reactive-messaging.nats.servers=nats://localhost:4242 +quarkus.quarkus.reactive-messaging.nats.username=guest +quarkus.quarkus.reactive-messaging.nats.password=guest +quarkus.quarkus.reactive-messaging.nats.ssl-enabled=false + +# Streams and subjects are auto-created by default based on channel configuration +quarkus.reactive-messaging.nats.jet-stream.auto-configure=true + +mp.messaging.incoming.data.connector=quarkus-jetstream +mp.messaging.incoming.data.stream=test +mp.messaging.incoming.data.subject=data +---- + +Then, your application receives Message. You can consumes the payload directly: + +---- +package inbound; + +import org.eclipse.microprofile.reactive.messaging.Incoming; + +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class DataConsumer { + + @Incoming("data") + public void consume(Data data) { + // process your data. + } + +} +---- + +If you want more examples, please take a look at the tests of the extension. + +[[extension-configuration-reference]] +== Extension Configuration Reference + +include::includes/quarkus-reactive-messsaging-nats-jetstream.adoc[leveloffset=+1, opts=optional] diff --git a/docs/pom.xml b/docs/pom.xml new file mode 100644 index 00000000..ab0ced89 --- /dev/null +++ b/docs/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + io.quarkiverse + quarkus-reactive-messsaging-nats-jetstream-parent + 999-SNAPSHOT + ../pom.xml + + + quarkus-reactive-messsaging-nats-jetstream-docs + Quarkus Reactive Messsaging Nats Jetstream - Documentation + + + + + io.quarkiverse + quarkus-reactive-messsaging-nats-jetstream-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-reactive-messsaging-nats-jetstream.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 00000000..e1a28816 --- /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 00000000..491f0ad7 --- /dev/null +++ b/integration-tests/pom.xml @@ -0,0 +1,109 @@ + + + 4.0.0 + + io.quarkiverse + quarkus-reactive-messsaging-nats-jetstream-parent + 999-SNAPSHOT + + quarkus-reactive-messsaging-nats-jetstream-integration-tests + Quarkus Reactive Messsaging Nats Jetstream - Integration Tests + + true + + + + io.quarkus + quarkus-resteasy-reactive-jackson + + + io.quarkiverse + quarkus-reactive-messsaging-nats-jetstream + ${project.version} + + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + org.assertj + assertj-core + test + + + org.awaitility + awaitility + test + + + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + maven-failsafe-plugin + + + + integration-test + verify + + + + + + ${project.build.directory}/${project.build.finalName}-runner + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + + native-image + + + native + + + + + + maven-surefire-plugin + + ${native.surefire.skip} + + + + + + false + native + + + + diff --git a/integration-tests/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/Data.java b/integration-tests/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/Data.java new file mode 100644 index 00000000..804f49a2 --- /dev/null +++ b/integration-tests/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/Data.java @@ -0,0 +1,48 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.it; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public class Data { + private String data; + private String resourceId; + + private String messageId; + + public Data(String data, String resourceId) { + this(data, resourceId, null); + } + + public Data(String data, String resourceId, String messageId) { + this.data = data; + this.resourceId = resourceId; + this.messageId = messageId; + } + + public Data() { + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + + public String getResourceId() { + return resourceId; + } + + public void setResourceId(String resourceId) { + this.resourceId = resourceId; + } + + public String getMessageId() { + return messageId; + } + + public void setMessageId(String messageId) { + this.messageId = messageId; + } +} diff --git a/integration-tests/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/DataConsumingBean.java b/integration-tests/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/DataConsumingBean.java new file mode 100644 index 00000000..3b7544dc --- /dev/null +++ b/integration-tests/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/DataConsumingBean.java @@ -0,0 +1,41 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.it; + +import java.util.Optional; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Message; +import org.jboss.logging.Logger; + +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamIncomingMessageMetadata; +import io.smallrye.mutiny.Uni; +import io.smallrye.reactive.messaging.annotations.Blocking; + +@ApplicationScoped +public class DataConsumingBean { + private final static Logger logger = Logger.getLogger(DataConsumingBean.class); + + volatile Optional lastData = Optional.empty(); + + @Blocking + @Incoming("data-consumer") + public Uni data(Message message) { + return Uni.createFrom().item(message) + .onItem().invoke(this::handleData) + .onItem().ignore().andContinueWithNull(); + } + + public Optional getLast() { + return lastData; + } + + private void handleData(Message message) { + logger.infof("Received message: %s", message); + message.getMetadata(JetStreamIncomingMessageMetadata.class) + .ifPresent(metadata -> lastData = Optional.of( + new Data(message.getPayload().getData(), metadata.headers().get("RESOURCE_ID").get(0), + metadata.messageId()))); + message.ack(); + } +} diff --git a/integration-tests/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/DataResource.java b/integration-tests/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/DataResource.java new file mode 100644 index 00000000..b8ae8fd2 --- /dev/null +++ b/integration-tests/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/DataResource.java @@ -0,0 +1,65 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. See the NOTICE file distributed with +* this work for additional information regarding copyright ownership. +* The ASF licenses this file to You 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. +*/ +package io.quarkiverse.reactive.messsaging.nats.jetstream.it; + +import java.util.HashMap; +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; + +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.eclipse.microprofile.reactive.messaging.Message; +import org.eclipse.microprofile.reactive.messaging.Metadata; + +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamOutgoingMessageMetadata; +import io.smallrye.mutiny.Uni; + +@Path("/data") +@Produces("application/json") +@ApplicationScoped +public class DataResource { + @Inject + DataConsumingBean bean; + + @Channel("data") + Emitter emitter; + + @GET + @Path("/last") + public Data getLast() { + return bean.getLast().orElseGet(Data::new); + } + + @POST + @Consumes("application/json") + @Path("/{messageId}") + public Uni produceData(@PathParam("messageId") String messageId, Data data) { + return Uni.createFrom().item(() -> emitData(messageId, data)) + .onItem().ignore().andContinueWithNull(); + } + + private Message emitData(String messageId, Data data) { + final var headers = new HashMap>(); + headers.put("RESOURCE_ID", List.of(data.getResourceId())); + final var message = Message.of(data, Metadata.of(new JetStreamOutgoingMessageMetadata(messageId, headers))); + emitter.send(message); + return message; + } +} diff --git a/integration-tests/src/main/resources/application.properties b/integration-tests/src/main/resources/application.properties new file mode 100644 index 00000000..f1a59ab5 --- /dev/null +++ b/integration-tests/src/main/resources/application.properties @@ -0,0 +1,10 @@ +mp.messaging.outgoing.data.connector=quarkus-jetstream +mp.messaging.outgoing.data.stream=test +mp.messaging.outgoing.data.subject=data + +mp.messaging.incoming.data-consumer.connector=quarkus-jetstream +mp.messaging.incoming.data-consumer.stream=test +mp.messaging.incoming.data-consumer.subject=data +mp.messaging.incoming.data-consumer.max-deliver=1 +mp.messaging.incoming.data-consumer.durable=data-consumer +mp.messaging.incoming.data-consumer.deliver-group=data-queue \ No newline at end of file diff --git a/integration-tests/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/DataResourceIT.java b/integration-tests/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/DataResourceIT.java new file mode 100644 index 00000000..8ccbcdf8 --- /dev/null +++ b/integration-tests/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/DataResourceIT.java @@ -0,0 +1,7 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.it; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class DataResourceIT extends DataResourceTest { +} diff --git a/integration-tests/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/DataResourceTest.java b/integration-tests/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/DataResourceTest.java new file mode 100644 index 00000000..d0c69f09 --- /dev/null +++ b/integration-tests/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/it/DataResourceTest.java @@ -0,0 +1,51 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.it; + +import static io.restassured.RestAssured.get; +import static io.restassured.RestAssured.given; +import static org.awaitility.Awaitility.await; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +public class DataResourceTest { + + @Test + public void data() { + final var messageId = "8cb9fd88-08e9-422d-9f19-a3b4b3cc8cb7"; + final var data = "N6cXzM"; + final var resourceId = "56d5cc43-92dd-4df9-b385-1e412fd8fc8a"; + + given() + .header("Content-Type", "application/json") + .pathParam("messageId", messageId) + .body(new Data(data, resourceId)) + .post("/data/{messageId}") + .then().statusCode(204); + + await().atMost(1, TimeUnit.MINUTES).until(() -> { + final var dataValue = get("/data/last").as(Data.class); + return messageId.equals(dataValue.getMessageId()) && data.equals(dataValue.getData()) + && resourceId.equals(dataValue.getResourceId()); + }); + } + + @Test + public void healthLive() { + given() + .when().get("/q/health/live") + .then() + .statusCode(200); + } + + @Test + public void healthReady() { + given() + .when().get("/q/health/ready") + .then() + .statusCode(200); + } +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..4decbb3f --- /dev/null +++ b/pom.xml @@ -0,0 +1,105 @@ + + + 4.0.0 + + io.quarkiverse + quarkiverse-parent + 15 + + quarkus-reactive-messsaging-nats-jetstream-parent + 999-SNAPSHOT + pom + Quarkus Reactive Messsaging Nats Jetstream - Parent + + deployment + runtime + docs + + + scm:git:git@github.com:quarkiverse/quarkus-reactive-messsaging-nats-jetstream.git + scm:git:git@github.com:quarkiverse/quarkus-reactive-messsaging-nats-jetstream.git + https://github.com/quarkiverse/quarkus-reactive-messsaging-nats-jetstream + + + 3.11.0 + 11 + UTF-8 + UTF-8 + 3.6.4 + + 2.17.1 + 0.3.0 + 4.13.0 + 4.13.0 + 3.1 + 3.24.2 + + + + + io.quarkus + quarkus-bom + ${quarkus.version} + pom + import + + + io.nats + jnats + ${jnats.version} + + + net.i2p.crypto + eddsa + ${eddsa.version} + + + io.smallrye.reactive + smallrye-reactive-messaging-otel + ${smallrye-reactive-messaging-otel.version} + + + org.assertj + assertj-core + ${assertj-core.version} + test + + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + maven-compiler-plugin + ${compiler-plugin.version} + + + -parameters + + + + + + + + + it + + + performRelease + !true + + + + integration-tests + + + + diff --git a/runtime/pom.xml b/runtime/pom.xml new file mode 100644 index 00000000..f9859061 --- /dev/null +++ b/runtime/pom.xml @@ -0,0 +1,102 @@ + + + 4.0.0 + + io.quarkiverse + quarkus-reactive-messsaging-nats-jetstream-parent + 999-SNAPSHOT + + quarkus-reactive-messsaging-nats-jetstream + Quarkus Reactive Messsaging Nats Jetstream - Runtime + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-smallrye-reactive-messaging + + + io.quarkus + quarkus-jackson + + + io.quarkus + quarkus-opentelemetry + + + io.nats + jnats + + + net.i2p.crypto + eddsa + provided + + + org.graalvm.sdk + graal-sdk + provided + + + io.smallrye.reactive + smallrye-reactive-messaging-otel + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + io.quarkus + quarkus-extension-maven-plugin + ${quarkus.version} + + + compile + + extension-descriptor + + + ${project.groupId}:${project.artifactId}-deployment:${project.version} + + + + + + + maven-compiler-plugin + + + + io.smallrye.reactive + smallrye-connector-attribute-processor + ${smallrye-connector-attribute-processor.version} + + + org.eclipse.microprofile.config + microprofile-config-api + ${microprofile-config-api.version} + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/NatsConfiguration.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/NatsConfiguration.java new file mode 100644 index 00000000..482119b9 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/NatsConfiguration.java @@ -0,0 +1,55 @@ +package io.quarkiverse.reactive.messsaging.nats; + +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigMapping(prefix = "quarkus.reactive-messaging.nats") +@ConfigRoot(phase = ConfigPhase.RUN_TIME) +public interface NatsConfiguration { + + /** + * A comma-separated list of URI's nats://{host}:{port} to use for establishing the initial connection to the NATS cluster. + */ + String servers(); + + /** + * The username to connect to the NATS server + */ + Optional username(); + + /** + * The password to connect to the NATS server + */ + Optional password(); + + /** + * Enable SSL connecting to servers + */ + @WithDefault("true") + Boolean sslEnabled(); + + /** + * The maximum number of reconnect attempts + */ + Optional maxReconnects(); + + /** + * The connection timeout in milliseconds + */ + Optional connectionTimeout(); + + /** + * The classname for the error listener + */ + Optional errorListener(); + + /** + * "The size in bytes to make buffers for connections" + */ + Optional bufferSize(); + +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamBuildConfiguration.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamBuildConfiguration.java new file mode 100644 index 00000000..f288cf33 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamBuildConfiguration.java @@ -0,0 +1,36 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigMapping(prefix = "quarkus.reactive-messaging.nats.jet-stream") +@ConfigRoot(phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) +public interface JetStreamBuildConfiguration { + + /** + * Autoconfigure stream and subjects based on channel configuration + */ + @WithDefault("true") + Boolean autoConfigure(); + + /** + * The number of replicas a message must be stored. Default value is 1. + */ + @WithDefault("1") + Integer replicas(); + + /** + * The storage type for stream data (File or Memory). + */ + @WithDefault("File") + String storageType(); + + + /** + * Declares the retention policy for the stream. @see Retention Policy + */ + @WithDefault("Interest") + String retentionPolicy(); +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamConnector.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamConnector.java new file mode 100644 index 00000000..a16813ab --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamConnector.java @@ -0,0 +1,122 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream; + +import static io.smallrye.reactive.messaging.annotations.ConnectorAttribute.Direction.INCOMING_AND_OUTGOING; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Flow; + +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 io.quarkiverse.reactive.messsaging.nats.NatsConfiguration; +import io.quarkiverse.reactive.messsaging.nats.jetstream.client.ConnectionConfiguration; +import io.quarkiverse.reactive.messsaging.nats.jetstream.client.JetStreamClient; +import io.quarkiverse.reactive.messsaging.nats.jetstream.mapper.PayloadMapper; +import io.quarkiverse.reactive.messsaging.nats.jetstream.processors.MessageProcessor; +import io.quarkiverse.reactive.messsaging.nats.jetstream.processors.publisher.MessagePublisherConfiguration; +import io.quarkiverse.reactive.messsaging.nats.jetstream.processors.publisher.MessagePublisherProcessor; +import io.quarkiverse.reactive.messsaging.nats.jetstream.processors.subscriber.MessageSubscriberConfiguration; +import io.quarkiverse.reactive.messsaging.nats.jetstream.processors.subscriber.MessageSubscriberProcessor; +import io.quarkiverse.reactive.messsaging.nats.jetstream.tracing.JetStreamInstrumenter; +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(JetStreamConnector.CONNECTOR_NAME) + +// Publish and subscriber processor attributes +@ConnectorAttribute(name = "stream", description = "The stream to subscribe or publish messages to", direction = INCOMING_AND_OUTGOING, type = "String") +@ConnectorAttribute(name = "subject", description = "The subject to subscribe or publish messages to", direction = INCOMING_AND_OUTGOING, type = "String") +@ConnectorAttribute(name = "trace-enabled", description = "Enable traces for publisher or subscriber", direction = INCOMING_AND_OUTGOING, type = "Boolean", defaultValue = "true") +@ConnectorAttribute(name = "auto-configure", description = "Auto configure subject on NATS", direction = INCOMING_AND_OUTGOING, type = "Boolean", defaultValue = "true") + +// Publish processor attributes +@ConnectorAttribute(name = "ordered", description = "Flag indicating whether this subscription should be ordered", direction = ConnectorAttribute.Direction.INCOMING, type = "Boolean") +@ConnectorAttribute(name = "deliver-group", description = "The optional deliver group to join", direction = ConnectorAttribute.Direction.INCOMING, type = "String") +@ConnectorAttribute(name = "durable", description = "Sets the durable name for the consumer", direction = ConnectorAttribute.Direction.INCOMING, type = "String") +@ConnectorAttribute(name = "max-deliver", description = "The maximum number of times a specific message delivery will be attempted", direction = ConnectorAttribute.Direction.INCOMING, type = "Long", defaultValue = "1") +@ConnectorAttribute(name = "back-off", description = "The timing of re-deliveries as a comma-separated list of durations", direction = ConnectorAttribute.Direction.INCOMING, type = "String") +@ConnectorAttribute(name = "payload-type", description = "The payload type", direction = ConnectorAttribute.Direction.INCOMING, type = "String") +public class JetStreamConnector implements InboundConnector, OutboundConnector, HealthReporter { + public static final String CONNECTOR_NAME = "quarkus-jetstream"; + + private final List processors; + private final ExecutionHolder executionHolder; + private final PayloadMapper payloadMapper; + private final JetStreamInstrumenter jetStreamInstrumenter; + private final NatsConfiguration natsConfiguration; + + @Inject + public JetStreamConnector(PayloadMapper payloadMapper, + JetStreamInstrumenter jetStreamInstrumenter, + ExecutionHolder executionHolder, + NatsConfiguration natsConfiguration) { + this.payloadMapper = payloadMapper; + this.jetStreamInstrumenter = jetStreamInstrumenter; + this.processors = new CopyOnWriteArrayList<>(); + this.executionHolder = executionHolder; + this.natsConfiguration = natsConfiguration; + } + + @Override + public Flow.Publisher> getPublisher(Config config) { + final var configuration = new JetStreamConnectorIncomingConfiguration(config); + final var client = new JetStreamClient(ConnectionConfiguration.of(natsConfiguration), getVertx()); + final var processor = new MessagePublisherProcessor(client, MessagePublisherConfiguration.of(configuration), + payloadMapper, jetStreamInstrumenter); + processors.add(processor); + return processor.getPublisher(); + } + + @Override + public Flow.Subscriber> getSubscriber(Config config) { + final var configuration = new JetStreamConnectorIncomingConfiguration(config); + final var client = new JetStreamClient(ConnectionConfiguration.of(natsConfiguration), getVertx()); + final var processor = new MessageSubscriberProcessor(client, MessageSubscriberConfiguration.of(configuration), + payloadMapper, jetStreamInstrumenter); + processors.add(processor); + return processor.getSubscriber(); + } + + @Override + public HealthReport getReadiness() { + return getHealth(); + } + + @Override + public HealthReport getLiveness() { + return getHealth(); + } + + HealthReport getHealth() { + final HealthReport.HealthReportBuilder builder = HealthReport.builder(); + processors.forEach(client -> builder.add(new HealthReport.ChannelInfo( + client.getChannel(), + client.getStatus().healthy(), + client.getStatus().message()))); + return builder.build(); + } + + public void terminate( + @Observes(notifyObserver = Reception.IF_EXISTS) @Priority(50) @BeforeDestroyed(ApplicationScoped.class) Object ignored) { + this.processors.forEach(MessageProcessor::close); + } + + public Vertx getVertx() { + return executionHolder.vertx(); + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamIncomingMessage.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamIncomingMessage.java new file mode 100644 index 00000000..56e2d974 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamIncomingMessage.java @@ -0,0 +1,109 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream; + +import static io.quarkiverse.reactive.messsaging.nats.jetstream.mapper.HeaderMapper.toMessageHeaders; +import static io.smallrye.reactive.messaging.providers.locals.ContextAwareMessage.captureContextMetadata; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.eclipse.microprofile.reactive.messaging.Metadata; + +import io.nats.client.Message; +import io.smallrye.reactive.messaging.providers.helpers.VertxContext; +import io.smallrye.reactive.messaging.providers.locals.LocalContextMetadata; +import io.vertx.mutiny.core.Context; + +public class JetStreamIncomingMessage implements JetStreamMessage { + private final Message message; + private Metadata metadata; + private final JetStreamIncomingMessageMetadata incomingMetadata; + private final T payload; + private final Context context; + + public JetStreamIncomingMessage(final Message message, final T payload, Context context) { + this.message = message; + this.incomingMetadata = JetStreamIncomingMessageMetadata.create(message); + this.metadata = captureContextMetadata(incomingMetadata, new LocalContextMetadata( + io.smallrye.common.vertx.VertxContext.createNewDuplicatedContext(context.getDelegate()))); + this.payload = payload; + this.context = context; + } + + @Override + public Metadata getMetadata() { + return metadata; + } + + public String getMessageId() { + return incomingMetadata.messageId(); + } + + public byte[] getData() { + return message.getData(); + } + + public String getSubject() { + return incomingMetadata.subject(); + } + + public String getStream() { + return incomingMetadata.stream(); + } + + public Map> getHeaders() { + return toMessageHeaders(message.getHeaders()); + } + + @Override + public T getPayload() { + return payload; + } + + @Override + public Supplier> getAck() { + return this::ack; + } + + @Override + public CompletionStage ack() { + return VertxContext.runOnContext(context.getDelegate(), f -> { + message.ack(); + this.runOnMessageContext(() -> f.complete(null)); + }); + } + + @Override + public CompletionStage nack(Throwable reason, Metadata metadata) { + return VertxContext.runOnContext(context.getDelegate(), f -> { + message.nak(); + this.runOnMessageContext(() -> f.completeExceptionally(reason)); + }); + } + + @Override + public Function> getNack() { + return this::nack; + } + + @Override + public synchronized void injectMetadata(Object metadataObject) { + this.metadata = metadata.with(metadataObject); + } + + @Override + public org.eclipse.microprofile.reactive.messaging.Message addMetadata(Object metadata) { + injectMetadata(metadata); + return this; + } + + @Override + public String toString() { + return "IncomingNatsMessage{" + + "metadata=" + incomingMetadata + + ", payload=" + payload + + '}'; + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamIncomingMessageMetadata.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamIncomingMessageMetadata.java new file mode 100644 index 00000000..15b6b1cf --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamIncomingMessageMetadata.java @@ -0,0 +1,79 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream; + +import static io.nats.client.support.NatsJetStreamConstants.MSG_ID_HDR; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.nats.client.Message; +import io.nats.client.impl.Headers; +import io.smallrye.common.constraint.NotNull; + +public class JetStreamIncomingMessageMetadata { + private final String stream; + private final String subject; + private final String messageId; + private final Map> headers; + private final long deliveredCount; + + public JetStreamIncomingMessageMetadata(final String stream, + final String subject, + final String messageId, + final Map> headers, + final long deliveredCount) { + this.stream = stream; + this.subject = subject; + this.messageId = messageId; + this.headers = headers; + this.deliveredCount = deliveredCount; + } + + public String stream() { + return stream; + } + + public String subject() { + return subject; + } + + public String messageId() { + return messageId; + } + + public Map> headers() { + return headers; + } + + public long deliveredCount() { + return deliveredCount; + } + + public static JetStreamIncomingMessageMetadata create(@NotNull Message message) { + final var headers = Optional.ofNullable(message.getHeaders()); + return new JetStreamIncomingMessageMetadata( + message.metaData().getStream(), + message.getSubject(), + headers.map(h -> h.getFirst(MSG_ID_HDR)).orElse(null), + headers.map(JetStreamIncomingMessageMetadata::headers).orElseGet(HashMap::new), + message.metaData().deliveredCount()); + } + + public static Map> headers(Headers messageHeaders) { + final var headers = new HashMap>(); + messageHeaders.entrySet().forEach(entry -> headers.put(entry.getKey(), entry.getValue())); + return headers; + } + + @Override + public String toString() { + return "JetStreamIncomingMessageMetadata{" + + "stream='" + stream + '\'' + + ", subject='" + subject + '\'' + + ", messageId='" + messageId + '\'' + + ", headers=" + headers + + ", deliveredCount=" + deliveredCount + + '}'; + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamMessage.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamMessage.java new file mode 100644 index 00000000..89b4aa9a --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamMessage.java @@ -0,0 +1,17 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream; + +import java.util.List; +import java.util.Map; + +import org.eclipse.microprofile.reactive.messaging.Message; + +import io.smallrye.reactive.messaging.providers.MetadataInjectableMessage; +import io.smallrye.reactive.messaging.providers.locals.ContextAwareMessage; + +public interface JetStreamMessage extends Message, ContextAwareMessage, MetadataInjectableMessage { + + String getMessageId(); + + Map> getHeaders(); + +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamOutgoingMessageMetadata.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamOutgoingMessageMetadata.java new file mode 100644 index 00000000..32c2b1ae --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamOutgoingMessageMetadata.java @@ -0,0 +1,22 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream; + +import java.util.List; +import java.util.Map; + +public class JetStreamOutgoingMessageMetadata { + private final String messageId; + private final Map> headers; + + public JetStreamOutgoingMessageMetadata(final String messageId, final Map> headers) { + this.messageId = messageId; + this.headers = headers; + } + + public String messageId() { + return messageId; + } + + public Map> headers() { + return headers; + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamRecorder.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamRecorder.java new file mode 100644 index 00000000..b667792a --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/JetStreamRecorder.java @@ -0,0 +1,36 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream; + +import java.time.Duration; + +import org.jboss.logging.Logger; + +import io.quarkiverse.reactive.messsaging.nats.NatsConfiguration; +import io.quarkiverse.reactive.messsaging.nats.jetstream.client.ConnectionConfiguration; +import io.quarkiverse.reactive.messsaging.nats.jetstream.client.JetStreamClient; +import io.quarkiverse.reactive.messsaging.nats.jetstream.setup.JetStreamSetup; +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.annotations.Recorder; + +@Recorder +public class JetStreamRecorder { + private final static Logger logger = Logger.getLogger(JetStreamRecorder.class); + + private final JetStreamSetup jetStreamSetup; + private final RuntimeValue natsConfiguration; + private final RuntimeValue jetStreamConfiguration; + + public JetStreamRecorder(RuntimeValue natsConfiguration, + RuntimeValue jetStreamConfiguration) { + this.jetStreamSetup = new JetStreamSetup(); + this.natsConfiguration = natsConfiguration; + this.jetStreamConfiguration = jetStreamConfiguration; + } + + public void setupStreams() { + logger.info("Setup JetStream"); + try (final var jetStreamClient = new JetStreamClient(ConnectionConfiguration.of(natsConfiguration.getValue()))) { + final var connection = jetStreamClient.getOrEstablishConnection().await().atMost(Duration.ofSeconds(30)); + jetStreamSetup.setup(connection, jetStreamConfiguration.getValue()); + } + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/administration/JetStreamMessage.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/administration/JetStreamMessage.java new file mode 100644 index 00000000..d4db4e2f --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/administration/JetStreamMessage.java @@ -0,0 +1,124 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.administration; + +import static io.nats.client.support.NatsJetStreamConstants.MSG_ID_HDR; +import static io.quarkiverse.reactive.messsaging.nats.jetstream.mapper.HeaderMapper.toMessageHeaders; +import static io.smallrye.reactive.messaging.providers.locals.ContextAwareMessage.captureContextMetadata; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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.Metadata; +import org.jboss.logging.Logger; + +import io.nats.client.api.MessageInfo; +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamIncomingMessageMetadata; + +public class JetStreamMessage implements io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamMessage { + + private static final Logger logger = Logger.getLogger(JetStreamMessage.class); + + private final MessageInfo message; + private Metadata metadata; + private final JetStreamIncomingMessageMetadata incomingMetadata; + private final T payload; + + public JetStreamMessage(final MessageInfo message, final T payload) { + this.message = message; + this.incomingMetadata = create(message); + this.metadata = captureContextMetadata(incomingMetadata); + this.payload = payload; + } + + @Override + public Metadata getMetadata() { + return metadata; + } + + public String getMessageId() { + return incomingMetadata.messageId(); + } + + public byte[] getData() { + return message.getData(); + } + + public String getSubject() { + return incomingMetadata.subject(); + } + + public String getStream() { + return incomingMetadata.stream(); + } + + public Map> getHeaders() { + return toMessageHeaders(message.getHeaders()); + } + + @Override + public T getPayload() { + return payload; + } + + @Override + public Supplier> getAck() { + return this::ack; + } + + @Override + public CompletionStage ack() { + return CompletableFuture.supplyAsync(() -> { + logger.debugf("Message with id = %s acknowledged", getMessageId()); + return null; + }); + } + + @Override + public CompletionStage nack(Throwable reason, Metadata metadata) { + return CompletableFuture.supplyAsync(() -> { + logger.errorf(reason, "Message with id = %s not acknowledged", getMessageId()); + throw new RuntimeException(reason); + }); + } + + @Override + public Function> getNack() { + return this::nack; + } + + @Override + public synchronized void injectMetadata(Object metadataObject) { + this.metadata = metadata.with(metadataObject); + } + + @Override + public org.eclipse.microprofile.reactive.messaging.Message addMetadata(Object metadata) { + injectMetadata(metadata); + return this; + } + + @Override + public String toString() { + return "JetStreamMessage{" + + "message=" + message + + ", metadata=" + metadata + + ", incomingMetadata=" + incomingMetadata + + ", payload=" + payload + + '}'; + } + + private JetStreamIncomingMessageMetadata create(MessageInfo message) { + final var headers = Optional.ofNullable(message.getHeaders()); + return new JetStreamIncomingMessageMetadata( + message.getStream(), + message.getSubject(), + headers.map(h -> h.getFirst(MSG_ID_HDR)).orElse(null), + headers.map(JetStreamIncomingMessageMetadata::headers).orElseGet(HashMap::new), + 0); + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/administration/MessageResolver.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/administration/MessageResolver.java new file mode 100644 index 00000000..e94a132d --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/administration/MessageResolver.java @@ -0,0 +1,57 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.administration; + +import java.io.IOException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.eclipse.microprofile.reactive.messaging.Message; +import org.jboss.logging.Logger; + +import io.nats.client.JetStreamApiException; +import io.quarkiverse.reactive.messsaging.nats.NatsConfiguration; +import io.quarkiverse.reactive.messsaging.nats.jetstream.client.Connection; +import io.quarkiverse.reactive.messsaging.nats.jetstream.client.ConnectionConfiguration; +import io.quarkiverse.reactive.messsaging.nats.jetstream.client.JetStreamClient; +import io.quarkiverse.reactive.messsaging.nats.jetstream.mapper.PayloadMapper; +import io.smallrye.mutiny.Uni; +import io.smallrye.reactive.messaging.providers.connectors.ExecutionHolder; + +@ApplicationScoped +public class MessageResolver { + private static final Logger logger = Logger.getLogger(MessageResolver.class); + private final NatsConfiguration configuration; + private final PayloadMapper payloadMapper; + private final ExecutionHolder executionHolder; + + @Inject + public MessageResolver(NatsConfiguration configuration, PayloadMapper payloadMapper, ExecutionHolder executionHolder) { + this.configuration = configuration; + this.payloadMapper = payloadMapper; + this.executionHolder = executionHolder; + } + + public Uni> resolve(String streamName, long sequence) { + final var client = new JetStreamClient(ConnectionConfiguration.of(configuration), executionHolder.vertx()); + return client.getOrEstablishConnection() + .onItem().> transformToUni(connection -> resolve(connection, streamName, sequence)) + .onItem().invoke(m -> client.close()) + .onFailure().invoke(throwable -> { + logger.errorf(throwable, "Failed to resolve message: %s", throwable.getMessage()); + client.close(); + }); + } + + private Uni> resolve(Connection connection, String streamName, long sequence) { + return Uni.createFrom().emitter(emitter -> { + try { + final var jetStream = connection.jetStream(); + final var streamContext = jetStream.getStreamContext(streamName); + final var messageInfo = streamContext.getMessage(sequence); + emitter.complete(new JetStreamMessage(messageInfo, payloadMapper. toPayload(messageInfo).orElse(null))); + } catch (IOException | JetStreamApiException e) { + emitter.fail(e); + } + }); + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/Connection.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/Connection.java new file mode 100644 index 00000000..c3ebda43 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/Connection.java @@ -0,0 +1,61 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.client; + +import static io.nats.client.Connection.Status.CONNECTED; + +import java.io.IOException; + +import org.jboss.logging.Logger; + +import io.nats.client.Dispatcher; +import io.nats.client.JetStream; +import io.nats.client.JetStreamManagement; +import io.vertx.mutiny.core.Context; + +public class Connection implements AutoCloseable { + private final static Logger logger = Logger.getLogger(Connection.class); + + private final io.nats.client.Connection connection; + private final Context context; + + public Connection(final io.nats.client.Connection connection, final Context context) { + this.connection = connection; + this.context = context; + } + + public io.nats.client.Connection connection() { + return connection; + } + + public Context context() { + return context; + } + + public JetStream jetStream() throws IOException { + return connection.jetStream(); + } + + public JetStreamManagement jetStreamManagement() throws IOException { + return connection.jetStreamManagement(); + } + + public Dispatcher createDispatcher() { + return connection.createDispatcher(); + } + + public io.nats.client.Connection.Status getStatus() { + return connection.getStatus(); + } + + public boolean isConnected() { + return CONNECTED.equals(getStatus()); + } + + @Override + public void close() { + try { + connection.close(); + } catch (InterruptedException e) { + logger.warn(e.getMessage(), e); + } + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/ConnectionConfiguration.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/ConnectionConfiguration.java new file mode 100644 index 00000000..a28b216c --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/ConnectionConfiguration.java @@ -0,0 +1,29 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.client; + +import java.util.Optional; + +import io.nats.client.ErrorListener; +import io.quarkiverse.reactive.messsaging.nats.NatsConfiguration; + +public interface ConnectionConfiguration { + + String getServers(); + + Optional getPassword(); + + Optional getUsername(); + + Optional getMaxReconnects(); + + boolean sslEnabled(); + + Optional getBufferSize(); + + Optional getErrorListener(); + + Optional getConnectionTimeout(); + + static ConnectionConfiguration of(NatsConfiguration configuration) { + return new DefaultConnectionConfiguration(configuration); + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/ConnectionException.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/ConnectionException.java new file mode 100644 index 00000000..d95189f1 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/ConnectionException.java @@ -0,0 +1,9 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.client; + +public class ConnectionException extends RuntimeException { + + public ConnectionException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/DefaultConnectionConfiguration.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/DefaultConnectionConfiguration.java new file mode 100644 index 00000000..2c01c71d --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/DefaultConnectionConfiguration.java @@ -0,0 +1,66 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.client; + +import java.lang.reflect.InvocationTargetException; +import java.util.Optional; + +import io.nats.client.ErrorListener; +import io.quarkiverse.reactive.messsaging.nats.NatsConfiguration; + +class DefaultConnectionConfiguration implements ConnectionConfiguration { + private final NatsConfiguration configuration; + + DefaultConnectionConfiguration(NatsConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public String getServers() { + return configuration.servers(); + } + + @Override + public Optional getPassword() { + return configuration.password(); + } + + @Override + public Optional getUsername() { + return configuration.username(); + } + + @Override + public Optional getMaxReconnects() { + return configuration.maxReconnects(); + } + + @Override + public boolean sslEnabled() { + return configuration.sslEnabled(); + } + + @Override + public Optional getBufferSize() { + return configuration.bufferSize(); + } + + @Override + public Optional getErrorListener() { + return configuration.errorListener().map(this::getInstanceOfErrorListener); + } + + @Override + public Optional getConnectionTimeout() { + return configuration.connectionTimeout(); + } + + private ErrorListener getInstanceOfErrorListener(String className) { + try { + var clazz = Class.forName(className); + var constructor = clazz.getConstructor(); + return (ErrorListener) constructor.newInstance(); + } catch (ClassNotFoundException | NoSuchMethodException | InstantiationException | IllegalAccessException + | InvocationTargetException e) { + throw new RuntimeException("Not able to create instance of error listener", e); + } + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/DefaultErrorListener.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/DefaultErrorListener.java new file mode 100644 index 00000000..7745c9f5 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/DefaultErrorListener.java @@ -0,0 +1,48 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.client; + +import org.jboss.logging.Logger; + +import io.nats.client.Consumer; +import io.nats.client.ErrorListener; +import io.nats.client.JetStreamSubscription; +import io.nats.client.Message; +import io.nats.client.support.Status; + +public class DefaultErrorListener implements ErrorListener { + private final static Logger logger = Logger.getLogger(DefaultErrorListener.class); + + @Override + public void errorOccurred(io.nats.client.Connection conn, String error) { + logger.warnf("Error occurred: %s", error); + } + + @Override + public void exceptionOccurred(io.nats.client.Connection conn, Exception exp) { + logger.warnf("Caught exception connecting to %s with message: %s", conn.getServers(), exp.getMessage()); + } + + @Override + public void slowConsumerDetected(io.nats.client.Connection conn, Consumer consumer) { + logger.warn("Slow consumer detected"); + } + + @Override + public void messageDiscarded(io.nats.client.Connection conn, Message msg) { + logger.warnf("Message with id = %s discarded", msg.getSID()); + } + + @Override + public void unhandledStatus(io.nats.client.Connection conn, JetStreamSubscription sub, Status status) { + logger.warnf("Unhandled status: %s", status); + } + + @Override + public void pullStatusWarning(io.nats.client.Connection conn, JetStreamSubscription sub, Status status) { + logger.warnf("Pull status warning with status: %s", status); + } + + @Override + public void pullStatusError(io.nats.client.Connection conn, JetStreamSubscription sub, Status status) { + logger.errorf("Pull status error with status: %s", status); + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/JetStreamClient.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/JetStreamClient.java new file mode 100644 index 00000000..03a32646 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/client/JetStreamClient.java @@ -0,0 +1,114 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.client; + +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import io.nats.client.ConnectionListener; +import io.nats.client.ErrorListener; +import io.nats.client.Nats; +import io.nats.client.Options; +import io.smallrye.mutiny.Uni; +import io.vertx.mutiny.core.Context; +import io.vertx.mutiny.core.Vertx; + +public class JetStreamClient implements AutoCloseable { + private final ConnectionConfiguration configuration; + private final Vertx vertx; + private final AtomicReference connection; + + public JetStreamClient(ConnectionConfiguration configuration, Vertx vertx) { + this.vertx = vertx; + this.configuration = configuration; + this.connection = new AtomicReference<>(); + } + + public JetStreamClient(ConnectionConfiguration configuration) { + this(configuration, null); + } + + public Uni getOrEstablishConnection() { + return Uni.createFrom().item(() -> Optional.ofNullable(connection.get()) + .filter(this::isConnected) + .orElse(null)) + .onItem().ifNull().switchTo(this::connect) + .onItem().invoke(this.connection::set); + } + + public Optional getConnection() { + return Optional.ofNullable(connection.get()); + } + + public Optional getVertx() { + return Optional.ofNullable(vertx); + } + + private Uni connect() { + return getVertx().map(v -> connectWithContext(v.getOrCreateContext())).orElseGet(this::connectWithoutContext); + } + + private Uni connectWithContext(Context context) { + return Uni.createFrom(). emitter(em -> { + try { + final var options = createConnectionOptions(configuration, (connection, type) -> { + if (ConnectionListener.Events.CONNECTED.equals(type)) { + em.complete(new Connection(connection, context)); + } + }); + Nats.connectAsynchronously(options, true); + } catch (InterruptedException | NoSuchAlgorithmException e) { + em.fail(e); + } + }).emitOn(context::runOnContext); + } + + private Uni connectWithoutContext() { + return Uni.createFrom().emitter(em -> { + try { + final var options = createConnectionOptions(configuration, (connection, type) -> { + if (ConnectionListener.Events.CONNECTED.equals(type)) { + em.complete(new Connection(connection, null)); + } + }); + Nats.connectAsynchronously(options, true); + } catch (InterruptedException | NoSuchAlgorithmException e) { + em.fail(e); + } + }); + } + + private boolean isConnected(Connection connection) { + return connection != null && connection.isConnected(); + } + + private Options createConnectionOptions(ConnectionConfiguration configuration, ConnectionListener connectionListener) + throws NoSuchAlgorithmException { + final var servers = configuration.getServers().split(","); + final var optionsBuilder = new Options.Builder(); + optionsBuilder.servers(servers); + optionsBuilder.maxReconnects(configuration.getMaxReconnects().orElse(-1)); + optionsBuilder.connectionListener(connectionListener); + optionsBuilder.errorListener(getErrorListener(configuration)); + optionsBuilder.userInfo(configuration.getUsername().orElse(null), configuration.getPassword().orElse(null)); + configuration.getBufferSize().ifPresent(optionsBuilder::bufferSize); + configuration.getConnectionTimeout() + .ifPresent(connectionTimeout -> optionsBuilder.connectionTimeout(Duration.ofMillis(connectionTimeout))); + if (configuration.sslEnabled()) { + optionsBuilder.opentls(); + } + return optionsBuilder.build(); + } + + private ErrorListener getErrorListener(ConnectionConfiguration configuration) { + return configuration.getErrorListener() + .orElseGet(DefaultErrorListener::new); + } + + @Override + public void close() { + if (connection.get() != null) { + connection.get().close(); + } + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/graal/Target_EdDSAEngine.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/graal/Target_EdDSAEngine.java new file mode 100644 index 00000000..ec71f73e --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/graal/Target_EdDSAEngine.java @@ -0,0 +1,74 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.graal; + +import java.io.ByteArrayOutputStream; +import java.security.*; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; + +import com.oracle.svm.core.annotate.Alias; +import com.oracle.svm.core.annotate.Substitute; +import com.oracle.svm.core.annotate.TargetClass; + +import net.i2p.crypto.eddsa.EdDSAEngine; +import net.i2p.crypto.eddsa.EdDSAKey; +import net.i2p.crypto.eddsa.EdDSAPublicKey; + +@TargetClass(EdDSAEngine.class) +public final class Target_EdDSAEngine { + + @Alias + private MessageDigest digest; + + @Alias + private ByteArrayOutputStream baos; + + @Alias + private EdDSAKey key; + + @Alias + private boolean oneShotMode; + + @Alias + private byte[] oneShotBytes; + + @Substitute + private void reset() { + if (digest != null) + digest.reset(); + if (baos != null) + baos.reset(); + oneShotMode = false; + oneShotBytes = null; + } + + @Substitute + protected void engineInitVerify(PublicKey publicKey) throws InvalidKeyException { + reset(); + if (publicKey instanceof EdDSAPublicKey) { + key = (EdDSAPublicKey) publicKey; + + if (digest == null) { + // Instantiate the digest from the key parameters + try { + digest = MessageDigest.getInstance(key.getParams().getHashAlgorithm()); + } catch (NoSuchAlgorithmException e) { + throw new InvalidKeyException( + "cannot get required digest " + key.getParams().getHashAlgorithm() + " for private key."); + } + } else if (!key.getParams().getHashAlgorithm().equals(digest.getAlgorithm())) + throw new InvalidKeyException("Key hash algorithm does not match chosen digest"); + } else if (publicKey.getFormat().equals("X.509")) { + // X509Certificate will sometimes contain an X509Key rather than the EdDSAPublicKey itself; the contained + // key is valid but needs to be instanced as an EdDSAPublicKey before it can be used. + EdDSAPublicKey parsedPublicKey; + try { + parsedPublicKey = new EdDSAPublicKey(new X509EncodedKeySpec(publicKey.getEncoded())); + } catch (InvalidKeySpecException ex) { + throw new InvalidKeyException("cannot handle X.509 EdDSA public key: " + publicKey.getAlgorithm()); + } + engineInitVerify(parsedPublicKey); + } else { + throw new InvalidKeyException("cannot identify EdDSA public key: " + publicKey.getClass()); + } + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/mapper/HeaderMapper.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/mapper/HeaderMapper.java new file mode 100644 index 00000000..c160881c --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/mapper/HeaderMapper.java @@ -0,0 +1,25 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.mapper; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.nats.client.impl.Headers; + +public class HeaderMapper { + + public static Map> toMessageHeaders(Headers headers) { + final var result = new HashMap>(); + if (headers != null) { + headers.entrySet().forEach(entry -> result.put(entry.getKey(), entry.getValue())); + } + return result; + } + + public static Headers toJetStreamHeaders(Map> headers) { + final var result = new Headers(); + headers.forEach(result::add); + return result; + } + +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/mapper/PayloadMapper.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/mapper/PayloadMapper.java new file mode 100644 index 00000000..954dde40 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/mapper/PayloadMapper.java @@ -0,0 +1,88 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.mapper; + +import java.io.IOException; +import java.util.Optional; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.jboss.logging.Logger; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.nats.client.api.MessageInfo; + +@ApplicationScoped +public class PayloadMapper { + private final static Logger logger = Logger.getLogger(PayloadMapper.class); + + public static final String MESSAGE_TYPE_HEADER = "message.type"; + + private final ObjectMapper objectMapper; + + @Inject + public PayloadMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * Returns a byte array containing the supplied payload. + * + * @param payload the payload + * @return a byte array encapsulation of the payload + */ + public byte[] toByteArray(final Object payload) { + try { + if (payload == null) { + return new byte[0]; + } else if (payload instanceof byte[]) { + final var byteArray = (byte[]) payload; + return byteArray; + } else { + return objectMapper.writeValueAsBytes(payload); + } + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public Optional toPayload(io.nats.client.Message message) { + return Optional.ofNullable(message).flatMap(m -> Optional.ofNullable(m.getHeaders())) + .flatMap(headers -> Optional.ofNullable(headers.getFirst(MESSAGE_TYPE_HEADER))) + .map(this::loadClass) + .map(type -> decode(message.getData(), type)); + } + + @SuppressWarnings("unchecked") + public Optional toPayload(MessageInfo message) { + logger.infof("Getting payload from message info: %s", message); + return Optional.ofNullable(message).flatMap(m -> Optional.ofNullable(m.getHeaders())) + .flatMap(headers -> Optional.ofNullable(headers.getFirst(MESSAGE_TYPE_HEADER))) + .map(this::loadClass) + .map(type -> (T) decode(message.getData(), type)); + } + + public T toPayload(io.nats.client.Message message, String type) { + Class payLoadType = loadClass(type); + return decode(message.getData(), payLoadType); + } + + private T decode(byte[] data, Class type) { + try { + return objectMapper.readValue(data, type); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @SuppressWarnings("unchecked") + private Class loadClass(String type) { + try { + final ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + return (Class) classLoader.loadClass(type); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/MessageProcessor.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/MessageProcessor.java new file mode 100644 index 00000000..09c67110 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/MessageProcessor.java @@ -0,0 +1,11 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.processors; + +public interface MessageProcessor { + + String getChannel(); + + Status getStatus(); + + void close(); + +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/Status.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/Status.java new file mode 100644 index 00000000..328757b2 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/Status.java @@ -0,0 +1,19 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.processors; + +public class Status { + private final boolean healthy; + private final String message; + + public Status(boolean healthy, String message) { + this.healthy = healthy; + this.message = message; + } + + public boolean healthy() { + return healthy; + } + + public String message() { + return message; + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/publisher/DefaultMessagePublisherConfiguration.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/publisher/DefaultMessagePublisherConfiguration.java new file mode 100644 index 00000000..8eb5a7ca --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/publisher/DefaultMessagePublisherConfiguration.java @@ -0,0 +1,54 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.processors.publisher; + +import java.util.Optional; + +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamConnectorIncomingConfiguration; + +public class DefaultMessagePublisherConfiguration implements MessagePublisherConfiguration { + private final JetStreamConnectorIncomingConfiguration configuration; + + public DefaultMessagePublisherConfiguration(final JetStreamConnectorIncomingConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public String getChannel() { + return configuration.getChannel(); + } + + @Override + public String getSubject() { + return configuration.getSubject() + .orElseThrow(() -> new RuntimeException(String.format("Subject not configured for channel: %s", getChannel()))); + } + + @Override + public Optional getDeliverGroup() { + return configuration.getDeliverGroup(); + } + + @Override + public Optional getDurable() { + return configuration.getDurable(); + } + + @Override + public Long getMaxDeliver() { + return configuration.getMaxDeliver(); + } + + @Override + public Optional getBackOff() { + return configuration.getBackOff(); + } + + @Override + public boolean traceEnabled() { + return configuration.getTraceEnabled(); + } + + @Override + public Optional getType() { + return configuration.getPayloadType(); + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/publisher/MessagePublisherConfiguration.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/publisher/MessagePublisherConfiguration.java new file mode 100644 index 00000000..bed00eef --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/publisher/MessagePublisherConfiguration.java @@ -0,0 +1,28 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.processors.publisher; + +import java.util.Optional; + +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamConnectorIncomingConfiguration; + +public interface MessagePublisherConfiguration { + + String getChannel(); + + String getSubject(); + + Optional getDeliverGroup(); + + Optional getDurable(); + + Long getMaxDeliver(); + + Optional getBackOff(); + + boolean traceEnabled(); + + Optional getType(); + + static MessagePublisherConfiguration of(JetStreamConnectorIncomingConfiguration configuration) { + return new DefaultMessagePublisherConfiguration(configuration); + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/publisher/MessagePublisherProcessor.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/publisher/MessagePublisherProcessor.java new file mode 100644 index 00000000..7bae90f9 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/publisher/MessagePublisherProcessor.java @@ -0,0 +1,171 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.processors.publisher; + +import static io.smallrye.reactive.messaging.tracing.TracingUtils.traceIncoming; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import org.eclipse.microprofile.reactive.messaging.Message; +import org.jboss.logging.Logger; + +import io.nats.client.JetStreamApiException; +import io.nats.client.PushSubscribeOptions; +import io.nats.client.api.ConsumerConfiguration; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamIncomingMessage; +import io.quarkiverse.reactive.messsaging.nats.jetstream.client.Connection; +import io.quarkiverse.reactive.messsaging.nats.jetstream.client.JetStreamClient; +import io.quarkiverse.reactive.messsaging.nats.jetstream.mapper.PayloadMapper; +import io.quarkiverse.reactive.messsaging.nats.jetstream.processors.MessageProcessor; +import io.quarkiverse.reactive.messsaging.nats.jetstream.processors.Status; +import io.quarkiverse.reactive.messsaging.nats.jetstream.tracing.JetStreamInstrumenter; +import io.quarkiverse.reactive.messsaging.nats.jetstream.tracing.JetStreamTrace; +import io.smallrye.mutiny.Multi; +import io.vertx.mutiny.core.Context; + +public class MessagePublisherProcessor implements MessageProcessor { + private final static Logger logger = Logger.getLogger(MessagePublisherProcessor.class); + + final static int CONSUMER_ALREADY_IN_USE = 10013; + + private final MessagePublisherConfiguration configuration; + private final JetStreamClient jetStreamClient; + private final PayloadMapper payloadMapper; + private final Instrumenter instrumenter; + private final AtomicReference status; + + public MessagePublisherProcessor(final JetStreamClient jetStreamClient, + final MessagePublisherConfiguration configuration, + final PayloadMapper payloadMapper, + final JetStreamInstrumenter jetStreamInstrumenter) { + this.configuration = configuration; + this.jetStreamClient = jetStreamClient; + this.payloadMapper = payloadMapper; + this.instrumenter = jetStreamInstrumenter.receiver(); + this.status = new AtomicReference<>(new Status(false, "Not connected")); + } + + @Override + public Status getStatus() { + return status.get(); + } + + @Override + public void close() { + jetStreamClient.close(); + } + + @Override + public String getChannel() { + return configuration.getChannel(); + } + + public Multi> getPublisher() { + return jetStreamClient.getOrEstablishConnection() + .onItem().transformToMulti(this::publish) + .onFailure().invoke(throwable -> { + if (!isConsumerAlreadyInUse(throwable)) { + logger.errorf(throwable, "Publish failure: %s", throwable.getMessage()); + } + close(); + }) + .onFailure().retry().withBackOff(Duration.ofSeconds(30)).indefinitely() + .onCompletion().invoke(this::close); + } + + public Multi> publish(Connection connection) { + return Multi.createFrom().deferred( + () -> Multi.createFrom().> emitter(emitter -> { + try { + final var jetStream = connection.jetStream(); + final var subject = configuration.getSubject(); + final var dispatcher = connection.createDispatcher(); + final var pushOptions = createPushSubscribeOptions(configuration); + jetStream.subscribe(subject, dispatcher, + message -> emitter.emit(create(configuration, message, connection.context())), false, + pushOptions); + setStatus(true, "Is connected"); + } catch (JetStreamApiException e) { + if (CONSUMER_ALREADY_IN_USE == e.getApiErrorCode()) { + setStatus(true, "Consumer already in use"); + emitter.fail(e); + } else { + logger.errorf(e, "Failed subscribing to stream with message: %s", e.getMessage()); + setStatus(false, e.getMessage()); + emitter.fail(e); + } + } catch (Throwable e) { + logger.errorf(e, "Failed subscribing to stream with message: %s", e.getMessage()); + setStatus(false, e.getMessage()); + emitter.fail(e); + } + })).emitOn(runnable -> connection.context().runOnContext(runnable)); + } + + private void setStatus(boolean healthy, String message) { + this.status.set(new Status(healthy, message)); + } + + private org.eclipse.microprofile.reactive.messaging.Message create(MessagePublisherConfiguration configuration, + io.nats.client.Message message, Context context) { + final var incomingMessage = configuration.getType() + .map(type -> new JetStreamIncomingMessage<>(message, payloadMapper.toPayload(message, type), context)) + .orElseGet( + () -> new JetStreamIncomingMessage<>(message, payloadMapper.toPayload(message).orElse(null), context)); + if (configuration.traceEnabled()) { + return traceIncoming(instrumenter, incomingMessage, JetStreamTrace.trace(incomingMessage)); + } else { + return incomingMessage; + } + } + + private boolean isConsumerAlreadyInUse(Throwable throwable) { + if (throwable instanceof JetStreamApiException) { + final var jetStreamApiException = (JetStreamApiException) throwable; + return jetStreamApiException.getApiErrorCode() == CONSUMER_ALREADY_IN_USE; + } + return false; + } + + private PushSubscribeOptions createPushSubscribeOptions(final MessagePublisherConfiguration configuration) { + final var deliverGroup = configuration.getDeliverGroup().orElse(null); + final var durable = configuration.getDurable().orElse(null); + final var backoff = getBackOff(configuration).orElse(null); + final var maxDeliver = configuration.getMaxDeliver(); + return createPushSubscribeOptions(durable, deliverGroup, backoff, maxDeliver); + } + + static PushSubscribeOptions createPushSubscribeOptions(final String durable, final String deliverGroup, String[] backoff, + Long maxDeliever) { + return PushSubscribeOptions.builder() + .deliverGroup(deliverGroup) + .durable(durable) + .configuration( + ConsumerConfiguration.builder() + .maxDeliver(maxDeliever) + .backoff(getBackOff(backoff).orElse(null)) + .build()) + .build(); + } + + private Optional getBackOff(final MessagePublisherConfiguration configuration) { + return configuration.getBackOff().map(backoff -> backoff.split(",")); + } + + private static Optional getBackOff(String[] backoff) { + if (backoff == null || backoff.length == 0) { + return Optional.empty(); + } else { + return Optional.of(Arrays.stream(backoff).map(MessagePublisherProcessor::toDuration).collect(Collectors.toList()) + .toArray(new Duration[] {})); + } + } + + private static Duration toDuration(String value) { + return Duration.parse(value); + } + +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/subscriber/DefaultMessageSubscriberConfiguration.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/subscriber/DefaultMessageSubscriberConfiguration.java new file mode 100644 index 00000000..b02479b5 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/subscriber/DefaultMessageSubscriberConfiguration.java @@ -0,0 +1,33 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.processors.subscriber; + +import java.util.Optional; + +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamConnectorIncomingConfiguration; + +public class DefaultMessageSubscriberConfiguration implements MessageSubscriberConfiguration { + private final JetStreamConnectorIncomingConfiguration configuration; + + public DefaultMessageSubscriberConfiguration(JetStreamConnectorIncomingConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public String getChannel() { + return configuration.getChannel(); + } + + @Override + public Optional getStream() { + return configuration.getStream(); + } + + @Override + public Optional getSubject() { + return configuration.getSubject(); + } + + @Override + public boolean traceEnabled() { + return configuration.getTraceEnabled(); + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/subscriber/MessageSubscriberConfiguration.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/subscriber/MessageSubscriberConfiguration.java new file mode 100644 index 00000000..5e65a588 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/subscriber/MessageSubscriberConfiguration.java @@ -0,0 +1,20 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.processors.subscriber; + +import java.util.Optional; + +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamConnectorIncomingConfiguration; + +public interface MessageSubscriberConfiguration { + + String getChannel(); + + Optional getStream(); + + Optional getSubject(); + + boolean traceEnabled(); + + static MessageSubscriberConfiguration of(JetStreamConnectorIncomingConfiguration configuration) { + return new DefaultMessageSubscriberConfiguration(configuration); + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/subscriber/MessageSubscriberProcessor.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/subscriber/MessageSubscriberProcessor.java new file mode 100644 index 00000000..e13e7f1e --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/subscriber/MessageSubscriberProcessor.java @@ -0,0 +1,148 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.processors.subscriber; + +import static io.quarkiverse.reactive.messsaging.nats.jetstream.mapper.HeaderMapper.toJetStreamHeaders; +import static io.quarkiverse.reactive.messsaging.nats.jetstream.mapper.PayloadMapper.MESSAGE_TYPE_HEADER; +import static io.smallrye.reactive.messaging.tracing.TracingUtils.traceOutgoing; + +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Flow; + +import org.eclipse.microprofile.reactive.messaging.Message; +import org.jboss.logging.Logger; + +import io.nats.client.PublishOptions; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamOutgoingMessageMetadata; +import io.quarkiverse.reactive.messsaging.nats.jetstream.client.Connection; +import io.quarkiverse.reactive.messsaging.nats.jetstream.client.JetStreamClient; +import io.quarkiverse.reactive.messsaging.nats.jetstream.mapper.PayloadMapper; +import io.quarkiverse.reactive.messsaging.nats.jetstream.processors.MessageProcessor; +import io.quarkiverse.reactive.messsaging.nats.jetstream.processors.Status; +import io.quarkiverse.reactive.messsaging.nats.jetstream.tracing.JetStreamInstrumenter; +import io.quarkiverse.reactive.messsaging.nats.jetstream.tracing.JetStreamTrace; +import io.smallrye.mutiny.Uni; +import io.smallrye.reactive.messaging.providers.helpers.MultiUtils; + +public class MessageSubscriberProcessor implements MessageProcessor { + private final static Logger logger = Logger.getLogger(MessageSubscriberProcessor.class); + + private final MessageSubscriberConfiguration configuration; + private final JetStreamClient jetStreamClient; + private final PayloadMapper payloadMapper; + private final Instrumenter instrumenter; + private final String streamName; + private final String subject; + + public MessageSubscriberProcessor(final JetStreamClient jetStreamClient, + final MessageSubscriberConfiguration configuration, + final PayloadMapper payloadMapper, + final JetStreamInstrumenter jetStreamInstrumenter) { + this.jetStreamClient = jetStreamClient; + this.configuration = configuration; + this.payloadMapper = payloadMapper; + this.instrumenter = jetStreamInstrumenter.publisher(); + this.streamName = getStreamName(configuration); + this.subject = getSubject(configuration); + } + + public Flow.Subscriber> getSubscriber() { + return MultiUtils.via(m -> m.onSubscription() + .call(this::getOrEstablishConnection) + .onItem() + .transformToUniAndConcatenate(this::send) + .onCompletion().invoke(this::close) + .onFailure().invoke(throwable -> { + logger.errorf(throwable, "Failed to publish: %s", throwable.getMessage()); + close(); + })); + } + + public Message publish(final Connection connection, final Message message) { + try { + final var metadata = message.getMetadata(JetStreamOutgoingMessageMetadata.class); + final var messageId = metadata.map(JetStreamOutgoingMessageMetadata::messageId) + .orElseGet(() -> UUID.randomUUID().toString()); + final var payload = payloadMapper.toByteArray(message.getPayload()); + + final var headers = new HashMap>(); + metadata.ifPresent(m -> headers.putAll(m.headers())); + headers.putIfAbsent(MESSAGE_TYPE_HEADER, List.of(message.getPayload().getClass().getTypeName())); + + if (configuration.traceEnabled()) { + // Create a new span for the outbound message and record updated tracing information in + // the headers; this has to be done before we build the properties below + traceOutgoing(instrumenter, message, + new JetStreamTrace(streamName, subject, messageId, headers, new String(payload))); + } + + final var jetStream = connection.jetStream(); + final var options = createPublishOptions(messageId, streamName); + jetStream.publish(subject, toJetStreamHeaders(headers), payload, options); + + return message; + } catch (Exception e) { + logger.errorf(e, "Failed to publish message: %s", e.getMessage()); + throw new RuntimeException(e); + } + } + + /** + * Connections are made only on first message dispatch for subscribers. To avoid health is reporting not ok + * the method returns true if connection is not established. + */ + @Override + public Status getStatus() { + return jetStreamClient.getConnection().map(c -> new Status(c.isConnected(), "Is connected")) + .orElseGet(() -> new Status(true, "Not connected")); + } + + @Override + public void close() { + jetStreamClient.close(); + } + + @Override + public String getChannel() { + return configuration.getChannel(); + } + + private Uni> send(Message message) { + return getOrEstablishConnection() + .onItem() + .transformToUni(connection -> send(message, connection)); + } + + private Uni> send(Message message, Connection connection) { + return Uni.createFrom().> emitter(em -> { + try { + em.complete(publish(connection, message)); + } catch (Throwable e) { + logger.errorf(e, "Failed sending message: %s", e.getMessage()); + em.fail(e); + } + }).emitOn(runnable -> connection.context().runOnContext(runnable)); + } + + private Uni getOrEstablishConnection() { + return jetStreamClient.getOrEstablishConnection(); + } + + private String getStreamName(final MessageSubscriberConfiguration configuration) { + return configuration.getStream() + .orElseThrow(() -> new RuntimeException("Stream not configured for channel = " + configuration.getChannel())); + } + + private String getSubject(final MessageSubscriberConfiguration configuration) { + return configuration.getSubject() + .orElseThrow(() -> new RuntimeException("Subject not configured for channel = " + configuration.getChannel())); + } + + private PublishOptions createPublishOptions(final String messageId, String streamName) { + return PublishOptions.builder() + .messageId(messageId) + .stream(streamName) + .build(); + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/setup/JetStreamSetup.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/setup/JetStreamSetup.java new file mode 100644 index 00000000..3079a230 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/setup/JetStreamSetup.java @@ -0,0 +1,140 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.setup; + +import static io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamConnector.CONNECTOR_NAME; + +import java.io.IOException; +import java.util.*; + +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; +import org.jboss.logging.Logger; + +import io.nats.client.JetStreamApiException; +import io.nats.client.JetStreamManagement; +import io.nats.client.api.*; +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamBuildConfiguration; +import io.quarkiverse.reactive.messsaging.nats.jetstream.client.Connection; +import io.quarkiverse.reactive.messsaging.nats.jetstream.client.ConnectionException; + +public class JetStreamSetup { + private static final Logger logger = Logger.getLogger(JetStreamSetup.class); + + public void setup(Connection connection, + JetStreamBuildConfiguration jetStreamConfiguration) { + try { + final var jsm = connection.jetStreamManagement(); + getStreams() + .stream() + .filter(streamConfig -> !streamConfig.subjects().isEmpty()) + .forEach(streamConfig -> getStreamConfiguration(jsm, streamConfig) + .ifPresentOrElse( + streamConfiguration -> updateStreamConfiguration(jsm, streamConfiguration, streamConfig, + jetStreamConfiguration), + () -> createStreamConfiguration(jsm, streamConfig, jetStreamConfiguration))); + } catch (Exception e) { + // Either not allowed or stream already configured by another instance + throw new JetStreamSetupException(String.format("Unable to configure stream: %s", e.getMessage()), e); + } + } + + private Optional getStreamConfiguration(JetStreamManagement jsm, Stream stream) { + return getStreamInfo(jsm, stream.name()).map(StreamInfo::getConfiguration); + } + + private Optional getStreamInfo(JetStreamManagement jsm, String streamName) { + try { + return Optional.of(jsm.getStreamInfo(streamName, StreamInfoOptions.allSubjects())); + } catch (IOException e) { + throw new ConnectionException("Failed getting stream info: " + e.getMessage(), e); + } catch (JetStreamApiException e) { + return Optional.empty(); + } + } + + private void createStreamConfiguration(JetStreamManagement jsm, + Stream stream, + JetStreamBuildConfiguration jetStreamConfiguration) { + try { + logger.infof("Creating stream: %s with subjects: %s", stream.name(), stream.subjects()); + StreamConfiguration.Builder streamConfigBuilder = StreamConfiguration.builder() + .name(stream.name()) + .storageType(StorageType.valueOf(jetStreamConfiguration.storageType())) + .retentionPolicy(RetentionPolicy.valueOf(jetStreamConfiguration.retentionPolicy())) + .replicas(jetStreamConfiguration.replicas()) + .subjects(stream.subjects()); + jsm.addStream(streamConfigBuilder.build()); + } catch (IOException | JetStreamApiException e) { + throw new ConnectionException( + String.format("Failed creating stream: %s with message: %s", stream, e.getMessage()), e); + } + } + + private void updateStreamConfiguration(JetStreamManagement jsm, + StreamConfiguration streamConfiguration, + Stream stream, + JetStreamBuildConfiguration jetStreamConfiguration) { + try { + if (!new HashSet<>(streamConfiguration.getSubjects()).containsAll(stream.subjects())) { + logger.infof("Updating stream %s with subjects %s", streamConfiguration.getName(), stream.subjects()); + jsm.updateStream(StreamConfiguration.builder(streamConfiguration).subjects(stream.subjects()) + .replicas(jetStreamConfiguration.replicas()).build()); + } + } catch (IOException | JetStreamApiException e) { + throw new ConnectionException( + String.format("Failed updating stream: %s with message: %s", stream, e.getMessage()), e); + } + } + + private Collection getStreams() { + final var configs = new HashMap(); + final var config = ConfigProvider.getConfig(); + getChannelPrefixes(config).forEach(channelPrefix -> { + if (isNatsConnector(config, channelPrefix) && (autoConfigure(config, channelPrefix))) { + getStream(config, channelPrefix).ifPresent(streamName -> { + final var streamConfig = Optional.ofNullable(configs.get(streamName)) + .orElseGet(() -> new Stream(streamName, new HashSet<>())); + getSubject(config, channelPrefix).ifPresent(subject -> streamConfig.subjects().add(subject)); + configs.putIfAbsent(streamName, streamConfig); + }); + + } + }); + return configs.values(); + } + + private Set getChannelPrefixes(Config config) { + final var channelPrefixes = new HashSet(); + config.getPropertyNames().forEach(propertyName -> { + if (propertyName.startsWith("mp.messaging.incoming.")) { + final var index = propertyName.indexOf(".", "mp.messaging.incoming.".length()); + channelPrefixes.add(propertyName.substring(0, index)); + } else if (propertyName.startsWith("mp.messaging.outgoing.")) { + var index = propertyName.indexOf(".", "mp.messaging.outgoing.".length()); + channelPrefixes.add(propertyName.substring(0, index)); + } + }); + return channelPrefixes; + } + + private boolean isNatsConnector(Config config, String channelPrefix) { + return config.getOptionalValue(channelPrefix + ".connector", String.class).filter(CONNECTOR_NAME::equals).isPresent(); + } + + private Optional getStream(Config config, String channelPrefix) { + return config.getOptionalValue(channelPrefix + ".stream", String.class); + } + + private Optional getSubject(Config config, String channelPrefix) { + return config.getOptionalValue(channelPrefix + ".subject", String.class).map(subject -> { + if (subject.endsWith(".>")) { + return subject.substring(0, subject.length() - 2); + } else { + return subject; + } + }); + } + + private boolean autoConfigure(Config config, String channelPrefix) { + return config.getOptionalValue(channelPrefix + ".auto-configure", Boolean.class).orElse(true); + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/setup/JetStreamSetupException.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/setup/JetStreamSetupException.java new file mode 100644 index 00000000..4d643558 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/setup/JetStreamSetupException.java @@ -0,0 +1,9 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.setup; + +public class JetStreamSetupException extends RuntimeException { + + public JetStreamSetupException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/setup/Stream.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/setup/Stream.java new file mode 100644 index 00000000..50cd036d --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/setup/Stream.java @@ -0,0 +1,29 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.setup; + +import java.util.Set; + +public class Stream { + private final String name; + private final Set subjects; + + public Stream(final String name, final Set subjects) { + this.name = name; + this.subjects = subjects; + } + + public String name() { + return name; + } + + public Set subjects() { + return subjects; + } + + @Override + public String toString() { + return "Stream{" + + "name='" + name + '\'' + + ", subjects=" + subjects + + '}'; + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamInstrumenter.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamInstrumenter.java new file mode 100644 index 00000000..d4b8c161 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamInstrumenter.java @@ -0,0 +1,46 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.tracing; + +import static io.opentelemetry.instrumentation.api.instrumenter.messaging.MessageOperation.RECEIVE; +import static io.opentelemetry.instrumentation.api.instrumenter.messaging.MessagingAttributesExtractor.create; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import io.opentelemetry.instrumentation.api.instrumenter.InstrumenterBuilder; +import io.opentelemetry.instrumentation.api.instrumenter.messaging.MessageOperation; +import io.opentelemetry.instrumentation.api.instrumenter.messaging.MessagingAttributesExtractor; +import io.opentelemetry.instrumentation.api.instrumenter.messaging.MessagingAttributesGetter; +import io.opentelemetry.instrumentation.api.instrumenter.messaging.MessagingSpanNameExtractor; + +@ApplicationScoped +public class JetStreamInstrumenter { + + public Instrumenter publisher() { + final var attributesExtractor = new JetStreamTraceAttributesExtractor(); + MessagingAttributesGetter messagingAttributesGetter = attributesExtractor + .getMessagingAttributesGetter(); + + InstrumenterBuilder builder = Instrumenter.builder(GlobalOpenTelemetry.get(), + "io.smallrye.reactive.messaging.jetstream", + MessagingSpanNameExtractor.create(messagingAttributesGetter, MessageOperation.PUBLISH)); + + return builder.addAttributesExtractor(create(messagingAttributesGetter, MessageOperation.PUBLISH)) + .addAttributesExtractor(attributesExtractor) + .buildProducerInstrumenter(JetStreamTraceTextMapSetter.INSTANCE); + } + + public Instrumenter receiver() { + final var attributesExtractor = new JetStreamTraceAttributesExtractor(); + MessagingAttributesGetter messagingAttributesGetter = attributesExtractor + .getMessagingAttributesGetter(); + InstrumenterBuilder builder = Instrumenter.builder(GlobalOpenTelemetry.get(), + "io.smallrye.reactive.messaging.jetstream", + MessagingSpanNameExtractor.create(messagingAttributesGetter, RECEIVE)); + + return builder.addAttributesExtractor(attributesExtractor) + .addAttributesExtractor(MessagingAttributesExtractor.create(messagingAttributesGetter, RECEIVE)) + .buildConsumerInstrumenter(JetStreamTraceTextMapGetter.INSTANCE); + } + +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamTrace.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamTrace.java new file mode 100644 index 00000000..51453120 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamTrace.java @@ -0,0 +1,47 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.tracing; + +import java.util.List; +import java.util.Map; + +import io.quarkiverse.reactive.messsaging.nats.jetstream.JetStreamIncomingMessage; + +public class JetStreamTrace { + private final String stream; + private final String subject; + private final String messageId; + private final Map> headers; + private final String payload; + + public JetStreamTrace(String stream, String subject, String messageId, Map> headers, String payload) { + this.stream = stream; + this.subject = subject; + this.messageId = messageId; + this.headers = headers; + this.payload = payload; + } + + public String stream() { + return stream; + } + + public String subject() { + return subject; + } + + public String messageId() { + return messageId; + } + + public Map> headers() { + return headers; + } + + public String payload() { + return payload; + } + + public static JetStreamTrace trace(JetStreamIncomingMessage message) { + return new JetStreamTrace(message.getStream(), message.getSubject(), message.getMessageId(), message.getHeaders(), + new String(message.getData())); + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamTraceAttributesExtractor.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamTraceAttributesExtractor.java new file mode 100644 index 00000000..053040c1 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamTraceAttributesExtractor.java @@ -0,0 +1,71 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.tracing; + +import jakarta.annotation.Nullable; + +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; +import io.opentelemetry.instrumentation.api.instrumenter.messaging.MessagingAttributesGetter; + +public class JetStreamTraceAttributesExtractor implements AttributesExtractor { + private static final String MESSAGE_PAYLOAD = "message.payload"; + private final MessagingAttributesGetter messagingAttributesGetter; + + public JetStreamTraceAttributesExtractor() { + this.messagingAttributesGetter = new JetStreamMessagingAttributesGetter(); + } + + @Override + public void onStart(AttributesBuilder attributesBuilder, Context context, JetStreamTrace jetStreamTrace) { + attributesBuilder.put(MESSAGE_PAYLOAD, jetStreamTrace.payload()); + } + + @Override + public void onEnd(AttributesBuilder attributesBuilder, Context context, JetStreamTrace jetStreamTrace, + @Nullable Void unused, @Nullable Throwable throwable) { + + } + + public MessagingAttributesGetter getMessagingAttributesGetter() { + return messagingAttributesGetter; + } + + private final static class JetStreamMessagingAttributesGetter implements MessagingAttributesGetter { + + @Override + public String getSystem(JetStreamTrace trace) { + return "jetstream"; + } + + @Override + public String getDestination(JetStreamTrace trace) { + return String.format("%s.%s", trace.stream(), trace.subject()); + } + + @Override + public boolean isTemporaryDestination(JetStreamTrace trace) { + return false; + } + + @Override + public String getConversationId(JetStreamTrace trace) { + return null; + } + + @Override + public Long getMessagePayloadSize(JetStreamTrace trace) { + return null; + } + + @Override + public Long getMessagePayloadCompressedSize(JetStreamTrace trace) { + return null; + } + + @Override + public String getMessageId(JetStreamTrace trace, Void unused) { + return null; + } + + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamTraceTextMapGetter.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamTraceTextMapGetter.java new file mode 100644 index 00000000..c0b81521 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamTraceTextMapGetter.java @@ -0,0 +1,34 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.tracing; + +import java.util.Collections; + +import jakarta.annotation.Nullable; + +import io.opentelemetry.context.propagation.TextMapGetter; + +public enum JetStreamTraceTextMapGetter implements TextMapGetter { + INSTANCE; + + @Override + public Iterable keys(JetStreamTrace carrier) { + final var headers = carrier.headers(); + if (headers != null) { + return headers.keySet(); + } + return Collections.emptyList(); + } + + @Override + public String get(@Nullable JetStreamTrace carrier, String key) { + if (carrier != null) { + final var headers = carrier.headers(); + if (headers != null) { + final var value = headers.get(key); + if (value != null) { + return String.join(",", value); + } + } + } + return null; + } +} diff --git a/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamTraceTextMapSetter.java b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamTraceTextMapSetter.java new file mode 100644 index 00000000..8b068301 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/reactive/messsaging/nats/jetstream/tracing/JetStreamTraceTextMapSetter.java @@ -0,0 +1,19 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.tracing; + +import java.util.List; + +import io.opentelemetry.context.propagation.TextMapSetter; + +public enum JetStreamTraceTextMapSetter implements TextMapSetter { + INSTANCE; + + @Override + public void set(final JetStreamTrace carrier, final String key, final String value) { + if (carrier != null) { + final var headers = carrier.headers(); + if (headers != null) { + headers.put(key, List.of(value)); + } + } + } +} 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 00000000..78191252 --- /dev/null +++ b/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,9 @@ +name: Reactive Messsaging Nats Jetstream +description: Easily integrate to nats.io JetStream. +metadata: + keywords: + - reactive-messsaging-nats-jetstream + guide: https://quarkiverse.github.io/quarkiverse-docs/reactive-messsaging-nats-jetstream/dev/ + categories: + - "messaging" + status: "preview" diff --git a/runtime/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/publisher/MessagePublisherProcessorTest.java b/runtime/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/publisher/MessagePublisherProcessorTest.java new file mode 100644 index 00000000..5ef98506 --- /dev/null +++ b/runtime/src/test/java/io/quarkiverse/reactive/messsaging/nats/jetstream/processors/publisher/MessagePublisherProcessorTest.java @@ -0,0 +1,28 @@ +package io.quarkiverse.reactive.messsaging.nats.jetstream.processors.publisher; + +import static io.quarkiverse.reactive.messsaging.nats.jetstream.processors.publisher.MessagePublisherProcessor.createPushSubscribeOptions; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +public class MessagePublisherProcessorTest { + + @Test + void configureSubscriberClient() { + final var durable = "durable"; + final var deleiverGroup = "deliver-group"; + final var backoff = new String[] { "PT1S" }; + final var maxDeliever = 3L; + + final var options = createPushSubscribeOptions(durable, deleiverGroup, backoff, maxDeliever); + + assertThat(options.getDurable()).isEqualTo(durable); + assertThat(options.getDeliverGroup()).isEqualTo(deleiverGroup); + assertThat(options.getConsumerConfiguration().getMaxDeliver()).isEqualTo(maxDeliever); + assertThat(options.getConsumerConfiguration().getBackoff()).hasSize(1); + assertThat(options.getConsumerConfiguration().getBackoff()).contains(Duration.ofSeconds(1)); + } + +}