diff --git a/.circleci/config.yml b/.circleci/config.yml index 6e08c49..b37b681 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -32,4 +32,4 @@ workflows: - /.*/ tags: only: - - /^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$/ \ No newline at end of file + - /^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$/ diff --git a/.gitignore b/.gitignore index 760fe44..b1fd4fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,37 +1,23 @@ -*.class +!.gitignore -# Mobile Tools for Java (J2ME) -.mtj.tmp/ +**/target/ +.idea/ +*.iml +.DS_Store +.*.settings.xml +**/.logs -# Package Files # -*.jar -*.war -*.ear +# eclipse +.settings/ +.project +.classpath +/bin/ -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* +**/.tmp/ +**/coverage/ -# Maven files -/target/ +# Deepcode (Snyk Code) cache +**/.dccache -# Idea files -/.idea/ -/*.iml -# -- Cicd : Git ignore the [.circleci/**/*] which contains -# files which do not need to be commited (password to artifactory) -.circleci/**/* -# -- Cicd : Do not git ignore the [!./.circleci/config.yml] which contains -# the pipeline definition -!.circleci/config.yml -# -- Cicd : Git ignore the [gpg.script.snippet.sh] which contains -# secrets (password to artifactory) -gpg.script.snippet.sh -# -- Cicd : The [graviteebot.gpg.priv.key] file contains secrets -# which should not be commited -graviteebot.gpg.priv.key -# -- Cicd : The [.secrethub.credential] file contains secrets -# which should not be commited -graviteebot.gpg.pub.key -# -- Cicd : The [.secrets.json] file contains secrets -# which should not be commited -.secrets.json \ No newline at end of file +# Exclude flattened version of the pom, for details see https://maven.apache.org/maven-ci-friendly.html#install-deploy +.flattened-pom.xml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1104623 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,76 @@ +# [1.0.0-alpha.7](https://github.com/gravitee-io/gravitee-exchange/compare/1.0.0-alpha.6...1.0.0-alpha.7) (2024-03-15) + + +### Bug Fixes + +* add missing provided scope ([832a90c](https://github.com/gravitee-io/gravitee-exchange/commit/832a90ca17a7233030b9e62047a80e26b5081ea6)) +* fix some edge case issue on unit test ([f21cd3c](https://github.com/gravitee-io/gravitee-exchange/commit/f21cd3cca77ff612846a2090e2941c4bc302ba41)) + +# [1.0.0-alpha.6](https://github.com/gravitee-io/gravitee-exchange/compare/1.0.0-alpha.5...1.0.0-alpha.6) (2024-03-14) + + +### Features + +* allow defining fallback configuration key ([4bed7a3](https://github.com/gravitee-io/gravitee-exchange/commit/4bed7a38d9c15a8191841a00b7d6aaaffe9cb7d3)) + +# [1.0.0-alpha.5](https://github.com/gravitee-io/gravitee-exchange/compare/1.0.0-alpha.4...1.0.0-alpha.5) (2024-03-05) + + +### Bug Fixes + +* adapte connector log during connection ([59f4d15](https://github.com/gravitee-io/gravitee-exchange/commit/59f4d153818508b84c08d9b269066c5e8784bdf8)) +* add command id on clusteredReply to manage no reply ([1bd10ab](https://github.com/gravitee-io/gravitee-exchange/commit/1bd10abb9cd0e7f8cbb64db2132658551cae1b89)) +* add legacy controller path ([2016430](https://github.com/gravitee-io/gravitee-exchange/commit/2016430d832e8c3b7e5f13804361403792c6aa53)) +* add missing client auth default value ([404bdde](https://github.com/gravitee-io/gravitee-exchange/commit/404bdde9398b0b92cf4bcb333bb069307f73ec71)) +* add missing space on legacy prefixes ([dcd8ecf](https://github.com/gravitee-io/gravitee-exchange/commit/dcd8ecf0366de4d481cab262f7c6fab523f364cd)) +* clean default GoodByeCommandHandler from reconnection mechanism ([7e84b4f](https://github.com/gravitee-io/gravitee-exchange/commit/7e84b4f773a7a2329fcbb7b4f172169a5c62a4d6)) +* correct an issue with reply adapter wrongly used ([94684f3](https://github.com/gravitee-io/gravitee-exchange/commit/94684f323c99d0ad4f1f421e32cbe5bf25ac4c56)) +* do not failed on no reply exception ([4703c83](https://github.com/gravitee-io/gravitee-exchange/commit/4703c834783fe68a9d8b5122a3ebcfedd7c5e910)) +* dont use cause to log error ([c049c8f](https://github.com/gravitee-io/gravitee-exchange/commit/c049c8f311c2a9d9f55d1231e05cfbf9366f0a21)) +* filter null channel on stop to avoid NPE ([3ba8c76](https://github.com/gravitee-io/gravitee-exchange/commit/3ba8c764c44f52e1b93af1ef649f10c8f04ed38d)) +* handle internal the websocket status based on GoodBye command ([1478bfd](https://github.com/gravitee-io/gravitee-exchange/commit/1478bfd7a183ec9e238a123caaeee1a1db376eac)) +* improve and correct legacy adapters ([f97684f](https://github.com/gravitee-io/gravitee-exchange/commit/f97684fc3d9b6c92c57e0567d1543f9bac76187d)) +* issue with batch configuration ([08cdf38](https://github.com/gravitee-io/gravitee-exchange/commit/08cdf384febef676b8555ead0167b50627c1d283)) +* properly handle goodbye reconnection ([4f92d83](https://github.com/gravitee-io/gravitee-exchange/commit/4f92d835a09cd4fc117a140c997c7cd8c8a95881)) +* properly handle serialization ([3321dcf](https://github.com/gravitee-io/gravitee-exchange/commit/3321dcfcdc33af9ad00e76cf784048f50aec3c69)) +* properly manage controller shutdown process ([dd59ba2](https://github.com/gravitee-io/gravitee-exchange/commit/dd59ba206c6db22c004b4ae52c7de3504c85233d)) +* properly set default primary value on websocket connector ([b6d7bb9](https://github.com/gravitee-io/gravitee-exchange/commit/b6d7bb9f41fd96e6360019e8f51347922ec077e4)) +* properly use adapted cmd/reply when required ([7407a59](https://github.com/gravitee-io/gravitee-exchange/commit/7407a59d39f742d271befde216f6bb482e287e33)) + + +### Features + +* add channel metrics ([bf1d814](https://github.com/gravitee-io/gravitee-exchange/commit/bf1d81407187bdadc62d275c35732b7a026465e1)) +* add key on a batch ([49ec37f](https://github.com/gravitee-io/gravitee-exchange/commit/49ec37f52e38c86c2d9b3d0e9216c8e27d869959)) +* add observer to be notified when a batch finishes ([f958cb7](https://github.com/gravitee-io/gravitee-exchange/commit/f958cb7c5a512ff73e1b60ce79f37407beddac21)) +* add protocol version on adapters factory ([8ac8d77](https://github.com/gravitee-io/gravitee-exchange/commit/8ac8d77f5a82779f35472c8cf567990949f3318d)) +* allow to obtain connector status ([49f9d36](https://github.com/gravitee-io/gravitee-exchange/commit/49f9d3672ead035c893e7dd7a8e84ce0911670fb)) +* improve controller metrics ([986079d](https://github.com/gravitee-io/gravitee-exchange/commit/986079d70a5454893266afe648d84c8f536fd37f)) + +# [1.0.0-alpha.4](https://github.com/gravitee-io/gravitee-exchange/compare/1.0.0-alpha.3...1.0.0-alpha.4) (2024-02-15) + + +### Bug Fixes + +* apply correct version ([a3bac9e](https://github.com/gravitee-io/gravitee-exchange/commit/a3bac9e794e85eeccb3ab89a39bcf716823ec7d1)) + +# [1.0.0-alpha.3](https://github.com/gravitee-io/gravitee-exchange/compare/1.0.0-alpha.2...1.0.0-alpha.3) (2024-02-15) + + +### Features + +* add command/reply adapters to manage command migration ([23b4605](https://github.com/gravitee-io/gravitee-exchange/commit/23b46050c8e63fc3453b00e079001c6a98ca1d04)) + +# [1.0.0-alpha.2](https://github.com/gravitee-io/gravitee-exchange/compare/1.0.0-alpha.1...1.0.0-alpha.2) (2024-02-07) + + +### Bug Fixes + +* refactor serialization to avoid class cast ([42531a4](https://github.com/gravitee-io/gravitee-exchange/commit/42531a499d4628ab6e3ca398fdc08fe3bff94b66)) + +# 1.0.0-alpha.1 (2024-01-29) + + +### Features + +* initialize command-reply exchange framework ([8cbac71](https://github.com/gravitee-io/gravitee-exchange/commit/8cbac71d14814c749a1c86d1a31e283da8450732)) diff --git a/README.adoc b/README.adoc index 4153cc7..4569edf 100644 --- a/README.adoc +++ b/README.adoc @@ -1,3 +1,42 @@ -= Command-Reply Exchange Framework += Gravitee.io - Command-Reply Exchange Framework -This repository contains all code related to the command-reply exchange framework. \ No newline at end of file +== Description +The command-reply exchange framework offers a generic mechanism to exchange commands and replies between two components called Controller and Connectors. + +== Overview +=== Connector +A connector is identified by its id and its target-id. In order to scale connectors, connectors can be attached to the same target-id. The connector is managed by a controller. +A Connector allows to send command to the controller. + +=== Controller +A controller is the component where connectors can connect to. It will manage connectors life-cycle based health check, and primary election. Controllers work in cluster based on ClusterManager service offered by https://github.com/gravitee-io/gravitee-node/tree/master/gravitee-node-cluster[gravitee-node]. +A controller allows to send commands and batches to a connector via its target-id. + +=== Channel +A channel is a way to exchange commands and replies between a connector and a controller. There is always a connector view and a controller view of the same channel. A channel can be embedded when both controller and connectors run on the same JVM or could represent a network when they are remote. + +=== Commands +A command is a json payload send to a channel. For any commands a reply is expected. + +=== Workflow +Bellow you could find advanced details on different connector<>controller workflows. + +==== Registration +This is the first required step to allow connectors and controllers to exchange commands. The diagram bellow describes how a connector establishes the connection and how the controller registers it. + +image::docs/registration.png[width=980] + +==== Primary channel election +The primary channel election is used in order to avoid cluster management on the connectors side. A channel get notified it is primary thanks to a specific command. Then, it can e.g. start a scheduled process to send the controller some specific commands. Note that primary and secondary channels can still be used to send and receive commands and replies, this mechanism exists so that a connector bound to the primary channel is responsible to send commands that no other connector is allowed to send. + +image::docs/primary-channel-election.png[width=1167] + +==== Sending command +A command is the way to communicate between a connector and a controller. The schema bellow explain the various steps its processing involves. + +image::docs/sending-command.png[width=1125] + +==== Health check mechanism +The purpose of health check is to verify that the connector is still properly working and able to receive commands, at network level and also at the business logic level. The connector can do business check to ensure it can react on any commands. + +image::docs/health-check.png[width=1125] \ No newline at end of file diff --git a/docs/health-check.png b/docs/health-check.png new file mode 100644 index 0000000..5413886 Binary files /dev/null and b/docs/health-check.png differ diff --git a/docs/primary-channel-election.png b/docs/primary-channel-election.png new file mode 100644 index 0000000..05e3f2e Binary files /dev/null and b/docs/primary-channel-election.png differ diff --git a/docs/registration.png b/docs/registration.png new file mode 100644 index 0000000..9c1f93a Binary files /dev/null and b/docs/registration.png differ diff --git a/docs/sending-command.png b/docs/sending-command.png new file mode 100644 index 0000000..77e5768 Binary files /dev/null and b/docs/sending-command.png differ diff --git a/gravitee-exchange-api/pom.xml b/gravitee-exchange-api/pom.xml new file mode 100644 index 0000000..2866e95 --- /dev/null +++ b/gravitee-exchange-api/pom.xml @@ -0,0 +1,96 @@ + + + + 4.0.0 + + + io.gravitee.exchange + gravitee-exchange + 1.0.0-alpha.7 + + + gravitee-exchange-api + 1.0.0-alpha.7 + Gravitee.io - Exchange - API + + + + + com.fasterxml.jackson.core + jackson-databind + provided + + + + io.gravitee.common + gravitee-common + provided + + + + io.gravitee.node + gravitee-node-api + provided + + + org.springframework + spring-context + provided + + + + + io.vertx + vertx-core + provided + + + io.vertx + vertx-rx-java3 + provided + + + io.reactivex.rxjava3 + rxjava + provided + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + test-jar + + + + io/gravitee/exchange/api/websocket/channel/test/** + + + + + + + + diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/Batch.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/Batch.java new file mode 100644 index 0000000..4eae0cf --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/Batch.java @@ -0,0 +1,160 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.batch; + +import io.gravitee.common.utils.UUID; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import java.io.Serializable; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Accessors(fluent = true, chain = true) +public class Batch implements Serializable { + + public static final int DEFAULT_MAX_RETRY = 5; + public static final long DEFAULT_SCHEDULER_PERIOD_IN_SECONDS = 60; + + /** + * The ID of the batch + */ + @Builder.Default + private String id = UUID.random().toString(); + + /** + * The key to identify the batch + */ + private String key; + + /** + * The target id of the batch + */ + private String targetId; + + /** + * The list of commands for this batch + */ + private List batchCommands; + + /** + * The status of the batch + */ + @Builder.Default + private BatchStatus status = BatchStatus.CREATED; + + /** + * The message related to the status of the batch. Ex: the error message when a command has failed. + */ + private String errorDetails; + + @Builder.Default + private int maxRetry = DEFAULT_MAX_RETRY; + + private Integer retry; + + private Instant lastRetryAt; + + public Batch start() { + Instant now = Instant.now(); + boolean shouldRetry = Optional + .ofNullable(this.lastRetryAt) + .map(t -> now.compareTo(t.plusSeconds(this.retry * DEFAULT_SCHEDULER_PERIOD_IN_SECONDS))) + .map(compare -> compare >= 0) + .orElse(true); + + if (shouldRetry) { + this.status = BatchStatus.IN_PROGRESS; + this.retry = Optional.ofNullable(this.retry).map(r -> r + 1).orElse(0); + this.lastRetryAt = now; + } + return this; + } + + public Batch reset() { + if (this.status == BatchStatus.IN_PROGRESS) { + this.status = BatchStatus.PENDING; + this.retry = 0; + this.lastRetryAt = null; + return this; + } + return this; + } + + public Batch markCommandInProgress(final String commandId) { + return markCommand(commandId, CommandStatus.IN_PROGRESS, null); + } + + public Batch markCommandInError(final String commandId, final String errorDetails) { + return markCommand(commandId, CommandStatus.ERROR, errorDetails); + } + + private Batch markCommand(final String commandId, final CommandStatus commandStatus, final String errorDetails) { + this.batchCommands.forEach(c -> { + if (Objects.equals(c.command().getId(), commandId)) { + c.status(commandStatus).errorDetails(errorDetails); + } + }); + this.status = computeStatus(); + return this; + } + + public Batch setCommandReply(final String commandId, final Reply reply) { + this.batchCommands.forEach(c -> { + if (Objects.equals(c.command().getId(), commandId)) { + c.status(reply.getCommandStatus()).reply(reply).errorDetails(reply.getErrorDetails()); + } + }); + this.status = computeStatus(); + return this; + } + + private BatchStatus computeStatus() { + boolean isActionSucceeded = + this.batchCommands.stream().allMatch(batchCommand -> batchCommand.status().equals(CommandStatus.SUCCEEDED)); + if (isActionSucceeded) { + return BatchStatus.SUCCEEDED; + } + + boolean isActionOnError = this.batchCommands.stream().anyMatch(batchCommand -> batchCommand.status().equals(CommandStatus.ERROR)); + boolean thresholdReached = this.retry >= this.maxRetry; + if (isActionOnError) { + if (thresholdReached) { + return BatchStatus.ERROR; + } + return BatchStatus.PENDING; + } + + return BatchStatus.IN_PROGRESS; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchCommand.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchCommand.java new file mode 100644 index 0000000..f22a42e --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchCommand.java @@ -0,0 +1,63 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.batch; + +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Getter +@Accessors(fluent = true, chain = true) +public class BatchCommand implements Serializable { + + /** + * The status of the command + */ + @Builder.Default + @Setter + private CommandStatus status = CommandStatus.PENDING; + + /** + * The reply if any received yet + */ + @Setter + private Reply reply; + + /** + * An optional message that is used to give some details about the reply error. + */ + @Setter + private String errorDetails; + + /** + * The real command to execute. + */ + private Command command; +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchObserver.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchObserver.java new file mode 100644 index 0000000..edc22d5 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchObserver.java @@ -0,0 +1,26 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.batch; + +import io.reactivex.rxjava3.core.Completable; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface BatchObserver { + Completable notify(final Batch batch); +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchStatus.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchStatus.java new file mode 100644 index 0000000..0bfda5f --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchStatus.java @@ -0,0 +1,43 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.batch; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public enum BatchStatus { + /** + * The batch was just created + */ + CREATED, + /** + * The batch is waiting for one of its commands to be retried + */ + PENDING, + /** + * The batch is currently being processed + */ + IN_PROGRESS, + /** + * The batch has been processed successfully + */ + SUCCEEDED, + /** + * The batch has been processed but ends in error even after multiple retries + */ + ERROR, +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/KeyBatchObserver.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/KeyBatchObserver.java new file mode 100644 index 0000000..47e49cd --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/KeyBatchObserver.java @@ -0,0 +1,24 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.batch; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface KeyBatchObserver extends BatchObserver { + String batchKey(); +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/Channel.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/Channel.java new file mode 100644 index 0000000..ab248a5 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/Channel.java @@ -0,0 +1,97 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.channel; + +import io.gravitee.exchange.api.channel.exception.ChannelClosedException; +import io.gravitee.exchange.api.channel.exception.ChannelInitializationException; +import io.gravitee.exchange.api.channel.exception.ChannelNoReplyException; +import io.gravitee.exchange.api.channel.exception.ChannelTimeoutException; +import io.gravitee.exchange.api.channel.exception.ChannelUnknownCommandException; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Single; +import java.util.List; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface Channel { + /** + * Get the unique id of this channel. + * + * @return the id of the channel. + */ + String id(); + + /** + * Get the target id the channel is opened for. + * + * @return target id. + */ + String targetId(); + + /** + * Must be called to initialize channel connectivity + * + * @return returns a {@code Completable} instance that completes in case of success or a {@link ChannelInitializationException} is emitted + */ + Completable initialize(); + + /** + * Must be called to properly close channel + * + * @return returns a {@code Completable} instance that completes in case of success or a {@link ChannelClosedException} is emitted + */ + Completable close(); + + /** + * Return true is the current channel is active and ready to receive new commands, false otherwise. + * + * @return status of the channel. + */ + boolean isActive(); + + /** + * Send the actual commands to the current channel. In case of error, different exception could be returned: + * + * @return a {@code Single} with the {@code Reply} of the command + */ + , R extends Reply> Single send(final C command); + + /** + * Add customs {@link CommandHandler} to this channel + */ + void addCommandHandlers(final List, ? extends Reply>> commandHandlers); + + /** + * Add customs {@link CommandAdapter} to this channel + */ + void addCommandAdapters(final List, ? extends Command, ? extends Reply>> commandAdapters); + /** + * Add customs {@link ReplyAdapter} to this channel + */ + void addReplyAdapters(final List, ? extends Reply>> replyAdapters); +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelClosedException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelClosedException.java new file mode 100644 index 0000000..173ac10 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelClosedException.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelClosedException extends ChannelException {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelException.java new file mode 100644 index 0000000..0af3d63 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelException.java @@ -0,0 +1,33 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelException extends RuntimeException { + + public ChannelException() {} + + public ChannelException(final String message) { + super(message); + } + + public ChannelException(final Throwable cause) { + super(cause); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelInactiveException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelInactiveException.java new file mode 100644 index 0000000..905bf8b --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelInactiveException.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelInactiveException extends ChannelException {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelInitializationException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelInitializationException.java new file mode 100644 index 0000000..6286c17 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelInitializationException.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelInitializationException extends ChannelException { + + public ChannelInitializationException(final String message) { + super(message); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelNoReplyException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelNoReplyException.java new file mode 100644 index 0000000..7e7029e --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelNoReplyException.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelNoReplyException extends ChannelException { + + public ChannelNoReplyException(final String message) { + super(message); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelReplyException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelReplyException.java new file mode 100644 index 0000000..f4944ac --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelReplyException.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelReplyException extends ChannelException { + + public ChannelReplyException(final Throwable cause) { + super(cause); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelTimeoutException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelTimeoutException.java new file mode 100644 index 0000000..b4395dc --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelTimeoutException.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelTimeoutException extends ChannelException {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelUnknownCommandException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelUnknownCommandException.java new file mode 100644 index 0000000..16d9538 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelUnknownCommandException.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelUnknownCommandException extends ChannelException { + + public ChannelUnknownCommandException(final String message) { + super(message); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Command.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Command.java new file mode 100644 index 0000000..fb2ff99 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Command.java @@ -0,0 +1,62 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.gravitee.common.utils.UUID; +import io.gravitee.exchange.api.command.unknown.UnknownCommand; +import java.io.Serializable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "type", + defaultImpl = UnknownCommand.class +) +@NoArgsConstructor +@Getter +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@JsonPropertyOrder({ "id", "type", "payload" }) +public abstract class Command

extends Exchange

implements Serializable { + + public static final int COMMAND_REPLY_TIMEOUT_MS = 60_000; + + /** + * The command id. + */ + protected String id; + + @Setter + @JsonIgnore + protected int replyTimeoutMs = COMMAND_REPLY_TIMEOUT_MS; + + protected Command(String type) { + super(type); + this.id = UUID.random().toString(); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandAdapter.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandAdapter.java new file mode 100644 index 0000000..84e956b --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandAdapter.java @@ -0,0 +1,49 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command; + +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface CommandAdapter, C2 extends Command, R extends Reply> { + /** + * Returns the type of command supported by this adapter. + * + * @return the type of command supported. + */ + String supportType(); + + /** + * Method invoked before sending the command. + + * @return the command adapted + */ + default Single adapt(C1 command) { + return (Single) Single.just(command); + } + + /** + * Method invoke when an error is raised when sending a command. + * + * @param throwable a throwable + */ + default Single onError(final Command command, final Throwable throwable) { + return Single.error(throwable); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandHandler.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandHandler.java new file mode 100644 index 0000000..59a9abc --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandHandler.java @@ -0,0 +1,41 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command; + +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface CommandHandler, R extends Reply> { + /** + * Returns the type of command supported by this command handler. + * The type is used to determine the right handler to use when a command need to be handled. + * @return the type of command supported. + */ + String supportType(); + + /** + * Method invoked when a command of the expected type is received. + * + * @param command the command to handle. + * @return the reply with a status indicating if the command has been successfully handled or not. + */ + default Single handle(C command) { + return Single.error(new RuntimeException("Handle command of type " + supportType() + " is not implemented")); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandStatus.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandStatus.java new file mode 100644 index 0000000..5f22f67 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandStatus.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public enum CommandStatus { + /** + * The command is waiting for processing. + */ + PENDING, + + /** + * The command is processing. + */ + IN_PROGRESS, + + /** + * The command have been successfully processed. + */ + SUCCEEDED, + + /** + * The command got an unexpected error. + */ + ERROR, +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Exchange.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Exchange.java new file mode 100644 index 0000000..b3136ee --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Exchange.java @@ -0,0 +1,55 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import java.io.Serializable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.FieldNameConstants; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type") +@JsonIgnoreProperties(ignoreUnknown = true) +@NoArgsConstructor +@Getter +@EqualsAndHashCode +@ToString +@FieldNameConstants +public abstract class Exchange

implements Serializable { + + /** + * The type of the exchange (mainly used for deserialization). + */ + protected String type; + + /** + * The actual payload to send. + */ + protected P payload; + + protected Exchange(String type) { + this.type = type; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Payload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Payload.java new file mode 100644 index 0000000..b0781a7 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Payload.java @@ -0,0 +1,24 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command; + +import java.io.Serializable; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface Payload extends Serializable {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Reply.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Reply.java new file mode 100644 index 0000000..797d772 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Reply.java @@ -0,0 +1,65 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.gravitee.exchange.api.command.unknown.UnknownReply; +import java.io.Serializable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", defaultImpl = UnknownReply.class) +@Getter +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@JsonPropertyOrder({ "commandId", "type", "commandStatus", "errorDetails", "payload" }) +public abstract class Reply

extends Exchange

implements Serializable { + + /** + * The command id the reply is related to. + */ + protected String commandId; + + /** + * The result status of the command. + */ + protected CommandStatus commandStatus; + + /** + * An optional message that can be used to give some details about the error. + */ + protected String errorDetails; + + protected Reply(final String type) { + super(type); + } + + protected Reply(final String type, final String commandId, final CommandStatus commandStatus) { + super(type); + this.commandId = commandId; + this.commandStatus = commandStatus; + } + + public boolean stopOnErrorStatus() { + return false; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/ReplyAdapter.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/ReplyAdapter.java new file mode 100644 index 0000000..0401b03 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/ReplyAdapter.java @@ -0,0 +1,39 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command; + +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface ReplyAdapter, R2 extends Reply> { + /** + * Returns the type of reply handled by this adapter + * + * @return the type of reply supported. + */ + String supportType(); + + /** + * Method invoked when receiving the reply + * @return the reply adapted + */ + default Single adapt(R1 reply) { + return (Single) Single.just(reply); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeCommand.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeCommand.java new file mode 100644 index 0000000..fd7af3b --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeCommand.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.goodbye; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Command; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class GoodByeCommand extends Command { + + public static final String COMMAND_TYPE = "GOOD_BYE"; + + public GoodByeCommand() { + super(COMMAND_TYPE); + } + + public GoodByeCommand(final GoodByeCommandPayload goodByeCommandPayload) { + this(); + this.payload = goodByeCommandPayload; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeCommandPayload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeCommandPayload.java new file mode 100644 index 0000000..8431671 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeCommandPayload.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.goodbye; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Payload; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +@EqualsAndHashCode +@ToString +public class GoodByeCommandPayload implements Payload { + + private String targetId; + private boolean reconnect; +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeReply.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeReply.java new file mode 100644 index 0000000..9a6b7c8 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeReply.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.goodbye; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class GoodByeReply extends Reply { + + public GoodByeReply() { + super(GoodByeCommand.COMMAND_TYPE); + } + + public GoodByeReply(String commandId, GoodByeReplyPayload payload) { + super(GoodByeCommand.COMMAND_TYPE, commandId, CommandStatus.SUCCEEDED); + this.payload = payload; + } + + public GoodByeReply(String commandId, String errorDetails) { + super(GoodByeCommand.COMMAND_TYPE, commandId, CommandStatus.ERROR); + this.errorDetails = errorDetails; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeReplyPayload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeReplyPayload.java new file mode 100644 index 0000000..40c64e8 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeReplyPayload.java @@ -0,0 +1,41 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.goodbye; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Payload; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +@EqualsAndHashCode +@ToString +public class GoodByeReplyPayload implements Payload { + + private String targetId; +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckCommand.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckCommand.java new file mode 100644 index 0000000..3e1e35d --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckCommand.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.healtcheck; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Command; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class HealthCheckCommand extends Command { + + public static final String COMMAND_TYPE = "HEALTH_CHECK"; + + public HealthCheckCommand() { + super(COMMAND_TYPE); + } + + public HealthCheckCommand(final HealthCheckCommandPayload healthCheckCommandPayload) { + this(); + this.payload = healthCheckCommandPayload; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckCommandPayload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckCommandPayload.java new file mode 100644 index 0000000..ce304f7 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckCommandPayload.java @@ -0,0 +1,28 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.healtcheck; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Payload; +import lombok.Builder; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +public record HealthCheckCommandPayload() implements Payload {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckReply.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckReply.java new file mode 100644 index 0000000..604f57c --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckReply.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.healtcheck; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class HealthCheckReply extends Reply { + + public HealthCheckReply() { + super(HealthCheckCommand.COMMAND_TYPE); + } + + public HealthCheckReply(final String commandId, final HealthCheckReplyPayload payload) { + super(HealthCheckCommand.COMMAND_TYPE, commandId, CommandStatus.SUCCEEDED); + this.payload = payload; + } + + public HealthCheckReply(String commandId, String errorDetails) { + super(HealthCheckCommand.COMMAND_TYPE, commandId, CommandStatus.ERROR); + this.errorDetails = errorDetails; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckReplyPayload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckReplyPayload.java new file mode 100644 index 0000000..e372427 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckReplyPayload.java @@ -0,0 +1,28 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.healtcheck; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Payload; +import lombok.Builder; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +public record HealthCheckReplyPayload(boolean healthy, String detail) implements Payload {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloCommand.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloCommand.java new file mode 100644 index 0000000..99b5959 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloCommand.java @@ -0,0 +1,48 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.hello; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Command; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class HelloCommand extends Command { + + public static final String COMMAND_TYPE = "HELLO"; + + public HelloCommand() { + super(COMMAND_TYPE); + } + + public HelloCommand(final HelloCommandPayload helloCommandPayload) { + this(); + this.payload = helloCommandPayload; + } + + public HelloCommand(final String id, final HelloCommandPayload helloCommandPayload) { + this(); + this.id = id; + this.payload = helloCommandPayload; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloCommandPayload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloCommandPayload.java new file mode 100644 index 0000000..1dd59b5 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloCommandPayload.java @@ -0,0 +1,41 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.hello; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Payload; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +@Getter +@EqualsAndHashCode +@ToString +@JsonIgnoreProperties(ignoreUnknown = true) +public class HelloCommandPayload implements Payload { + + private String targetId; +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloReply.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloReply.java new file mode 100644 index 0000000..9009781 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloReply.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.hello; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class HelloReply extends Reply { + + public HelloReply() { + super(HelloCommand.COMMAND_TYPE); + } + + public HelloReply(String commandId, HelloReplyPayload payload) { + super(HelloCommand.COMMAND_TYPE, commandId, CommandStatus.SUCCEEDED); + this.payload = payload; + } + + public HelloReply(String commandId, String errorDetails) { + super(HelloCommand.COMMAND_TYPE, commandId, CommandStatus.ERROR); + this.errorDetails = errorDetails; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloReplyPayload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloReplyPayload.java new file mode 100644 index 0000000..f346381 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloReplyPayload.java @@ -0,0 +1,41 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.hello; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Payload; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +@Getter +@EqualsAndHashCode +@ToString +@JsonIgnoreProperties(ignoreUnknown = true) +public class HelloReplyPayload implements Payload { + + private String targetId; +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/noreply/NoReply.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/noreply/NoReply.java new file mode 100644 index 0000000..e67addb --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/noreply/NoReply.java @@ -0,0 +1,44 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.noreply; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class NoReply extends Reply { + + public static final String COMMAND_TYPE = "NO_REPLY"; + + public NoReply() { + super(COMMAND_TYPE); + } + + public NoReply(String commandId, final String errorDetails) { + super(COMMAND_TYPE, commandId, CommandStatus.ERROR); + this.errorDetails = errorDetails; + this.payload = new NoReplyPayload(); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/noreply/NoReplyPayload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/noreply/NoReplyPayload.java new file mode 100644 index 0000000..75bd6e1 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/noreply/NoReplyPayload.java @@ -0,0 +1,26 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.noreply; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Payload; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record NoReplyPayload() implements Payload {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryCommand.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryCommand.java new file mode 100644 index 0000000..021b224 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryCommand.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.primary; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Command; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PrimaryCommand extends Command { + + public static final String COMMAND_TYPE = "PRIMARY"; + + public PrimaryCommand() { + super(COMMAND_TYPE); + } + + public PrimaryCommand(final PrimaryCommandPayload primaryCommandPayload) { + this(); + this.payload = primaryCommandPayload; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryCommandPayload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryCommandPayload.java new file mode 100644 index 0000000..d680719 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryCommandPayload.java @@ -0,0 +1,26 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.primary; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Payload; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PrimaryCommandPayload(boolean primary) implements Payload {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryReply.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryReply.java new file mode 100644 index 0000000..6c1ccf2 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryReply.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.primary; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PrimaryReply extends Reply { + + public PrimaryReply() { + super(PrimaryCommand.COMMAND_TYPE); + } + + public PrimaryReply(String commandId, PrimaryReplyPayload payload) { + super(PrimaryCommand.COMMAND_TYPE, commandId, CommandStatus.SUCCEEDED); + this.payload = payload; + } + + public PrimaryReply(String commandId, String errorDetails) { + super(PrimaryCommand.COMMAND_TYPE, commandId, CommandStatus.ERROR); + this.errorDetails = errorDetails; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryReplyPayload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryReplyPayload.java new file mode 100644 index 0000000..66571f4 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryReplyPayload.java @@ -0,0 +1,26 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.primary; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Payload; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PrimaryReplyPayload() implements Payload {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownCommand.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownCommand.java new file mode 100644 index 0000000..2129a24 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownCommand.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.unknown; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Command; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class UnknownCommand extends Command { + + public static final String COMMAND_TYPE = "UNKNOWN"; + + public UnknownCommand() { + super(COMMAND_TYPE); + this.payload = new UnknownPayload(); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownCommandHandler.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownCommandHandler.java new file mode 100644 index 0000000..134e0b2 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownCommandHandler.java @@ -0,0 +1,36 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.unknown; + +import io.gravitee.exchange.api.command.CommandHandler; +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class UnknownCommandHandler implements CommandHandler { + + @Override + public String supportType() { + return UnknownCommand.COMMAND_TYPE; + } + + @Override + public Single handle(final UnknownCommand command) { + return Single.just(new UnknownReply(command.getId(), "Command unknown")); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownPayload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownPayload.java new file mode 100644 index 0000000..8dae330 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownPayload.java @@ -0,0 +1,26 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.unknown; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Payload; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record UnknownPayload() implements Payload {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownReply.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownReply.java new file mode 100644 index 0000000..f3ce3cf --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownReply.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.command.unknown; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class UnknownReply extends Reply { + + public UnknownReply() { + super(UnknownCommand.COMMAND_TYPE); + } + + public UnknownReply(String commandId, final String errorDetails) { + super(UnknownCommand.COMMAND_TYPE, commandId, CommandStatus.ERROR); + this.errorDetails = errorDetails; + this.payload = new UnknownPayload(); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/configuration/IdentifyConfiguration.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/configuration/IdentifyConfiguration.java new file mode 100644 index 0000000..12553f5 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/configuration/IdentifyConfiguration.java @@ -0,0 +1,128 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.configuration; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@RequiredArgsConstructor +@Getter +@Accessors(fluent = true) +@Slf4j +public class IdentifyConfiguration { + + public static final String DEFAULT_EXCHANGE_ID = "exchange"; + private final Environment environment; + private final String id; + private final Map fallbackKeys; + + public IdentifyConfiguration(final Environment environment) { + this(environment, DEFAULT_EXCHANGE_ID); + } + + public IdentifyConfiguration(final Environment environment, final Map fallbackKeys) { + this(environment, DEFAULT_EXCHANGE_ID, fallbackKeys); + } + + public IdentifyConfiguration(final Environment environment, final String identifier) { + this(environment, identifier, Map.of()); + } + + public String id() { + return id; + } + + public boolean containsProperty(final String key) { + boolean containsProperty = environment.containsProperty(identifyProperty(key)); + if (!containsProperty && fallbackKeys.containsKey(key)) { + String fallbackKey = fallbackKeys.get(key); + containsProperty = environment.containsProperty(fallbackKey); + if (containsProperty) { + log.warn("[{}] Using deprecated configuration '{}', replace it by '{}' as it will be removed.", id, fallbackKey, key); + } + } + return containsProperty; + } + + public String getProperty(final String key) { + return this.getProperty(key, String.class, null); + } + + public T getProperty(final String key, final Class clazz, final T defaultValue) { + T value = environment.getProperty(identifyProperty(key), clazz); + if (value == null && fallbackKeys.containsKey(key)) { + String fallbackKey = fallbackKeys.get(key); + value = environment.getProperty(fallbackKeys.get(key), clazz); + if (value != null) { + log.warn("[{}] Using deprecated configuration '{}', replace it by '{}' as it will be removed.", id, fallbackKey, key); + } + } + return value != null ? value : defaultValue; + } + + public List getPropertyList(final String key) { + List values = new ArrayList<>(); + int index = 0; + String indexKey = ("%s[%s]").formatted(key, index); + while (containsProperty(indexKey)) { + String value = getProperty(indexKey); + if (value != null && !value.isBlank()) { + values.add(value); + } + index++; + indexKey = ("%s[%s]").formatted(key, index); + } + + // Fallback + if (fallbackKeys.containsKey(key)) { + String fallbackKey = fallbackKeys.get(key); + int fallbackIndex = 0; + String fallbackIndexKey = ("%s[%s]").formatted(fallbackKey, fallbackIndex); + while (environment.containsProperty(fallbackIndexKey)) { + String value = environment.getProperty(fallbackIndexKey); + if (value != null && !value.isBlank()) { + values.add(value); + } + fallbackIndex++; + fallbackIndexKey = ("%s[%s]").formatted(fallbackKey, fallbackIndex); + } + } + + return values; + } + + public String identifyProperty(final String key) { + return identify("%s.%s", key); + } + + public String identifyName(final String name) { + return identify("%s-%s", name); + } + + private String identify(final String format, final String key) { + return format.formatted(id, key); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ConnectorChannel.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ConnectorChannel.java new file mode 100644 index 0000000..88d9da2 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ConnectorChannel.java @@ -0,0 +1,24 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.connector; + +import io.gravitee.exchange.api.channel.Channel; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface ConnectorChannel extends Channel {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ConnectorCommandContext.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ConnectorCommandContext.java new file mode 100644 index 0000000..7a32bdb --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ConnectorCommandContext.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.connector; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface ConnectorCommandContext {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ConnectorCommandHandlersFactory.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ConnectorCommandHandlersFactory.java new file mode 100644 index 0000000..2e21ce7 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ConnectorCommandHandlersFactory.java @@ -0,0 +1,58 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.connector; + +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.websocket.protocol.ProtocolVersion; +import java.util.List; + +public interface ConnectorCommandHandlersFactory { + /** + * Build a list of command handlers dedicated to the specified context. + * + * @param connectorCommandContext the command context + * @return a list of command handlers + */ + List, ? extends Reply>> buildCommandHandlers( + final ConnectorCommandContext connectorCommandContext + ); + + /** + * Build a list of command decorators dedicated to the specified context. + * + * @param connectorCommandContext the command context + * @return a list of command decorators + */ + List, ? extends Command, ? extends Reply>> buildCommandAdapters( + final ConnectorCommandContext connectorCommandContext, + final ProtocolVersion protocolVersion + ); + + /** + * Build a list of command decorators dedicated to the specified context. + * + * @param connectorCommandContext the command context + * @return a list of command decorators + */ + List, ? extends Reply>> buildReplyAdapters( + final ConnectorCommandContext connectorCommandContext, + final ProtocolVersion protocolVersion + ); +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ExchangeConnector.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ExchangeConnector.java new file mode 100644 index 0000000..75bb082 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ExchangeConnector.java @@ -0,0 +1,86 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.connector; + +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.connector.exception.ConnectorClosedException; +import io.gravitee.exchange.api.connector.exception.ConnectorInitializationException; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Single; +import java.util.List; +import java.util.Map; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface ExchangeConnector { + /** + * Must be called to initialize connector + * + * @return returns a {@code Completable} instance that completes in case of success or a {@link ConnectorInitializationException} is emitted + */ + Completable initialize(); + + /** + * Must be called to properly close the connector + * + * @return returns a {@code Completable} instance that completes in case of success or a {@link ConnectorClosedException} is emitted + */ + Completable close(); + + /** + * Get the target id of the connector . + * + * @return target id. + */ + String targetId(); + + /** + * Return true when the current instance is active and ready, false otherwise. + * + * @return status of the connector. + */ + boolean isActive(); + + /** + * Returns true when the current instance is PRIMARY. + * + * @return boolean about primary status + */ + boolean isPrimary(); + + /** + * Set primary status of this connector. + * + * @param isPrimary {@code true} if this node should be primary, {@code false} otherwise. + */ + void setPrimary(final boolean isPrimary); + + /** + * Send a command to the target. + * + * @param command the command to send. + */ + Single> sendCommand(Command command); + + /** + * Add customs {@link CommandHandler} to this connector. This will only happen new handlers to the existing handlers + */ + void addCommandHandlers(final List, ? extends Reply>> commandHandlers); +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ExchangeConnectorManager.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ExchangeConnectorManager.java new file mode 100644 index 0000000..c015935 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ExchangeConnectorManager.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.connector; + +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Maybe; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface ExchangeConnectorManager { + /** + * Register a new {@code ExchangeConnector} on this manager + * + * @param targetId the target id of the {@code ExchangeConnector} to find. + */ + Maybe get(final String targetId); + + /** + * Register a new {@code ExchangeConnector} on this manager + * + * @param exchangeConnector the new {@code ExchangeConnector}. + */ + Completable register(final ExchangeConnector exchangeConnector); + + /** + * Unregister a {@code ExchangeConnector} on this manager + * + * @param exchangeConnector the {@code ExchangeConnector}. + */ + Completable unregister(final ExchangeConnector exchangeConnector); +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/exception/ConnectorClosedException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/exception/ConnectorClosedException.java new file mode 100644 index 0000000..4e6b1c5 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/exception/ConnectorClosedException.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.connector.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ConnectorClosedException extends RuntimeException {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/exception/ConnectorInitializationException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/exception/ConnectorInitializationException.java new file mode 100644 index 0000000..2808f77 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/exception/ConnectorInitializationException.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.connector.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ConnectorInitializationException extends RuntimeException {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ControllerChannel.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ControllerChannel.java new file mode 100644 index 0000000..e740532 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ControllerChannel.java @@ -0,0 +1,29 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.controller; + +import io.gravitee.exchange.api.channel.Channel; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface ControllerChannel extends Channel { + /** + * Enforce the active status of this controller channel. + */ + void enforceActiveStatus(final boolean isActive); +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ControllerCommandContext.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ControllerCommandContext.java new file mode 100644 index 0000000..f678eb9 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ControllerCommandContext.java @@ -0,0 +1,24 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.controller; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface ControllerCommandContext { + boolean isValid(); +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ControllerCommandHandlersFactory.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ControllerCommandHandlersFactory.java new file mode 100644 index 0000000..d2a26bd --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ControllerCommandHandlersFactory.java @@ -0,0 +1,58 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.controller; + +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.websocket.protocol.ProtocolVersion; +import java.util.List; + +public interface ControllerCommandHandlersFactory { + /** + * Build a map of command handlers dedicated to the specified context. + * + * @param controllerCommandContext the command context + * @return a list of command handlers indexed by type. + */ + List, ? extends Reply>> buildCommandHandlers( + final ControllerCommandContext controllerCommandContext + ); + + /** + * Build a list of command decorators dedicated to the specified context. + * + * @param controllerCommandContext the command context + * @return a list of command decorators + */ + List, ? extends Command, ? extends Reply>> buildCommandAdapters( + final ControllerCommandContext controllerCommandContext, + final ProtocolVersion protocolVersion + ); + + /** + * Build a list of command decorators dedicated to the specified context. + * + * @param controllerCommandContext the command context + * @return a list of command decorators + */ + List, ? extends Reply>> buildReplyAdapters( + final ControllerCommandContext controllerCommandContext, + final ProtocolVersion protocolVersion + ); +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ExchangeController.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ExchangeController.java new file mode 100644 index 0000000..535cb02 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ExchangeController.java @@ -0,0 +1,105 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.controller; + +import io.gravitee.common.service.Service; +import io.gravitee.exchange.api.batch.Batch; +import io.gravitee.exchange.api.batch.BatchObserver; +import io.gravitee.exchange.api.batch.KeyBatchObserver; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.controller.metrics.ChannelMetric; +import io.gravitee.exchange.api.controller.metrics.TargetMetric; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface ExchangeController extends Service { + /** + * Return all target metrics + * + * @return target metrics. + */ + Flowable targetsMetric(); + + /** + * Return all channel metrics for the given target + * + * @param targetId the target id to retrieve channel metrics + * @return channel metrics. + */ + Flowable channelsMetric(String targetId); + + /** + * Register a new {@code ControllerChannel} on this controller + * + * @param channel the new channel. + */ + Completable register(final ControllerChannel channel); + + /** + * Unregister a {@code ControllerChannel} on this controller + * + * @param channel the new channel. + */ + Completable unregister(final ControllerChannel channel); + + /** + * Send a {@code Command} to a specific target + * + * @param command the command to send + * @param targetId the if of the target + */ + Single> sendCommand(final Command command, final String targetId); + + /** + * Execute a {@code Batch} of command. + * If any key based {@link KeyBatchObserver} has been registered, they will be notified when the batch finishes + * in {@link io.gravitee.exchange.api.batch.BatchStatus#SUCCEEDED} + * or {@link io.gravitee.exchange.api.batch.BatchStatus#ERROR}. + * + * @param batch the batch to execute + */ + Single executeBatch(final Batch batch); + + /** + * As {@link ExchangeController#executeBatch(Batch)} but with an {@link BatchObserver} which will be notified when + * the batch finished in {@link io.gravitee.exchange.api.batch.BatchStatus#SUCCEEDED} + * or {@link io.gravitee.exchange.api.batch.BatchStatus#ERROR} + * + * @param batch the batch to execute + * @param batchObserver the given will be executed when the given batch finished + */ + Completable executeBatch(final Batch batch, final BatchObserver batchObserver); + + /** + * Add a key based {@link BatchObserver} which will be called when any batches with the according key finish + * + * @param keyBasedObserver the given will be executed when any batch with the according key finish + */ + void addKeyBasedBatchObserver(final KeyBatchObserver keyBasedObserver); + + /** + * Remove a key based {@link BatchObserver} which will be called when any batches with the according key finish + * + * @param keyBasedObserver the observer to unregister + */ + void removeKeyBasedBatchObserver(final KeyBatchObserver keyBasedObserver); +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/metrics/ChannelMetric.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/metrics/ChannelMetric.java new file mode 100644 index 0000000..44ac999 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/metrics/ChannelMetric.java @@ -0,0 +1,35 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.controller.metrics; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.experimental.Accessors; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Builder +@AllArgsConstructor +@Getter +@Accessors(fluent = true, chain = true) +public class ChannelMetric { + + String id; + boolean primary; +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/metrics/TargetMetric.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/metrics/TargetMetric.java new file mode 100644 index 0000000..7fa04ea --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/metrics/TargetMetric.java @@ -0,0 +1,36 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.controller.metrics; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.experimental.Accessors; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Builder +@AllArgsConstructor +@Getter +@Accessors(fluent = true, chain = true) +public class TargetMetric { + + String id; + List channelMetrics; +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ws/WebsocketControllerConstants.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ws/WebsocketControllerConstants.java new file mode 100644 index 0000000..b86232d --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ws/WebsocketControllerConstants.java @@ -0,0 +1,31 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.controller.ws; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class WebsocketControllerConstants { + + public static final String EXCHANGE_CONTROLLER_PATH = "/exchange/controller"; + public static final String LEGACY_CONTROLLER_PATH = "/ws/controller/*"; + public static final String EXCHANGE_PROTOCOL_HEADER = "X-Gravitee-Exchange-Protocol"; +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/channel/AbstractWebSocketChannel.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/channel/AbstractWebSocketChannel.java new file mode 100644 index 0000000..3187002 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/channel/AbstractWebSocketChannel.java @@ -0,0 +1,476 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.channel; + +import static io.gravitee.exchange.api.command.CommandStatus.ERROR; + +import io.gravitee.exchange.api.channel.Channel; +import io.gravitee.exchange.api.channel.exception.ChannelClosedException; +import io.gravitee.exchange.api.channel.exception.ChannelInactiveException; +import io.gravitee.exchange.api.channel.exception.ChannelInitializationException; +import io.gravitee.exchange.api.channel.exception.ChannelNoReplyException; +import io.gravitee.exchange.api.channel.exception.ChannelReplyException; +import io.gravitee.exchange.api.channel.exception.ChannelTimeoutException; +import io.gravitee.exchange.api.channel.exception.ChannelUnknownCommandException; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Payload; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.command.goodbye.GoodByeCommand; +import io.gravitee.exchange.api.command.goodbye.GoodByeCommandPayload; +import io.gravitee.exchange.api.command.hello.HelloCommand; +import io.gravitee.exchange.api.command.hello.HelloReply; +import io.gravitee.exchange.api.command.hello.HelloReplyPayload; +import io.gravitee.exchange.api.command.noreply.NoReply; +import io.gravitee.exchange.api.command.unknown.UnknownCommandHandler; +import io.gravitee.exchange.api.command.unknown.UnknownReply; +import io.gravitee.exchange.api.websocket.protocol.ProtocolAdapter; +import io.gravitee.exchange.api.websocket.protocol.ProtocolExchange; +import io.gravitee.exchange.api.websocket.protocol.legacy.ignored.IgnoredReply; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.CompletableEmitter; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.core.SingleEmitter; +import io.vertx.rxjava3.core.Vertx; +import io.vertx.rxjava3.core.buffer.Buffer; +import io.vertx.rxjava3.core.http.WebSocketBase; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Slf4j +public abstract class AbstractWebSocketChannel implements Channel { + + private static final int PING_DELAY = 5_000; + protected final String id = UUID.randomUUID().toString(); + protected final Map, ? extends Reply>> commandHandlers = new ConcurrentHashMap<>(); + protected final Map, ? extends Command, ? extends Reply>> commandAdapters = new ConcurrentHashMap<>(); + protected final Map, ? extends Reply>> replyAdapters = new ConcurrentHashMap<>(); + protected final Vertx vertx; + protected final WebSocketBase webSocket; + protected final ProtocolAdapter protocolAdapter; + protected String targetId; + protected final Map>> resultEmitters = new ConcurrentHashMap<>(); + protected boolean active; + private long pingTaskId = -1; + + protected AbstractWebSocketChannel( + final List, ? extends Reply>> commandHandlers, + final List, ? extends Command, ? extends Reply>> commandAdapters, + final List, ? extends Reply>> replyAdapters, + final Vertx vertx, + final WebSocketBase webSocket, + final ProtocolAdapter protocolAdapter + ) { + this.addCommandHandlers(commandHandlers); + this.addCommandHandlers(List.of(new UnknownCommandHandler())); + this.addCommandHandlers(protocolAdapter.commandHandlers()); + this.addCommandAdapters(commandAdapters); + this.addCommandAdapters(protocolAdapter.commandAdapters()); + this.addReplyAdapters(replyAdapters); + this.addReplyAdapters(protocolAdapter.replyAdapters()); + this.vertx = vertx; + this.webSocket = webSocket; + this.protocolAdapter = protocolAdapter; + } + + @Override + public String id() { + return id; + } + + @Override + public String targetId() { + return targetId; + } + + @Override + public boolean isActive() { + return this.active; + } + + @Override + public Completable initialize() { + return Completable + .create(emitter -> { + webSocket.closeHandler(v -> { + log.warn("Channel '{}' for target '{}' is closing", id, targetId); + active = false; + cleanChannel(); + }); + + webSocket.pongHandler(buffer -> log.debug("Receiving pong frame from channel '{}' for target '{}'", id, targetId)); + + webSocket.textMessageHandler(buffer -> webSocket.close((short) 1003, "Unsupported text frame").subscribe()); + + webSocket.binaryMessageHandler(buffer -> { + if (buffer.length() > 0) { + ProtocolExchange websocketExchange = protocolAdapter.read(buffer); + + try { + if (ProtocolExchange.Type.COMMAND == websocketExchange.type()) { + receiveCommand(emitter, websocketExchange.asCommand()); + } else if (ProtocolExchange.Type.REPLY == websocketExchange.type()) { + receiveReply(websocketExchange.asReply()); + } else { + webSocket.close((short) 1002, "Exchange message unknown").subscribe(); + } + } catch (Exception e) { + log.warn( + String.format( + "An error occurred when trying to decode incoming websocket exchange [%s]. Closing Socket.", + websocketExchange + ), + e + ); + webSocket.close((short) 1011, "Unexpected error while handling incoming websocket exchange").subscribe(); + } + } + }); + + if (!expectHelloCommand()) { + this.active = true; + emitter.onComplete(); + } + }) + .doOnComplete(() -> log.debug("Channel '{}' for target '{}' has been successfully initialized", id, targetId)) + .doOnError(throwable -> log.error("Unable to initialize channel '{}' for target '{}'", id, targetId)); + } + + private > void receiveCommand(final CompletableEmitter emitter, final C command) { + if (command == null) { + webSocket.close((short) 1002, "Unrecognized incoming exchange").subscribe(); + emitter.onError(new ChannelUnknownCommandException("Unrecognized incoming exchange")); + return; + } + + Single> commandObs; + CommandAdapter, Command, Reply> commandAdapter = (CommandAdapter, Command, Reply>) commandAdapters.get( + command.getType() + ); + if (commandAdapter != null) { + commandObs = commandAdapter.adapt(command); + } else { + commandObs = Single.just(command); + } + commandObs + .flatMapCompletable(adaptedCommand -> { + CommandHandler, Reply> commandHandler = (CommandHandler, Reply>) commandHandlers.get( + adaptedCommand.getType() + ); + if (expectHelloCommand() && !active && !Objects.equals(adaptedCommand.getType(), HelloCommand.COMMAND_TYPE)) { + webSocket.close((short) 1002, "Hello Command is first expected to initialize the exchange channel").subscribe(); + emitter.onError(new ChannelInitializationException("Hello Command is first expected to initialize the channel")); + } else if (Objects.equals(adaptedCommand.getType(), HelloCommand.COMMAND_TYPE)) { + return handleHelloCommand(emitter, adaptedCommand, commandHandler); + } else if (Objects.equals(adaptedCommand.getType(), GoodByeCommand.COMMAND_TYPE)) { + return handleGoodByeCommand(adaptedCommand, commandHandler); + } else if (commandHandler != null) { + return handleCommandAsync(adaptedCommand, commandHandler); + } else { + log.info("No handler found for command type {}. Ignoring", adaptedCommand.getType()); + return writeReply( + new NoReply( + adaptedCommand.getId(), + "No handler found for command type %s. Ignoring".formatted(adaptedCommand.getType()) + ) + ); + } + return Completable.complete(); + }) + .subscribe(); + } + + protected abstract boolean expectHelloCommand(); + + private void receiveReply(final Reply reply) { + SingleEmitter> replyEmitter = resultEmitters.remove(reply.getCommandId()); + if (replyEmitter != null) { + Single> replyObs; + ReplyAdapter, Reply> replyAdapter = (ReplyAdapter, Reply>) replyAdapters.get(reply.getType()); + if (replyAdapter != null) { + replyObs = replyAdapter.adapt(reply); + } else { + replyObs = Single.just(reply); + } + replyObs + .doOnSuccess(adaptedReply -> { + if (adaptedReply instanceof UnknownReply) { + replyEmitter.onError(new ChannelUnknownCommandException(adaptedReply.getErrorDetails())); + } else if (adaptedReply instanceof NoReply || adaptedReply instanceof IgnoredReply) { + replyEmitter.onError(new ChannelNoReplyException(adaptedReply.getErrorDetails())); + } else { + ((SingleEmitter>) replyEmitter).onSuccess(adaptedReply); + } + if (adaptedReply.stopOnErrorStatus() && adaptedReply.getCommandStatus() == ERROR) { + webSocket.close().subscribe(); + } + }) + .doOnError(throwable -> { + log.warn("Unable to handle reply [{}, {}]", reply.getType(), reply.getCommandId()); + replyEmitter.onError(new ChannelReplyException(throwable)); + }) + .subscribe(); + } + } + + @Override + public Completable close() { + return Completable.fromRunnable(() -> { + webSocket.close((short) 1000).subscribe(); + this.cleanChannel(); + }); + } + + protected void cleanChannel() { + this.active = false; + this.resultEmitters.forEach((type, emitter) -> { + if (!emitter.isDisposed()) { + emitter.onError(new ChannelClosedException()); + } + }); + this.resultEmitters.clear(); + + if (pingTaskId != -1) { + this.vertx.cancelTimer(this.pingTaskId); + this.pingTaskId = -1; + } + if (webSocket != null && !webSocket.isClosed()) { + this.webSocket.close((short) 1011).subscribe(); + } + } + + /** + * Method call to handle initialize command type + */ + protected Completable handleHelloCommand( + final CompletableEmitter emitter, + final Command command, + final CommandHandler, Reply> commandHandler + ) { + if (commandHandler != null) { + return handleCommand(command, commandHandler, false) + .doOnSuccess(reply -> { + if (reply.getCommandStatus() == CommandStatus.SUCCEEDED) { + Payload payload = reply.getPayload(); + if (payload instanceof HelloReplyPayload helloReplyPayload) { + this.targetId = helloReplyPayload.getTargetId(); + this.active = true; + startPingTask(); + emitter.onComplete(); + } else { + emitter.onError(new ChannelInitializationException("Unable to parse hello reply payload")); + } + } + }) + .ignoreElement(); + } else { + return Completable.fromRunnable(() -> { + startPingTask(); + emitter.onComplete(); + }); + } + } + + private void startPingTask() { + this.pingTaskId = + this.vertx.setPeriodic( + PING_DELAY, + timerId -> { + if (!this.webSocket.isClosed()) { + this.webSocket.writePing(Buffer.buffer()).subscribe(); + } + } + ); + } + + /** + * Method call to handle custom command type + */ + protected Completable handleGoodByeCommand(final Command command, final CommandHandler, Reply> commandHandler) { + if (commandHandler != null) { + return handleCommand(command, commandHandler, true) + .doOnSuccess(reply -> { + if (reply.getCommandStatus() == CommandStatus.SUCCEEDED) { + Payload payload = command.getPayload(); + if (payload instanceof GoodByeCommandPayload goodByeCommandPayload && goodByeCommandPayload.isReconnect()) { + webSocket.close((short) 1013, "GoodBye Command with reconnection requested.").subscribe(); + } else { + webSocket.close((short) 1000, "GoodBye Command without reconnection.").subscribe(); + } + } + }) + .doFinally(this::cleanChannel) + .ignoreElement(); + } else { + return Completable.fromRunnable(() -> { + webSocket.close((short) 1013).subscribe(); + this.cleanChannel(); + }); + } + } + + protected Completable handleCommandAsync(final Command command, final CommandHandler, Reply> commandHandler) { + return handleCommand(command, commandHandler, false).ignoreElement(); + } + + protected Single> handleCommand( + final Command command, + final CommandHandler, Reply> commandHandler, + boolean dontReply + ) { + return commandHandler + .handle(command) + .flatMap(reply -> { + if (!dontReply) { + return writeReply(reply).andThen(Single.just(reply)); + } + return Single.just(reply); + }) + .doOnError(throwable -> { + log.warn("Unable to handle command [{}, {}]", command.getType(), command.getId()); + webSocket.close((short) 1011, "Unexpected error").subscribe(); + }); + } + + @Override + public , R extends Reply> Single send(final C command) { + return send(command, false); + } + + protected Single sendHelloCommand(final HelloCommand helloCommand) { + return send(helloCommand, true); + } + + protected , R extends Reply> Single send(final C command, final boolean ignoreActiveStatus) { + return Single + .defer(() -> { + if (!ignoreActiveStatus && !active) { + return Single.error(new ChannelInactiveException()); + } + CommandAdapter, R> commandAdapter = (CommandAdapter, R>) commandAdapters.get(command.getType()); + if (commandAdapter != null) { + return commandAdapter.adapt(command); + } else { + return Single.just(command); + } + }) + .flatMap(adaptedCommand -> + Single + .create(emitter -> { + resultEmitters.put(adaptedCommand.getId(), emitter); + writeCommand(adaptedCommand).doOnError(emitter::onError).onErrorComplete().subscribe(); + }) + .timeout( + adaptedCommand.getReplyTimeoutMs(), + TimeUnit.MILLISECONDS, + Single.error(() -> { + log.warn("No reply received in time for command [{}, {}]", adaptedCommand.getType(), adaptedCommand.getId()); + throw new ChannelTimeoutException(); + }) + ) + ) + .onErrorResumeNext(throwable -> { + CommandAdapter, R> commandAdapter = (CommandAdapter, R>) commandAdapters.get(command.getType()); + if (commandAdapter != null) { + return commandAdapter.onError(command, throwable); + } else { + return Single.error(throwable); + } + }) + // Cleanup result emitters list if cancelled by the upstream. + .doOnDispose(() -> resultEmitters.remove(command.getId())); + } + + protected > Completable writeCommand(C command) { + ProtocolExchange protocolExchange = ProtocolExchange + .builder() + .type(ProtocolExchange.Type.COMMAND) + .exchangeType(command.getType()) + .exchange(command) + .build(); + return writeToSocket(command.getId(), protocolExchange); + } + + protected > Completable writeReply(R reply) { + return Single + .defer(() -> { + ReplyAdapter> replyAdapter = (ReplyAdapter>) replyAdapters.get(reply.getType()); + if (replyAdapter != null) { + return replyAdapter.adapt(reply); + } else { + return Single.just(reply); + } + }) + .flatMapCompletable(adaptedReply -> { + ProtocolExchange protocolExchange = ProtocolExchange + .builder() + .type(ProtocolExchange.Type.REPLY) + .exchangeType(adaptedReply.getType()) + .exchange(adaptedReply) + .build(); + return writeToSocket(adaptedReply.getCommandId(), protocolExchange); + }); + } + + private Completable writeToSocket(final String commandId, final ProtocolExchange websocketExchange) { + if (!webSocket.isClosed()) { + return webSocket + .writeBinaryMessage(protocolAdapter.write(websocketExchange)) + .doOnComplete(() -> + log.debug("Write command/reply [{}, {}] to websocket successfully", websocketExchange.exchangeType(), commandId) + ) + .onErrorResumeNext(throwable -> { + log.error("An error occurred when trying to send command/reply [{}, {}]", websocketExchange.exchangeType(), commandId); + return Completable.error(new Exception("Write to socket failed")); + }); + } else { + return Completable.error(new ChannelClosedException()); + } + } + + @Override + public void addCommandHandlers(final List, ? extends Reply>> commandHandlers) { + if (commandHandlers != null) { + commandHandlers.forEach(commandHandler -> this.commandHandlers.putIfAbsent(commandHandler.supportType(), commandHandler)); + } + } + + public void addCommandAdapters( + final List, ? extends Command, ? extends Reply>> commandAdapters + ) { + if (commandAdapters != null) { + commandAdapters.forEach(commandAdapter -> this.commandAdapters.putIfAbsent(commandAdapter.supportType(), commandAdapter)); + } + } + + public void addReplyAdapters(final List, ? extends Reply>> replyAdapters) { + if (replyAdapters != null) { + replyAdapters.forEach(replyAdapter -> this.replyAdapters.putIfAbsent(replyAdapter.supportType(), replyAdapter)); + } + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/DefaultExchangeSerDe.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/DefaultExchangeSerDe.java new file mode 100644 index 0000000..9104831 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/DefaultExchangeSerDe.java @@ -0,0 +1,182 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.command; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidTypeIdException; +import com.fasterxml.jackson.databind.jsontype.NamedType; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.Exchange; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.goodbye.GoodByeCommand; +import io.gravitee.exchange.api.command.goodbye.GoodByeReply; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckCommand; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckReply; +import io.gravitee.exchange.api.command.hello.HelloCommand; +import io.gravitee.exchange.api.command.hello.HelloReply; +import io.gravitee.exchange.api.command.noreply.NoReply; +import io.gravitee.exchange.api.command.primary.PrimaryCommand; +import io.gravitee.exchange.api.command.primary.PrimaryReply; +import io.gravitee.exchange.api.command.unknown.UnknownCommand; +import io.gravitee.exchange.api.command.unknown.UnknownReply; +import io.gravitee.exchange.api.websocket.command.exception.DeserializationException; +import io.gravitee.exchange.api.websocket.command.exception.SerializationException; +import io.gravitee.exchange.api.websocket.protocol.ProtocolVersion; +import io.gravitee.exchange.api.websocket.protocol.legacy.ignored.IgnoredReply; +import java.util.HashMap; +import java.util.Map; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class DefaultExchangeSerDe implements ExchangeSerDe { + + private static final Map>> DEFAULT_COMMAND_TYPE = Map.of( + // Command + HelloCommand.COMMAND_TYPE, + HelloCommand.class, + GoodByeCommand.COMMAND_TYPE, + GoodByeCommand.class, + io.gravitee.exchange.api.websocket.protocol.legacy.hello.HelloCommand.COMMAND_TYPE, + io.gravitee.exchange.api.websocket.protocol.legacy.hello.HelloCommand.class, + io.gravitee.exchange.api.websocket.protocol.legacy.goodbye.GoodByeCommand.COMMAND_TYPE, + io.gravitee.exchange.api.websocket.protocol.legacy.goodbye.GoodByeCommand.class, + HealthCheckCommand.COMMAND_TYPE, + HealthCheckCommand.class, + PrimaryCommand.COMMAND_TYPE, + PrimaryCommand.class, + UnknownCommand.COMMAND_TYPE, + UnknownCommand.class + ); + + private static final Map>> DEFAULT_REPLY_TYPE = Map.of( + HelloCommand.COMMAND_TYPE, + HelloReply.class, + GoodByeCommand.COMMAND_TYPE, + GoodByeReply.class, + io.gravitee.exchange.api.websocket.protocol.legacy.hello.HelloReply.COMMAND_TYPE, + io.gravitee.exchange.api.websocket.protocol.legacy.hello.HelloReply.class, + io.gravitee.exchange.api.websocket.protocol.legacy.goodbye.GoodByeReply.COMMAND_TYPE, + io.gravitee.exchange.api.websocket.protocol.legacy.goodbye.GoodByeReply.class, + HealthCheckCommand.COMMAND_TYPE, + HealthCheckReply.class, + PrimaryCommand.COMMAND_TYPE, + PrimaryReply.class, + NoReply.COMMAND_TYPE, + NoReply.class, + IgnoredReply.COMMAND_TYPE, + IgnoredReply.class, + UnknownCommand.COMMAND_TYPE, + UnknownReply.class + ); + private final ObjectMapper objectMapper; + + public DefaultExchangeSerDe(final ObjectMapper objectMapper) { + this(objectMapper, null, null); + } + + public DefaultExchangeSerDe( + final ObjectMapper objectMapper, + final Map>> customCommandTypes, + final Map>> customReplyTypes + ) { + this.objectMapper = objectMapper; + + registerCommandTypes(objectMapper, customCommandTypes); + registerReplyTypes(objectMapper, customReplyTypes); + } + + private void registerCommandTypes(final ObjectMapper objectMapper, final Map>> customCommandTypes) { + Map>> commandTypes = new HashMap<>(DEFAULT_COMMAND_TYPE); + if (customCommandTypes != null) { + commandTypes.putAll(customCommandTypes); + } + commandTypes.forEach((type, aClass) -> objectMapper.registerSubtypes(new NamedType(aClass, type))); + } + + private void registerReplyTypes(final ObjectMapper objectMapper, final Map>> customReplyTypes) { + Map>> replyTypes = new HashMap<>(DEFAULT_REPLY_TYPE); + if (customReplyTypes != null) { + replyTypes.putAll(customReplyTypes); + } + replyTypes.forEach((type, aClass) -> objectMapper.registerSubtypes(new NamedType(aClass, type))); + } + + @Override + public > C deserializeAsCommand(final ProtocolVersion protocolVersion, final String dataType, final String data) + throws DeserializationException { + return (C) readCommand(data, Command.class); + } + + protected > C readCommand(final String data, Class clazz) { + try { + return readJson(data, clazz); + } catch (InvalidTypeIdException e) { + return (C) unknownCommand(); + } catch (JsonProcessingException e) { + throw new DeserializationException(e); + } + } + + protected Command unknownCommand() { + return new UnknownCommand(); + } + + @Override + public > R deserializeAsReply(final ProtocolVersion protocolVersion, final String dataType, final String data) + throws DeserializationException { + return (R) readReply(data, Reply.class); + } + + protected > R readReply(final String data, Class clazz) { + try { + return readJson(data, clazz); + } catch (InvalidTypeIdException e) { + return (R) unknownReply(); + } catch (JsonProcessingException e) { + throw new DeserializationException(e); + } + } + + private static UnknownReply unknownReply() { + return new UnknownReply(null, "Unknown reply type"); + } + + protected T readJson(final String jsonAsString, Class clazz) throws JsonProcessingException { + if (jsonAsString != null) { + return objectMapper.readValue(jsonAsString, clazz); + } + return null; + } + + @Override + public String serialize(final ProtocolVersion protocolVersion, final Exchange exchange) throws SerializationException { + return writeJson(exchange); + } + + protected String writeJson(final Object object) { + try { + if (object != null) { + return objectMapper.writeValueAsString(object); + } + return null; + } catch (JsonProcessingException e) { + throw new SerializationException(e); + } + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/ExchangeSerDe.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/ExchangeSerDe.java new file mode 100644 index 0000000..956274d --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/ExchangeSerDe.java @@ -0,0 +1,35 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.command; + +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.Exchange; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.websocket.command.exception.DeserializationException; +import io.gravitee.exchange.api.websocket.command.exception.SerializationException; +import io.gravitee.exchange.api.websocket.protocol.ProtocolVersion; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface ExchangeSerDe { + > C deserializeAsCommand(final ProtocolVersion protocolVersion, final String exchangeType, final String exchange) + throws DeserializationException; + > R deserializeAsReply(final ProtocolVersion protocolVersion, final String exchangeType, final String exchange) + throws DeserializationException; + String serialize(final ProtocolVersion protocolVersion, final Exchange exchange) throws SerializationException; +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/exception/DeserializationException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/exception/DeserializationException.java new file mode 100644 index 0000000..1148e83 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/exception/DeserializationException.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.command.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class DeserializationException extends RuntimeException { + + public DeserializationException(final Throwable throwable) { + super(throwable); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/exception/SerializationException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/exception/SerializationException.java new file mode 100644 index 0000000..1b257dc --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/exception/SerializationException.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.command.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class SerializationException extends RuntimeException { + + public SerializationException(final Throwable throwable) { + super(throwable); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/ProtocolAdapter.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/ProtocolAdapter.java new file mode 100644 index 0000000..1cddb24 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/ProtocolAdapter.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol; + +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.vertx.rxjava3.core.buffer.Buffer; +import java.util.List; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface ProtocolAdapter { + default List, ? extends Reply>> commandHandlers() { + return List.of(); + } + + default List, ? extends Command, ? extends Reply>> commandAdapters() { + return List.of(); + } + + default List, ? extends Reply>> replyAdapters() { + return List.of(); + } + + Buffer write(final ProtocolExchange websocketExchange); + + ProtocolExchange read(final Buffer buffer); +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/ProtocolExchange.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/ProtocolExchange.java new file mode 100644 index 0000000..8561edd --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/ProtocolExchange.java @@ -0,0 +1,56 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol; + +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.Exchange; +import io.gravitee.exchange.api.command.Reply; +import javax.annotation.Nullable; +import lombok.Builder; +import lombok.Getter; +import lombok.experimental.Accessors; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Builder +@Getter +@Accessors(fluent = true) +public class ProtocolExchange { + + @Builder.Default + private final Type type = Type.UNKNOWN; + + @Nullable + private final String exchangeType; + + private final Exchange exchange; + + public > C asCommand() { + return (C) exchange; + } + + public > R asReply() { + return (R) exchange; + } + + public enum Type { + COMMAND, + REPLY, + UNKNOWN, + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/ProtocolVersion.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/ProtocolVersion.java new file mode 100644 index 0000000..3ff823f --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/ProtocolVersion.java @@ -0,0 +1,52 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol; + +import io.gravitee.exchange.api.websocket.command.ExchangeSerDe; +import io.gravitee.exchange.api.websocket.protocol.legacy.LegacyProtocolAdapter; +import io.gravitee.exchange.api.websocket.protocol.v1.V1ProtocolAdapter; +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Function; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@RequiredArgsConstructor +@Getter +@Accessors(fluent = true) +public enum ProtocolVersion { + LEGACY("legacy", LegacyProtocolAdapter::new), + V1("v1", V1ProtocolAdapter::new); + + private final String version; + private final Function adapterFactory; + + public static ProtocolVersion parse(final String version) { + if (version == null) { + return LEGACY; + } + return Arrays + .stream(ProtocolVersion.values()) + .filter(protocolVersion -> Objects.equals(protocolVersion.version, version)) + .findFirst() + .orElse(ProtocolVersion.LEGACY); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/LegacyProtocolAdapter.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/LegacyProtocolAdapter.java new file mode 100644 index 0000000..379c028 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/LegacyProtocolAdapter.java @@ -0,0 +1,118 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy; + +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.command.primary.PrimaryCommand; +import io.gravitee.exchange.api.command.primary.PrimaryCommandPayload; +import io.gravitee.exchange.api.websocket.command.ExchangeSerDe; +import io.gravitee.exchange.api.websocket.protocol.ProtocolAdapter; +import io.gravitee.exchange.api.websocket.protocol.ProtocolExchange; +import io.gravitee.exchange.api.websocket.protocol.ProtocolVersion; +import io.gravitee.exchange.api.websocket.protocol.legacy.goodbye.GoodyeCommandAdapter; +import io.gravitee.exchange.api.websocket.protocol.legacy.goodbye.LegacyGoodByeReplyAdapter; +import io.gravitee.exchange.api.websocket.protocol.legacy.healthcheck.HealthCheckCommandAdapter; +import io.gravitee.exchange.api.websocket.protocol.legacy.hello.HelloCommandAdapter; +import io.gravitee.exchange.api.websocket.protocol.legacy.hello.HelloReplyAdapter; +import io.gravitee.exchange.api.websocket.protocol.legacy.hello.LegacyHelloReplyAdapter; +import io.gravitee.exchange.api.websocket.protocol.legacy.ignored.NoReplyAdapter; +import io.gravitee.exchange.api.websocket.protocol.legacy.primary.PrimaryCommandAdapter; +import io.vertx.rxjava3.core.buffer.Buffer; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ + +@RequiredArgsConstructor +public class LegacyProtocolAdapter implements ProtocolAdapter { + + private static final String COMMAND_PREFIX = "command: "; + private static final String REPLY_PREFIX = "reply: "; + private static final String PRIMARY_MESSAGE = "primary: true"; + private static final String REPLICA_MESSAGE = "replica: true"; + private final ExchangeSerDe exchangeSerDe; + + @Override + public List, ? extends Command, ? extends Reply>> commandAdapters() { + return List.of(new HelloCommandAdapter(), new GoodyeCommandAdapter(), new HealthCheckCommandAdapter(), new PrimaryCommandAdapter()); + } + + @Override + public List, ? extends Reply>> replyAdapters() { + return List.of(new HelloReplyAdapter(), new LegacyHelloReplyAdapter(), new LegacyGoodByeReplyAdapter(), new NoReplyAdapter()); + } + + @Override + public Buffer write(final ProtocolExchange websocketExchange) { + if (websocketExchange.type() == ProtocolExchange.Type.COMMAND) { + if (Objects.equals(websocketExchange.exchangeType(), PrimaryCommand.COMMAND_TYPE)) { + PrimaryCommand primaryCommand = (PrimaryCommand) websocketExchange.exchange(); + if (primaryCommand.getPayload().primary()) { + return Buffer.buffer(PRIMARY_MESSAGE); + } else { + return Buffer.buffer(REPLICA_MESSAGE); + } + } else { + return Buffer.buffer(COMMAND_PREFIX + exchangeSerDe.serialize(ProtocolVersion.LEGACY, websocketExchange.exchange())); + } + } else if (websocketExchange.type() == ProtocolExchange.Type.REPLY) { + return Buffer.buffer(REPLY_PREFIX + exchangeSerDe.serialize(ProtocolVersion.LEGACY, websocketExchange.exchange())); + } + return Buffer.buffer(); + } + + @Override + public ProtocolExchange read(final Buffer buffer) { + String incoming = buffer.toString(); + if (incoming.startsWith(PRIMARY_MESSAGE)) { + return ProtocolExchange + .builder() + .type(ProtocolExchange.Type.COMMAND) + .exchangeType(PrimaryCommand.COMMAND_TYPE) + .exchange(new PrimaryCommand(new PrimaryCommandPayload(true))) + .build(); + } else if (incoming.startsWith(REPLICA_MESSAGE)) { + return ProtocolExchange + .builder() + .type(ProtocolExchange.Type.COMMAND) + .exchangeType(PrimaryCommand.COMMAND_TYPE) + .exchange(new PrimaryCommand(new PrimaryCommandPayload(false))) + .build(); + } else if (incoming.startsWith(COMMAND_PREFIX)) { + String exchange = incoming.replace(COMMAND_PREFIX, ""); + return ProtocolExchange + .builder() + .type(ProtocolExchange.Type.COMMAND) + .exchange(exchangeSerDe.deserializeAsCommand(ProtocolVersion.LEGACY, null, exchange)) + .build(); + } else if (incoming.startsWith(REPLY_PREFIX)) { + String exchange = incoming.replace(REPLY_PREFIX, ""); + return ProtocolExchange + .builder() + .type(ProtocolExchange.Type.REPLY) + .exchange(exchangeSerDe.deserializeAsReply(ProtocolVersion.LEGACY, null, exchange)) + .build(); + } + return ProtocolExchange.builder().type(ProtocolExchange.Type.UNKNOWN).build(); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeCommand.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeCommand.java new file mode 100644 index 0000000..094ab5a --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeCommand.java @@ -0,0 +1,32 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.goodbye; + +import io.gravitee.exchange.api.command.Command; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class GoodByeCommand extends Command { + + public static final String COMMAND_TYPE = "GOODBYE_COMMAND"; + + public GoodByeCommand(String id) { + super(COMMAND_TYPE); + this.id = id; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeCommandPayload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeCommandPayload.java new file mode 100644 index 0000000..6a096e4 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeCommandPayload.java @@ -0,0 +1,26 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.goodbye; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Payload; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GoodByeCommandPayload() implements Payload {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeReply.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeReply.java new file mode 100644 index 0000000..e425339 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeReply.java @@ -0,0 +1,47 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.goodbye; + +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import lombok.Getter; +import lombok.Setter; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class GoodByeReply extends Reply { + + public static final String COMMAND_TYPE = "GOODBYE_REPLY"; + + @Setter + @Getter + private String installationId; + + public GoodByeReply() { + this(null, null); + } + + public GoodByeReply(String commandId, CommandStatus commandStatus) { + super(COMMAND_TYPE, commandId, commandStatus); + } + + @Override + public boolean stopOnErrorStatus() { + return true; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeReplyPayload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeReplyPayload.java new file mode 100644 index 0000000..a9d0d58 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeReplyPayload.java @@ -0,0 +1,26 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.goodbye; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.gravitee.exchange.api.command.Payload; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GoodByeReplyPayload(String installationId) implements Payload {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodyeCommandAdapter.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodyeCommandAdapter.java new file mode 100644 index 0000000..844acf6 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodyeCommandAdapter.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.goodbye; + +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.goodbye.GoodByeReply; +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class GoodyeCommandAdapter + implements CommandAdapter { + + @Override + public String supportType() { + return io.gravitee.exchange.api.command.goodbye.GoodByeCommand.COMMAND_TYPE; + } + + @Override + public Single adapt(final io.gravitee.exchange.api.command.goodbye.GoodByeCommand command) { + return Single.just(new GoodByeCommand(command.getId())); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/LegacyGoodByeReplyAdapter.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/LegacyGoodByeReplyAdapter.java new file mode 100644 index 0000000..eda80b3 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/LegacyGoodByeReplyAdapter.java @@ -0,0 +1,49 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.goodbye; + +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.command.goodbye.GoodByeReplyPayload; +import io.gravitee.exchange.api.websocket.protocol.legacy.hello.HelloReply; +import io.gravitee.exchange.api.websocket.protocol.legacy.hello.HelloReplyPayload; +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class LegacyGoodByeReplyAdapter implements ReplyAdapter { + + @Override + public String supportType() { + return GoodByeReply.COMMAND_TYPE; + } + + @Override + public Single adapt(final GoodByeReply reply) { + return Single.fromCallable(() -> { + if (reply.getCommandStatus() == CommandStatus.SUCCEEDED) { + return new io.gravitee.exchange.api.command.goodbye.GoodByeReply( + reply.getCommandId(), + new GoodByeReplyPayload(reply.getInstallationId()) + ); + } else { + return new io.gravitee.exchange.api.command.goodbye.GoodByeReply(reply.getCommandId(), reply.getErrorDetails()); + } + }); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/healthcheck/HealthCheckCommandAdapter.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/healthcheck/HealthCheckCommandAdapter.java new file mode 100644 index 0000000..6a2bb76 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/healthcheck/HealthCheckCommandAdapter.java @@ -0,0 +1,54 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.healthcheck; + +import io.gravitee.exchange.api.channel.exception.ChannelTimeoutException; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckCommand; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckReply; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckReplyPayload; +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class HealthCheckCommandAdapter implements CommandAdapter { + + @Override + public String supportType() { + return HealthCheckCommand.COMMAND_TYPE; + } + + @Override + public Single adapt(final HealthCheckCommand command) { + return Single.fromCallable(() -> { + command.setReplyTimeoutMs(0); + return command; + }); + } + + @Override + public Single onError(final Command command, final Throwable throwable) { + return Single.defer(() -> { + if (throwable instanceof ChannelTimeoutException) { + return Single.just(new HealthCheckReply(command.getId(), new HealthCheckReplyPayload(true, null))); + } + return Single.error(throwable); + }); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloCommand.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloCommand.java new file mode 100644 index 0000000..4e929ea --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloCommand.java @@ -0,0 +1,36 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.hello; + +import io.gravitee.exchange.api.command.Command; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class HelloCommand extends Command { + + public static final String COMMAND_TYPE = "HELLO_COMMAND"; + + public HelloCommand() { + super(COMMAND_TYPE); + } + + public HelloCommand(HelloCommandPayload payload) { + this(); + this.payload = payload; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloCommandAdapter.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloCommandAdapter.java new file mode 100644 index 0000000..8ed4f3d --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloCommandAdapter.java @@ -0,0 +1,39 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.hello; + +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.hello.HelloReply; +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class HelloCommandAdapter implements CommandAdapter { + + @Override + public String supportType() { + return io.gravitee.exchange.api.command.hello.HelloCommand.COMMAND_TYPE; + } + + @Override + public Single adapt(final io.gravitee.exchange.api.command.hello.HelloCommand command) { + return Single.just( + new HelloCommand(new HelloCommandPayload(new HelloCommandPayload.LegacyNode(command.getPayload().getTargetId()))) + ); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloCommandPayload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloCommandPayload.java new file mode 100644 index 0000000..a828586 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloCommandPayload.java @@ -0,0 +1,26 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.hello; + +import io.gravitee.exchange.api.command.Payload; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public record HelloCommandPayload(LegacyNode node) implements Payload { + public record LegacyNode(String installationId) {} +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloReply.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloReply.java new file mode 100644 index 0000000..39caf98 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloReply.java @@ -0,0 +1,54 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.hello; + +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import lombok.Getter; +import lombok.Setter; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class HelloReply extends Reply { + + public static final String COMMAND_TYPE = "HELLO_REPLY"; + + @Getter + @Setter + protected String message; + + public HelloReply() { + super(COMMAND_TYPE); + } + + public HelloReply(String commandId, String errorDetails) { + super(COMMAND_TYPE, commandId, CommandStatus.ERROR); + this.message = errorDetails; + this.errorDetails = errorDetails; + } + + public HelloReply(String commandId, HelloReplyPayload helloReplyPayload) { + super(COMMAND_TYPE, commandId, CommandStatus.SUCCEEDED); + this.payload = helloReplyPayload; + } + + @Override + public boolean stopOnErrorStatus() { + return true; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloReplyAdapter.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloReplyAdapter.java new file mode 100644 index 0000000..1d02f49 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloReplyAdapter.java @@ -0,0 +1,44 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.hello; + +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.command.hello.HelloCommand; +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class HelloReplyAdapter implements ReplyAdapter { + + @Override + public String supportType() { + return HelloCommand.COMMAND_TYPE; + } + + @Override + public Single adapt(final io.gravitee.exchange.api.command.hello.HelloReply helloReply) { + return Single.fromCallable(() -> { + if (helloReply.getCommandStatus() == CommandStatus.SUCCEEDED) { + return new HelloReply(helloReply.getCommandId(), new HelloReplyPayload(helloReply.getPayload().getTargetId())); + } else { + return new HelloReply(helloReply.getCommandId(), helloReply.getErrorDetails()); + } + }); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloReplyPayload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloReplyPayload.java new file mode 100644 index 0000000..de8088a --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloReplyPayload.java @@ -0,0 +1,24 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.hello; + +import io.gravitee.exchange.api.command.Payload; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public record HelloReplyPayload(String installationId) implements Payload {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/LegacyHelloReplyAdapter.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/LegacyHelloReplyAdapter.java new file mode 100644 index 0000000..ba00090 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/LegacyHelloReplyAdapter.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.hello; + +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.command.hello.HelloReplyPayload; +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class LegacyHelloReplyAdapter implements ReplyAdapter { + + @Override + public String supportType() { + return HelloReply.COMMAND_TYPE; + } + + @Override + public Single adapt(final HelloReply helloReply) { + return Single.just( + new io.gravitee.exchange.api.command.hello.HelloReply( + helloReply.getCommandId(), + new HelloReplyPayload(helloReply.getPayload().installationId()) + ) + ); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/ignored/IgnoredReply.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/ignored/IgnoredReply.java new file mode 100644 index 0000000..5cbc924 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/ignored/IgnoredReply.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.ignored; + +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.noreply.NoReplyPayload; +import lombok.Getter; +import lombok.Setter; + +/** + * Only used with legacy protocol version + * + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Deprecated +public class IgnoredReply extends Reply { + + public static final String COMMAND_TYPE = "IGNORED_REPLY"; + + @Getter + @Setter + protected String message; + + public IgnoredReply() { + super(COMMAND_TYPE); + } + + public IgnoredReply(final String commandId) { + super(COMMAND_TYPE, commandId, CommandStatus.ERROR); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/ignored/NoReplyAdapter.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/ignored/NoReplyAdapter.java new file mode 100644 index 0000000..7a880e6 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/ignored/NoReplyAdapter.java @@ -0,0 +1,37 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.ignored; + +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.command.noreply.NoReply; +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class NoReplyAdapter implements ReplyAdapter { + + @Override + public String supportType() { + return NoReply.COMMAND_TYPE; + } + + @Override + public Single adapt(final NoReply noReply) { + return Single.just(new IgnoredReply(noReply.getCommandId())); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/primary/PrimaryCommandAdapter.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/primary/PrimaryCommandAdapter.java new file mode 100644 index 0000000..e8b9465 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/primary/PrimaryCommandAdapter.java @@ -0,0 +1,54 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.legacy.primary; + +import io.gravitee.exchange.api.channel.exception.ChannelTimeoutException; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.primary.PrimaryCommand; +import io.gravitee.exchange.api.command.primary.PrimaryReply; +import io.gravitee.exchange.api.command.primary.PrimaryReplyPayload; +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class PrimaryCommandAdapter implements CommandAdapter { + + @Override + public String supportType() { + return PrimaryCommand.COMMAND_TYPE; + } + + @Override + public Single adapt(final PrimaryCommand command) { + return Single.fromCallable(() -> { + command.setReplyTimeoutMs(0); + return command; + }); + } + + @Override + public Single onError(final Command command, final Throwable throwable) { + return Single.defer(() -> { + if (throwable instanceof ChannelTimeoutException) { + return Single.just(new PrimaryReply(command.getId(), new PrimaryReplyPayload())); + } + return Single.error(throwable); + }); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/v1/V1ProtocolAdapter.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/v1/V1ProtocolAdapter.java new file mode 100644 index 0000000..cd820f0 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/v1/V1ProtocolAdapter.java @@ -0,0 +1,91 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.protocol.v1; + +import io.gravitee.exchange.api.websocket.command.ExchangeSerDe; +import io.gravitee.exchange.api.websocket.protocol.ProtocolAdapter; +import io.gravitee.exchange.api.websocket.protocol.ProtocolExchange; +import io.gravitee.exchange.api.websocket.protocol.ProtocolVersion; +import io.vertx.rxjava3.core.buffer.Buffer; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@RequiredArgsConstructor +public class V1ProtocolAdapter implements ProtocolAdapter { + + private static final String TYPE_PREFIX = "t:"; + private static final String EXCHANGE_TYPE_PREFIX = "et:"; + private static final String EXCHANGE_PREFIX = "e:"; + private static final String SEPARATOR = ";;"; + private final ExchangeSerDe exchangeSerDe; + + @Override + public Buffer write(ProtocolExchange websocketExchange) { + List event = new ArrayList<>(); + if (websocketExchange.type() != null) { + event.add(TYPE_PREFIX + websocketExchange.type().name()); + } + if (websocketExchange.exchangeType() != null) { + event.add(EXCHANGE_TYPE_PREFIX + websocketExchange.exchangeType()); + } + if (websocketExchange.exchange() != null) { + event.add(EXCHANGE_PREFIX + exchangeSerDe.serialize(ProtocolVersion.V1, websocketExchange.exchange())); + } + return Buffer.buffer(String.join(SEPARATOR, event)); + } + + @Override + public ProtocolExchange read(Buffer buffer) { + final String bufferStr = buffer.toString(); + final String[] lines = bufferStr.split(SEPARATOR); + ProtocolExchange.Type type = ProtocolExchange.Type.UNKNOWN; + String exchangeType = null; + String exchange = null; + for (String line : lines) { + if (line.startsWith(TYPE_PREFIX)) { + try { + type = ProtocolExchange.Type.valueOf(extractFrom(line, TYPE_PREFIX)); + } catch (Exception e) { + type = ProtocolExchange.Type.UNKNOWN; + } + } else if (line.startsWith(EXCHANGE_TYPE_PREFIX)) { + exchangeType = extractFrom(line, EXCHANGE_TYPE_PREFIX); + } else if (line.startsWith(EXCHANGE_PREFIX)) { + exchange = extractFrom(line, EXCHANGE_PREFIX); + } + } + + ProtocolExchange.ProtocolExchangeBuilder exchangeObjectBuilder = ProtocolExchange.builder().type(type).exchangeType(exchangeType); + + if (exchange != null) { + if (ProtocolExchange.Type.COMMAND == type) { + exchangeObjectBuilder.exchange(exchangeSerDe.deserializeAsCommand(ProtocolVersion.V1, exchangeType, exchange)); + } else if (ProtocolExchange.Type.REPLY == type) { + exchangeObjectBuilder.exchange(exchangeSerDe.deserializeAsReply(ProtocolVersion.V1, exchangeType, exchange)); + } + } + return exchangeObjectBuilder.build(); + } + + private String extractFrom(final String line, final String prefix) { + return line.substring(prefix.length()).trim(); + } +} diff --git a/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/configuration/IdentifyConfigurationTest.java b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/configuration/IdentifyConfigurationTest.java new file mode 100644 index 0000000..307029a --- /dev/null +++ b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/configuration/IdentifyConfigurationTest.java @@ -0,0 +1,201 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class IdentifyConfigurationTest { + + private MockEnvironment environment; + private IdentifyConfiguration cut; + + @BeforeEach + public void beforeEach() { + environment = new MockEnvironment(); + } + + @Nested + class DefaultPrefix { + + @BeforeEach + public void beforeEach() { + cut = new IdentifyConfiguration(environment); + } + + @Test + void should_be_default() { + assertThat(cut.environment()).isEqualTo(environment); + assertThat(cut.id()).isEqualTo("exchange"); + } + + @Test + void should_contain_property() { + environment.withProperty("exchange.key", "value"); + assertThat(cut.containsProperty("key")).isTrue(); + } + + @Test + void should_get_property() { + environment.withProperty("exchange.key", "value"); + assertThat(cut.getProperty("key")).isEqualTo("value"); + } + + @Test + void should_get_property_list() { + environment.withProperty("exchange.key[0]", "value"); + assertThat(cut.getPropertyList("key")).containsOnly("value"); + } + + @Test + void should_not_get_property_with_wrong_prefix() { + environment.withProperty("wrong.key", "value"); + assertThat(cut.getProperty("key")).isNull(); + } + + @Test + void should_get_custom_property() { + environment.withProperty("exchange.key", "123"); + assertThat(cut.getProperty("key", Integer.class, 0)).isEqualTo(123); + } + + @Test + void should_get_default_value_for_custom_property() { + assertThat(cut.getProperty("key", Integer.class, 0)).isZero(); + } + + @Test + void should_return_identify_property() { + assertThat(cut.identifyProperty("key")).isEqualTo("exchange.key"); + } + + @Test + void should_return_identify_name() { + assertThat(cut.identifyName("name")).isEqualTo("exchange-name"); + } + } + + @Nested + class CustomPrefix { + + @BeforeEach + public void beforeEach() { + cut = new IdentifyConfiguration(environment, "custom"); + } + + @Test + void should_be_default() { + assertThat(cut.environment()).isEqualTo(environment); + assertThat(cut.id()).isEqualTo("custom"); + } + + @Test + void should_contain_property() { + environment.withProperty("custom.key", "value"); + assertThat(cut.containsProperty("key")).isTrue(); + } + + @Test + void should_get_property() { + environment.withProperty("custom.key", "value"); + assertThat(cut.getProperty("key")).isEqualTo("value"); + } + + @Test + void should_get_property_list() { + environment.withProperty("custom.key[0]", "value"); + assertThat(cut.getPropertyList("key")).containsOnly("value"); + } + + @Test + void should_not_get_property_with_wrong_prefix() { + environment.withProperty("wrong.key", "value"); + assertThat(cut.getProperty("key")).isNull(); + } + + @Test + void should_get_custom_property() { + environment.withProperty("custom.key", "123"); + assertThat(cut.getProperty("key", Integer.class, 0)).isEqualTo(123); + } + + @Test + void should_get_default_value_for_custom_property() { + assertThat(cut.getProperty("key", Integer.class, 0)).isZero(); + } + + @Test + void should_return_identify_property() { + assertThat(cut.identifyProperty("key")).isEqualTo("custom.key"); + } + + @Test + void should_return_identify_name() { + assertThat(cut.identifyName("name")).isEqualTo("custom-name"); + } + } + + @Nested + class FallbackKeys { + + @BeforeEach + public void beforeEach() { + cut = new IdentifyConfiguration(environment, Map.of("key", "fallbackKey", "collectionKey", "fallbackCollectionKey")); + } + + @Test + void should_contain_property() { + environment.withProperty("fallbackKey", "value"); + assertThat(cut.containsProperty("key")).isTrue(); + } + + @Test + void should_get_property() { + environment.withProperty("fallbackKey", "value"); + assertThat(cut.getProperty("key")).isEqualTo("value"); + } + + @Test + void should_get_property_list() { + environment.withProperty("fallbackCollectionKey[0]", "value"); + assertThat(cut.getPropertyList("collectionKey")).containsOnly("value"); + } + + @Test + void should_not_get_property_with_wrong_prefix() { + environment.withProperty("wrong.fallbackKey", "value"); + assertThat(cut.getProperty("key")).isNull(); + } + + @Test + void should_get_custom_property() { + environment.withProperty("fallbackKey", "123"); + assertThat(cut.getProperty("key", Integer.class, 0)).isEqualTo(123); + } + } +} diff --git a/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/AbstractWebSocketChannelTest.java b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/AbstractWebSocketChannelTest.java new file mode 100644 index 0000000..8b2245f --- /dev/null +++ b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/AbstractWebSocketChannelTest.java @@ -0,0 +1,430 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.channel; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.gravitee.exchange.api.channel.Channel; +import io.gravitee.exchange.api.channel.exception.ChannelNoReplyException; +import io.gravitee.exchange.api.channel.exception.ChannelTimeoutException; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.command.noreply.NoReply; +import io.gravitee.exchange.api.command.primary.PrimaryCommand; +import io.gravitee.exchange.api.command.primary.PrimaryCommandPayload; +import io.gravitee.exchange.api.command.primary.PrimaryReply; +import io.gravitee.exchange.api.command.primary.PrimaryReplyPayload; +import io.gravitee.exchange.api.command.unknown.UnknownReply; +import io.gravitee.exchange.api.websocket.channel.test.AbstractWebSocketTest; +import io.gravitee.exchange.api.websocket.channel.test.AdaptedDummyReply; +import io.gravitee.exchange.api.websocket.channel.test.DummyCommand; +import io.gravitee.exchange.api.websocket.channel.test.DummyCommandAdapter; +import io.gravitee.exchange.api.websocket.channel.test.DummyCommandHandler; +import io.gravitee.exchange.api.websocket.channel.test.DummyPayload; +import io.gravitee.exchange.api.websocket.channel.test.DummyReply; +import io.gravitee.exchange.api.websocket.channel.test.DummyReplyAdapter; +import io.gravitee.exchange.api.websocket.protocol.ProtocolAdapter; +import io.gravitee.exchange.api.websocket.protocol.ProtocolExchange; +import io.gravitee.exchange.api.websocket.protocol.ProtocolVersion; +import io.gravitee.exchange.api.websocket.protocol.legacy.ignored.IgnoredReply; +import io.reactivex.rxjava3.plugins.RxJavaPlugins; +import io.reactivex.rxjava3.schedulers.TestScheduler; +import io.vertx.junit5.Checkpoint; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import io.vertx.rxjava3.core.buffer.Buffer; +import io.vertx.rxjava3.core.http.ServerWebSocket; +import io.vertx.rxjava3.core.http.WebSocketBase; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@ExtendWith(VertxExtension.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class AbstractWebSocketChannelTest extends AbstractWebSocketTest { + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_send_command_and_receive_reply(ProtocolVersion protocolVersion) { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + websocketServerHandler = + serverWebSocket -> + serverWebSocket.binaryMessageHandler(buffer -> { + ProtocolExchange websocketExchange = protocolAdapter.read(buffer); + Command command = websocketExchange.asCommand(); + DummyReply primaryReply = new DummyReply(command.getId(), new DummyPayload()); + serverWebSocket + .writeBinaryMessage( + protocolAdapter.write( + ProtocolExchange + .builder() + .type(ProtocolExchange.Type.REPLY) + .exchangeType(primaryReply.getType()) + .exchange(primaryReply) + .build() + ) + ) + .subscribe(); + }); + DummyCommand command = new DummyCommand(new DummyPayload()); + rxWebSocket() + .>flatMap(webSocket -> { + Channel webSocketChannel = new SimpleWebSocketChannel(List.of(), List.of(), List.of(), vertx, webSocket, protocolAdapter); + return webSocketChannel.initialize().andThen(webSocketChannel.send(command)); + }) + .test() + .awaitDone(10, TimeUnit.SECONDS) + .assertValue(reply -> { + assertThat(reply).isInstanceOf(DummyReply.class); + assertThat(reply.getCommandId()).isEqualTo(command.getId()); + return true; + }); + } + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_send_command_and_throw_exception_after_timeout(ProtocolVersion protocolVersion) { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + TestScheduler testScheduler = new TestScheduler(); + // set calls to Schedulers.computation() to use our test scheduler + RxJavaPlugins.setComputationSchedulerHandler(ignore -> testScheduler); + // Advance in time when primary command is received so reply will timeout + websocketServerHandler = + serverWebSocket -> serverWebSocket.binaryMessageHandler(buffer -> testScheduler.advanceTimeBy(60, TimeUnit.SECONDS)); + DummyCommand command = new DummyCommand(new DummyPayload()); + rxWebSocket() + .flatMap(webSocket -> { + Channel webSocketChannel = new SimpleWebSocketChannel(List.of(), List.of(), List.of(), vertx, webSocket, protocolAdapter); + return webSocketChannel.initialize().andThen(webSocketChannel.send(command)); + }) + .test() + .awaitDone(10, TimeUnit.SECONDS) + .assertError(ChannelTimeoutException.class); + } + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_send_command_and_throw_no_reply_exception_when_receiving_no_reply(ProtocolVersion protocolVersion) { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + websocketServerHandler = + serverWebSocket -> + serverWebSocket.binaryMessageHandler(buffer -> { + ProtocolExchange websocketExchange = protocolAdapter.read(buffer); + Command command = websocketExchange.asCommand(); + NoReply reply = new NoReply(command.getId(), "no reply"); + serverWebSocket + .writeBinaryMessage( + protocolAdapter.write( + ProtocolExchange + .builder() + .type(ProtocolExchange.Type.REPLY) + .exchangeType(reply.getType()) + .exchange(reply) + .build() + ) + ) + .subscribe(); + }); + DummyCommand command = new DummyCommand(new DummyPayload()); + rxWebSocket() + .>flatMap(webSocket -> { + Channel webSocketChannel = new SimpleWebSocketChannel(List.of(), List.of(), List.of(), vertx, webSocket, protocolAdapter); + return webSocketChannel.initialize().andThen(webSocketChannel.send(command)); + }) + .test() + .awaitDone(10, TimeUnit.SECONDS) + .assertError(ChannelNoReplyException.class); + } + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_send_decorated_command_and_reply(ProtocolVersion protocolVersion, VertxTestContext vertxTestContext) + throws InterruptedException { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + Checkpoint handlerCheckpoint = vertxTestContext.checkpoint(3); + websocketServerHandler = + serverWebSocket -> + serverWebSocket.binaryMessageHandler(buffer -> { + ProtocolExchange websocketExchange = protocolAdapter.read(buffer); + Command command = websocketExchange.asCommand(); + AdaptedDummyReply adaptedDummyReply = new AdaptedDummyReply(command.getId(), new DummyPayload()); + serverWebSocket + .writeBinaryMessage( + protocolAdapter.write( + ProtocolExchange + .builder() + .type(ProtocolExchange.Type.REPLY) + .exchangeType(adaptedDummyReply.getType()) + .exchange(adaptedDummyReply) + .build() + ) + ) + .subscribe(); + handlerCheckpoint.flag(); + }); + DummyCommand command = new DummyCommand(new DummyPayload()); + rxWebSocket() + .>flatMap(webSocket -> { + Channel webSocketChannel = new SimpleWebSocketChannel( + List.of(), + List.of(new DummyCommandAdapter(handlerCheckpoint)), + List.of(new DummyReplyAdapter(handlerCheckpoint)), + vertx, + webSocket, + protocolAdapter + ); + return webSocketChannel.initialize().andThen(webSocketChannel.send(command)); + }) + .test() + .awaitDone(10, TimeUnit.SECONDS) + .assertValue(reply -> { + assertThat(reply.getCommandId()).isEqualTo(command.getId()); + return true; + }); + assertThat(vertxTestContext.awaitCompletion(10, TimeUnit.SECONDS)).isTrue(); + } + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_receive_pong(ProtocolVersion protocolVersion, VertxTestContext vertxTestContext) throws InterruptedException { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + Checkpoint pongCheckpoint = vertxTestContext.checkpoint(); + + AtomicReference webSocketAtomicReference = new AtomicReference<>(); + websocketServerHandler = + serverWebSocket -> { + serverWebSocket.pongHandler(event -> pongCheckpoint.flag()); + webSocketAtomicReference.set(serverWebSocket); + }; + rxWebSocket() + .flatMapCompletable(webSocket -> { + Channel webSocketChannel = new SimpleWebSocketChannel(List.of(), List.of(), List.of(), vertx, webSocket, protocolAdapter); + return webSocketChannel.initialize(); + }) + .test() + .awaitDone(10, TimeUnit.SECONDS) + .assertComplete(); + webSocketAtomicReference.get().writePing(Buffer.buffer()); + assertThat(vertxTestContext.awaitCompletion(10, TimeUnit.SECONDS)).isTrue(); + } + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_handle_command(ProtocolVersion protocolVersion, VertxTestContext vertxTestContext) throws InterruptedException { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + Checkpoint handlerCheckpoint = vertxTestContext.checkpoint(); + + AtomicReference webSocketAtomicReference = new AtomicReference<>(); + websocketServerHandler = webSocketAtomicReference::set; + rxWebSocket() + .flatMapCompletable(webSocket -> { + Channel webSocketChannel = new SimpleWebSocketChannel( + List.of(new DummyCommandHandler(handlerCheckpoint)), + List.of(), + List.of(), + vertx, + webSocket, + protocolAdapter + ); + return webSocketChannel.initialize(); + }) + .test() + .awaitDone(10, TimeUnit.SECONDS) + .assertComplete(); + + webSocketAtomicReference + .get() + .writeBinaryMessage( + protocolAdapter.write( + ProtocolExchange + .builder() + .type(ProtocolExchange.Type.COMMAND) + .exchangeType(DummyCommand.COMMAND_TYPE) + .exchange(new DummyCommand(new DummyPayload())) + .build() + ) + ); + + assertThat(vertxTestContext.awaitCompletion(10, TimeUnit.SECONDS)).isTrue(); + } + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_not_handle_command_without_command_handler(ProtocolVersion protocolVersion, VertxTestContext vertxTestContext) + throws InterruptedException { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + Checkpoint handlerCheckpoint = vertxTestContext.checkpoint(); + AtomicReference webSocketAtomicReference = new AtomicReference<>(); + websocketServerHandler = + ws -> { + webSocketAtomicReference.set(ws); + ws.binaryMessageHandler(buffer -> { + ProtocolExchange websocketExchange = protocolAdapter.read(buffer); + assertThat(websocketExchange.type()).isEqualTo(ProtocolExchange.Type.REPLY); + Reply reply = websocketExchange.asReply(); + if (ProtocolVersion.LEGACY == protocolVersion) { + assertThat(reply).isInstanceOf(IgnoredReply.class); + } else { + assertThat(reply).isInstanceOf(NoReply.class); + } + handlerCheckpoint.flag(); + }); + }; + rxWebSocket() + .flatMapCompletable(webSocket -> { + Channel webSocketChannel = new SimpleWebSocketChannel(List.of(), List.of(), List.of(), vertx, webSocket, protocolAdapter); + return webSocketChannel.initialize(); + }) + .test() + .awaitDone(10, TimeUnit.SECONDS) + .assertComplete(); + + webSocketAtomicReference + .get() + .writeBinaryMessage( + protocolAdapter.write( + ProtocolExchange + .builder() + .type(ProtocolExchange.Type.COMMAND) + .exchangeType(DummyCommand.COMMAND_TYPE) + .exchange(new DummyCommand(new DummyPayload())) + .build() + ) + ); + assertThat(vertxTestContext.awaitCompletion(10, TimeUnit.SECONDS)).isTrue(); + } + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_not_handle_command_with_unknown_command(ProtocolVersion protocolVersion, VertxTestContext vertxTestContext) + throws InterruptedException { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + Checkpoint handlerCheckpoint = vertxTestContext.checkpoint(); + AtomicReference webSocketAtomicReference = new AtomicReference<>(); + websocketServerHandler = + ws -> { + webSocketAtomicReference.set(ws); + ws.binaryMessageHandler(buffer -> { + ProtocolExchange websocketExchange = protocolAdapter.read(buffer); + assertThat(websocketExchange.type()).isEqualTo(ProtocolExchange.Type.REPLY); + Reply reply = websocketExchange.asReply(); + assertThat(reply).isInstanceOf(UnknownReply.class); + handlerCheckpoint.flag(); + }); + }; + rxWebSocket() + .flatMapCompletable(webSocket -> { + Channel webSocketChannel = new SimpleWebSocketChannel( + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + vertx, + webSocket, + protocolAdapter + ); + return webSocketChannel.initialize(); + }) + .test() + .awaitDone(10, TimeUnit.SECONDS) + .assertComplete(); + + if (ProtocolVersion.LEGACY == protocolVersion) { + webSocketAtomicReference.get().writeBinaryMessage(Buffer.buffer("command: {\"type\":\"WRONG\",\"payload\":{}}")); + } else { + webSocketAtomicReference.get().writeBinaryMessage(Buffer.buffer("t:COMMAND;;et:WRONG;;e:{\"type\":\"WRONG\",\"payload\":{}}")); + } + assertThat(vertxTestContext.awaitCompletion(10, TimeUnit.SECONDS)).isTrue(); + } + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_properly_sent_primary_command(ProtocolVersion protocolVersion, VertxTestContext vertxTestContext) + throws InterruptedException { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + Checkpoint handlerCheckpoint = vertxTestContext.checkpoint(); + websocketServerHandler = + ws -> + ws.binaryMessageHandler(buffer -> { + if (ProtocolVersion.LEGACY == protocolVersion) { + assertThat(buffer).hasToString("primary: true"); + } else { + ProtocolExchange websocketExchange = protocolAdapter.read(buffer); + assertThat(websocketExchange.type()).isEqualTo(ProtocolExchange.Type.COMMAND); + Command command = websocketExchange.asCommand(); + assertThat(command).isInstanceOf(PrimaryCommand.class); + ws.writeBinaryMessage( + protocolAdapter.write( + ProtocolExchange + .builder() + .type(ProtocolExchange.Type.REPLY) + .exchangeType(PrimaryCommand.COMMAND_TYPE) + .exchange(new PrimaryReply(command.getId(), new PrimaryReplyPayload())) + .build() + ) + ); + } + handlerCheckpoint.flag(); + }); + rxWebSocket() + .flatMap(webSocket -> { + Channel webSocketChannel = new SimpleWebSocketChannel( + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + vertx, + webSocket, + protocolAdapter + ); + return webSocketChannel.initialize().andThen(webSocketChannel.send(new PrimaryCommand(new PrimaryCommandPayload(true)))); + }) + .test() + .awaitDone(10, TimeUnit.SECONDS) + .assertComplete(); + assertThat(vertxTestContext.awaitCompletion(10, TimeUnit.SECONDS)).isTrue(); + } + + private static class SimpleWebSocketChannel extends AbstractWebSocketChannel { + + protected SimpleWebSocketChannel( + final List, ? extends Reply>> commandHandlers, + final List, ? extends Command, ? extends Reply>> commandAdapters, + final List, ? extends Reply>> replyAdapters, + final io.vertx.rxjava3.core.Vertx vertx, + final WebSocketBase webSocket, + final ProtocolAdapter protocolAdapter + ) { + super(commandHandlers, commandAdapters, replyAdapters, vertx, webSocket, protocolAdapter); + } + + @Override + protected boolean expectHelloCommand() { + return false; + } + } +} diff --git a/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/AbstractWebSocketTest.java b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/AbstractWebSocketTest.java new file mode 100644 index 0000000..d0a362c --- /dev/null +++ b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/AbstractWebSocketTest.java @@ -0,0 +1,114 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.channel.test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.gravitee.exchange.api.controller.ws.WebsocketControllerConstants; +import io.gravitee.exchange.api.websocket.command.DefaultExchangeSerDe; +import io.gravitee.exchange.api.websocket.protocol.ProtocolAdapter; +import io.gravitee.exchange.api.websocket.protocol.ProtocolVersion; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.plugins.RxJavaPlugins; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.core.http.WebSocketConnectOptions; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import io.vertx.rxjava3.core.http.HttpClient; +import io.vertx.rxjava3.core.http.HttpServer; +import io.vertx.rxjava3.core.http.ServerWebSocket; +import io.vertx.rxjava3.core.http.WebSocket; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.util.TestSocketUtils; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@ExtendWith(VertxExtension.class) +public abstract class AbstractWebSocketTest { + + protected static HttpServer httpServer; + protected static int serverPort; + protected static Handler websocketServerHandler; + protected static io.vertx.rxjava3.core.Vertx vertx; + private static Disposable serverDispose; + protected DefaultExchangeSerDe exchangeSerDe; + + @BeforeAll + public static void startWebSocketServer(Vertx vertx, VertxTestContext context) { + AbstractWebSocketTest.vertx = io.vertx.rxjava3.core.Vertx.newInstance(vertx); + final HttpServerOptions httpServerOptions = new HttpServerOptions(); + serverPort = TestSocketUtils.findAvailableTcpPort(); + httpServerOptions.setPort(serverPort); + serverDispose = + AbstractWebSocketTest.vertx + .createHttpServer(httpServerOptions) + .webSocketHandler(serverWebSocket -> { + if (null != websocketServerHandler) { + websocketServerHandler.handle(serverWebSocket); + } + }) + .listen(serverPort) + .subscribe( + server -> { + httpServer = server; + context.completeNow(); + }, + context::failNow + ); + } + + @BeforeEach + public void initSerDer() { + this.exchangeSerDe = new DummyCommandSerDe(new ObjectMapper()); + } + + @AfterAll + public static void stopWebSocketServer() { + if (null != serverDispose) { + serverDispose.dispose(); + } + if (null != httpServer) { + httpServer.close().subscribe(); + } + } + + @AfterEach + public void cleanHandler() { + websocketServerHandler = null; + RxJavaPlugins.reset(); + } + + public ProtocolAdapter protocolAdapter(final ProtocolVersion protocolVersion) { + return protocolVersion.adapterFactory().apply(this.exchangeSerDe); + } + + protected static Single rxWebSocket() { + HttpClient httpClient = vertx.createHttpClient(); + WebSocketConnectOptions webSocketConnectOptions = new WebSocketConnectOptions() + .setHost("localhost") + .setPort(serverPort) + .setURI(WebsocketControllerConstants.EXCHANGE_CONTROLLER_PATH); + return httpClient.rxWebSocket(webSocketConnectOptions); + } +} diff --git a/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/AdaptedDummyCommand.java b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/AdaptedDummyCommand.java new file mode 100644 index 0000000..57e8263 --- /dev/null +++ b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/AdaptedDummyCommand.java @@ -0,0 +1,33 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.channel.test; + +import io.gravitee.exchange.api.command.Command; + +public class AdaptedDummyCommand extends Command { + + public static final String COMMAND_TYPE = "ADAPTED_DUMMY"; + + public AdaptedDummyCommand() { + super(COMMAND_TYPE); + } + + public AdaptedDummyCommand(final String commandId, final DummyPayload dummyPayload) { + this(); + this.id = commandId; + this.payload = dummyPayload; + } +} diff --git a/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/AdaptedDummyReply.java b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/AdaptedDummyReply.java new file mode 100644 index 0000000..da0dda5 --- /dev/null +++ b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/AdaptedDummyReply.java @@ -0,0 +1,34 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.channel.test; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AdaptedDummyReply extends Reply { + + public AdaptedDummyReply() { + super(AdaptedDummyCommand.COMMAND_TYPE); + } + + public AdaptedDummyReply(final String commandId, final DummyPayload dummyPayload) { + super(AdaptedDummyCommand.COMMAND_TYPE, commandId, CommandStatus.SUCCEEDED); + this.payload = dummyPayload; + } +} diff --git a/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommand.java b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommand.java new file mode 100644 index 0000000..f7ae09c --- /dev/null +++ b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommand.java @@ -0,0 +1,32 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.channel.test; + +import io.gravitee.exchange.api.command.Command; + +public class DummyCommand extends Command { + + public static final String COMMAND_TYPE = "DUMMY"; + + public DummyCommand() { + super(COMMAND_TYPE); + } + + public DummyCommand(final DummyPayload dummyPayload) { + this(); + this.payload = dummyPayload; + } +} diff --git a/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommandAdapter.java b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommandAdapter.java new file mode 100644 index 0000000..da50c7d --- /dev/null +++ b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommandAdapter.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.channel.test; + +import io.gravitee.exchange.api.command.CommandAdapter; +import io.reactivex.rxjava3.core.Single; +import io.vertx.junit5.Checkpoint; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class DummyCommandAdapter implements CommandAdapter { + + private final Checkpoint checkpoint; + + @Override + public String supportType() { + return DummyCommand.COMMAND_TYPE; + } + + @Override + public Single adapt(final DummyCommand command) { + checkpoint.flag(); + return Single.just(new AdaptedDummyCommand(command.getId(), command.getPayload())); + } +} diff --git a/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommandHandler.java b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommandHandler.java new file mode 100644 index 0000000..5832813 --- /dev/null +++ b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommandHandler.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.channel.test; + +import io.gravitee.exchange.api.command.CommandHandler; +import io.reactivex.rxjava3.core.Single; +import io.vertx.junit5.Checkpoint; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class DummyCommandHandler implements CommandHandler { + + private final Checkpoint checkpoint; + + @Override + public String supportType() { + return DummyCommand.COMMAND_TYPE; + } + + @Override + public Single handle(final DummyCommand command) { + checkpoint.flag(); + return Single.just(new DummyReply(command.getId(), new DummyPayload())); + } +} diff --git a/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommandSerDe.java b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommandSerDe.java new file mode 100644 index 0000000..12c2dbf --- /dev/null +++ b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommandSerDe.java @@ -0,0 +1,31 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.channel.test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.gravitee.exchange.api.websocket.command.DefaultExchangeSerDe; +import java.util.Map; + +public class DummyCommandSerDe extends DefaultExchangeSerDe { + + public DummyCommandSerDe(final ObjectMapper objectMapper) { + super( + objectMapper, + Map.of(DummyCommand.COMMAND_TYPE, DummyCommand.class, AdaptedDummyCommand.COMMAND_TYPE, AdaptedDummyCommand.class), + Map.of(DummyCommand.COMMAND_TYPE, DummyReply.class, AdaptedDummyCommand.COMMAND_TYPE, AdaptedDummyReply.class) + ); + } +} diff --git a/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyPayload.java b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyPayload.java new file mode 100644 index 0000000..ecf126e --- /dev/null +++ b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyPayload.java @@ -0,0 +1,20 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.channel.test; + +import io.gravitee.exchange.api.command.Payload; + +public record DummyPayload() implements Payload {} diff --git a/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyReply.java b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyReply.java new file mode 100644 index 0000000..c289905 --- /dev/null +++ b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyReply.java @@ -0,0 +1,35 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.channel.test; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonSubTypes({ @JsonSubTypes.Type(value = DummyReply.class, name = DummyCommand.COMMAND_TYPE) }) +public class DummyReply extends Reply { + + public DummyReply() { + super(DummyCommand.COMMAND_TYPE); + } + + public DummyReply(final String commandId, final DummyPayload dummyPayload) { + super(DummyCommand.COMMAND_TYPE, commandId, CommandStatus.SUCCEEDED); + this.payload = dummyPayload; + } +} diff --git a/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyReplyAdapter.java b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyReplyAdapter.java new file mode 100644 index 0000000..3000b40 --- /dev/null +++ b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyReplyAdapter.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.channel.test; + +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.reactivex.rxjava3.core.Single; +import io.vertx.junit5.Checkpoint; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class DummyReplyAdapter implements ReplyAdapter { + + private final Checkpoint checkpoint; + + @Override + public String supportType() { + return AdaptedDummyCommand.COMMAND_TYPE; + } + + @Override + public Single adapt(final AdaptedDummyReply reply) { + checkpoint.flag(); + return Single.just(new DummyReply(reply.getCommandId(), reply.getPayload())); + } +} diff --git a/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/command/DefaultExchangeSerDeTest.java b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/command/DefaultExchangeSerDeTest.java new file mode 100644 index 0000000..926e133 --- /dev/null +++ b/gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/command/DefaultExchangeSerDeTest.java @@ -0,0 +1,206 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.api.websocket.command; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.goodbye.GoodByeCommand; +import io.gravitee.exchange.api.command.goodbye.GoodByeCommandPayload; +import io.gravitee.exchange.api.command.goodbye.GoodByeReply; +import io.gravitee.exchange.api.command.goodbye.GoodByeReplyPayload; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckCommand; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckCommandPayload; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckReply; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckReplyPayload; +import io.gravitee.exchange.api.command.hello.HelloCommand; +import io.gravitee.exchange.api.command.hello.HelloCommandPayload; +import io.gravitee.exchange.api.command.hello.HelloReply; +import io.gravitee.exchange.api.command.hello.HelloReplyPayload; +import io.gravitee.exchange.api.command.noreply.NoReply; +import io.gravitee.exchange.api.command.primary.PrimaryCommand; +import io.gravitee.exchange.api.command.primary.PrimaryCommandPayload; +import io.gravitee.exchange.api.command.primary.PrimaryReply; +import io.gravitee.exchange.api.command.primary.PrimaryReplyPayload; +import io.gravitee.exchange.api.command.unknown.UnknownCommand; +import io.gravitee.exchange.api.command.unknown.UnknownReply; +import io.gravitee.exchange.api.websocket.protocol.ProtocolVersion; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class DefaultExchangeSerDeTest { + + private DefaultExchangeSerDe cut; + + @BeforeEach + public void beforeEach() { + cut = new DefaultExchangeSerDe(new ObjectMapper()); + } + + @Nested + class Commands { + + @Test + void should_deserialize_unknown_command() { + Command command = cut.deserializeAsCommand(ProtocolVersion.V1, "wrong", "{\"type\" : \"wrong\"}"); + assertThat(command).isInstanceOf(UnknownCommand.class); + } + + private static Stream knownCommands() { + return Stream.of( + Arguments.of( + HelloCommand.COMMAND_TYPE, + "{\"id\":\"%id%\",\"type\":\"HELLO\",\"payload\":{\"targetId\":\"targetId\"}}", + new HelloCommand(HelloCommandPayload.builder().targetId("targetId").build()) + ), + Arguments.of( + GoodByeCommand.COMMAND_TYPE, + "{\"id\":\"%id%\",\"type\":\"GOOD_BYE\",\"payload\":{\"targetId\":\"targetId\",\"reconnect\":true}}", + new GoodByeCommand(GoodByeCommandPayload.builder().targetId("targetId").reconnect(true).build()) + ), + Arguments.of( + GoodByeCommand.COMMAND_TYPE, + "{\"id\":\"%id%\",\"type\":\"GOOD_BYE\",\"payload\":{\"targetId\":\"targetId\",\"reconnect\":false}}", + new GoodByeCommand(GoodByeCommandPayload.builder().targetId("targetId").reconnect(false).build()) + ), + Arguments.of( + HealthCheckCommand.COMMAND_TYPE, + "{\"id\":\"%id%\",\"type\":\"HEALTH_CHECK\",\"payload\":{}}", + new HealthCheckCommand(new HealthCheckCommandPayload()) + ), + Arguments.of( + PrimaryCommand.COMMAND_TYPE, + "{\"id\":\"%id%\",\"type\":\"PRIMARY\",\"payload\":{\"primary\":true}}", + new PrimaryCommand(new PrimaryCommandPayload(true)) + ), + Arguments.of( + PrimaryCommand.COMMAND_TYPE, + "{\"id\":\"%id%\",\"type\":\"PRIMARY\",\"payload\":{\"primary\":false}}", + new PrimaryCommand(new PrimaryCommandPayload(false)) + ), + Arguments.of(UnknownCommand.COMMAND_TYPE, "{\"id\":\"%id%\",\"type\":\"UNKNOWN\",\"payload\":{}}", new UnknownCommand()) + ); + } + + @ParameterizedTest + @MethodSource("knownCommands") + void should_deserialize_known_command(final String commandType, final String json, final Command command) { + Command deserializeCommand = cut.deserializeAsCommand( + ProtocolVersion.V1, + commandType, + json.replaceAll("%id%", command.getId()) + ); + assertThat(deserializeCommand).isEqualTo(command); + } + + @Test + void should_serialize_unknown_command() { + UnknownCommand command = new UnknownCommand(); + String json = cut.serialize(ProtocolVersion.V1, command); + assertThat(json).isEqualTo("{\"id\":\"%id%\",\"type\":\"UNKNOWN\",\"payload\":{}}".replaceAll("%id%", command.getId())); + } + + @ParameterizedTest + @MethodSource("knownCommands") + void should_serialize_known_command(final String commandType, final String json, final Command command) { + String serializeCommand = cut.serialize(ProtocolVersion.V1, command); + assertThat(serializeCommand).isEqualTo(json.replaceAll("%id%", command.getId())); + } + } + + @Nested + class Replies { + + @Test + void should_deserialize_unknown_reply() { + Reply reply = cut.deserializeAsReply(ProtocolVersion.V1, "wrong", "{\"type\" : \"wrong\"}"); + assertThat(reply).isInstanceOf(UnknownReply.class); + } + + private static Stream knownReplies() { + return Stream.of( + Arguments.of( + HelloCommand.COMMAND_TYPE, + "{\"commandId\":\"commandId\",\"type\":\"HELLO\",\"commandStatus\":\"SUCCEEDED\",\"payload\":{\"targetId\":\"targetId\"}}", + new HelloReply("commandId", HelloReplyPayload.builder().targetId("targetId").build()) + ), + Arguments.of( + GoodByeCommand.COMMAND_TYPE, + "{\"commandId\":\"commandId\",\"type\":\"GOOD_BYE\",\"commandStatus\":\"SUCCEEDED\",\"payload\":{\"targetId\":\"targetId\"}}", + new GoodByeReply("commandId", GoodByeReplyPayload.builder().targetId("targetId").build()) + ), + Arguments.of( + HealthCheckCommand.COMMAND_TYPE, + "{\"commandId\":\"commandId\",\"type\":\"HEALTH_CHECK\",\"commandStatus\":\"SUCCEEDED\",\"payload\":{\"healthy\":true,\"detail\":null}}", + new HealthCheckReply("commandId", new HealthCheckReplyPayload(true, null)) + ), + Arguments.of( + PrimaryCommand.COMMAND_TYPE, + "{\"commandId\":\"commandId\",\"type\":\"PRIMARY\",\"commandStatus\":\"SUCCEEDED\",\"payload\":{}}", + new PrimaryReply("commandId", new PrimaryReplyPayload()) + ), + Arguments.of( + UnknownCommand.COMMAND_TYPE, + "{\"commandId\":\"commandId\",\"type\":\"UNKNOWN\",\"commandStatus\":\"ERROR\",\"errorDetails\":\"error\",\"payload\":{}}", + new UnknownReply("commandId", "error") + ), + Arguments.of( + NoReply.COMMAND_TYPE, + "{\"commandId\":\"commandId\",\"type\":\"NO_REPLY\",\"commandStatus\":\"ERROR\",\"errorDetails\":\"error\",\"payload\":{}}", + new NoReply("commandId", "error") + ) + ); + } + + @ParameterizedTest + @MethodSource("knownReplies") + void should_deserialize_known_reply(final String commandType, final String json, final Reply reply) { + Reply deserializeReply = cut.deserializeAsReply(ProtocolVersion.V1, commandType, json); + assertThat(deserializeReply).isEqualTo(reply); + } + + @Test + void should_serialize_unknown_reply() { + UnknownReply unknownReply = new UnknownReply("commandId", "error"); + String json = cut.serialize(ProtocolVersion.V1, unknownReply); + assertThat(json) + .isEqualTo( + "{\"commandId\":\"commandId\",\"type\":\"UNKNOWN\",\"commandStatus\":\"ERROR\",\"errorDetails\":\"error\",\"payload\":{}}" + ); + } + + @ParameterizedTest + @MethodSource("knownReplies") + void should_serialize_known_reply(final String commandType, final String json, final Reply reply) { + String serializeReply = cut.serialize(ProtocolVersion.V1, reply); + assertThat(serializeReply).isEqualTo(json); + } + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-core/pom.xml b/gravitee-exchange-connector/gravitee-exchange-connector-core/pom.xml new file mode 100644 index 0000000..1b022e7 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-core/pom.xml @@ -0,0 +1,55 @@ + + + + 4.0.0 + + + io.gravitee.exchange + gravitee-exchange-connector + 1.0.0-alpha.7 + + + gravitee-exchange-connector-core + Gravitee.io - Exchange - Connector Core + + + + io.gravitee.exchange + gravitee-exchange-api + ${project.version} + + + io.gravitee.common + gravitee-common + provided + + + io.reactivex.rxjava3 + rxjava + provided + + + io.gravitee.node + gravitee-node-api + provided + + + \ No newline at end of file diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/DefaultExchangeConnectorManager.java b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/DefaultExchangeConnectorManager.java new file mode 100644 index 0000000..0213499 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/DefaultExchangeConnectorManager.java @@ -0,0 +1,80 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.core; + +import io.gravitee.exchange.api.command.goodbye.GoodByeCommand; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckCommand; +import io.gravitee.exchange.api.command.primary.PrimaryCommand; +import io.gravitee.exchange.api.connector.ExchangeConnector; +import io.gravitee.exchange.api.connector.ExchangeConnectorManager; +import io.gravitee.exchange.connector.core.command.goodbye.GoodByeCommandHandler; +import io.gravitee.exchange.connector.core.command.healtcheck.HealthCheckCommandHandler; +import io.gravitee.exchange.connector.core.command.primary.PrimaryCommandHandler; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Maybe; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Slf4j +public class DefaultExchangeConnectorManager implements ExchangeConnectorManager { + + private final Map exchangeConnectors = new ConcurrentHashMap<>(); + + @Override + public Maybe get(final String targetId) { + return Maybe.fromCallable(() -> exchangeConnectors.get(targetId)); + } + + @Override + public Completable register(final ExchangeConnector exchangeConnector) { + return exchangeConnector + .initialize() + .doOnComplete(() -> { + log.debug("New connector successfully register for target [{}]", exchangeConnector.targetId()); + // Add custom handlers to deal with healthcheck and primary commands + exchangeConnector.addCommandHandlers( + List.of( + new GoodByeCommandHandler(exchangeConnector), + new HealthCheckCommandHandler(exchangeConnector), + new PrimaryCommandHandler(exchangeConnector) + ) + ); + + exchangeConnectors.put(exchangeConnector.targetId(), exchangeConnector); + }) + .onErrorResumeNext(throwable -> { + log.warn("Unable to register new connector for target [{}]", exchangeConnector.targetId()); + return unregister(exchangeConnector).andThen(Completable.error(throwable)); + }); + } + + @Override + public Completable unregister(final ExchangeConnector exchangeConnector) { + return Completable + .defer(() -> { + exchangeConnectors.remove(exchangeConnector.targetId(), exchangeConnector); + return exchangeConnector.close(); + }) + .doOnComplete(() -> log.debug("Connector successfully unregister for target [{}]", exchangeConnector.targetId())) + .doOnError(throwable -> log.warn("Unable to unregister connector for target [{}]", exchangeConnector.targetId())); + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/command/goodbye/GoodByeCommandHandler.java b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/command/goodbye/GoodByeCommandHandler.java new file mode 100644 index 0000000..5a46618 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/command/goodbye/GoodByeCommandHandler.java @@ -0,0 +1,49 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.core.command.goodbye; + +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.goodbye.GoodByeCommand; +import io.gravitee.exchange.api.command.goodbye.GoodByeReply; +import io.gravitee.exchange.api.command.goodbye.GoodByeReplyPayload; +import io.gravitee.exchange.api.connector.ExchangeConnector; +import io.reactivex.rxjava3.core.Single; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@RequiredArgsConstructor +@Slf4j +public class GoodByeCommandHandler implements CommandHandler { + + private final ExchangeConnector exchangeConnector; + + @Override + public String supportType() { + return GoodByeCommand.COMMAND_TYPE; + } + + @Override + public Single handle(GoodByeCommand command) { + return Single.fromCallable(() -> { + log.debug("Goodbye command received for target id [{}]", exchangeConnector.targetId()); + return new GoodByeReply(command.getId(), GoodByeReplyPayload.builder().targetId(command.getPayload().getTargetId()).build()); + }); + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/command/healtcheck/HealthCheckCommandHandler.java b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/command/healtcheck/HealthCheckCommandHandler.java new file mode 100644 index 0000000..b8cf41d --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/command/healtcheck/HealthCheckCommandHandler.java @@ -0,0 +1,49 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.core.command.healtcheck; + +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckCommand; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckReply; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckReplyPayload; +import io.gravitee.exchange.api.connector.ExchangeConnector; +import io.reactivex.rxjava3.core.Single; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@RequiredArgsConstructor +@Slf4j +public class HealthCheckCommandHandler implements CommandHandler { + + private final ExchangeConnector exchangeConnector; + + @Override + public String supportType() { + return HealthCheckCommand.COMMAND_TYPE; + } + + @Override + public Single handle(HealthCheckCommand command) { + return Single.fromCallable(() -> { + log.debug("Health check command received for target id [{}]", exchangeConnector.targetId()); + return new HealthCheckReply(command.getId(), HealthCheckReplyPayload.builder().healthy(true).build()); + }); + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/command/primary/PrimaryCommandHandler.java b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/command/primary/PrimaryCommandHandler.java new file mode 100644 index 0000000..29d7644 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/command/primary/PrimaryCommandHandler.java @@ -0,0 +1,47 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.core.command.primary; + +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.primary.PrimaryCommand; +import io.gravitee.exchange.api.command.primary.PrimaryReply; +import io.gravitee.exchange.api.command.primary.PrimaryReplyPayload; +import io.gravitee.exchange.api.connector.ExchangeConnector; +import io.reactivex.rxjava3.core.Single; +import lombok.RequiredArgsConstructor; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@RequiredArgsConstructor +public class PrimaryCommandHandler implements CommandHandler { + + private final ExchangeConnector exchangeConnector; + + @Override + public String supportType() { + return PrimaryCommand.COMMAND_TYPE; + } + + @Override + public Single handle(PrimaryCommand command) { + return Single.fromCallable(() -> { + exchangeConnector.setPrimary(command.getPayload().primary()); + return new PrimaryReply(command.getId(), new PrimaryReplyPayload()); + }); + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/spring/ConnectorCoreConfiguration.java b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/spring/ConnectorCoreConfiguration.java new file mode 100644 index 0000000..64f787a --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/spring/ConnectorCoreConfiguration.java @@ -0,0 +1,30 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.core.spring; + +import io.gravitee.exchange.api.connector.ExchangeConnectorManager; +import io.gravitee.exchange.connector.core.DefaultExchangeConnectorManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ConnectorCoreConfiguration { + + @Bean + public ExchangeConnectorManager exchangeConnectorManager() { + return new DefaultExchangeConnectorManager(); + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/DefaultExchangeConnectorManagerTest.java b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/DefaultExchangeConnectorManagerTest.java new file mode 100644 index 0000000..53517a1 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/DefaultExchangeConnectorManagerTest.java @@ -0,0 +1,123 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.core; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.gravitee.exchange.api.connector.ExchangeConnector; +import io.reactivex.rxjava3.core.Completable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@ExtendWith(MockitoExtension.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class DefaultExchangeConnectorManagerTest { + + private DefaultExchangeConnectorManager cut; + + @Mock + private ExchangeConnector exchangeConnector; + + @BeforeEach + public void beforeEach() { + cut = new DefaultExchangeConnectorManager(); + lenient().when(exchangeConnector.targetId()).thenReturn("targetId"); + lenient().when(exchangeConnector.initialize()).thenReturn(Completable.complete()); + lenient().when(exchangeConnector.close()).thenReturn(Completable.complete()); + } + + @Nested + class Get { + + @Test + void should_not_return_any_connectors_with_unknown_id() { + cut.register(exchangeConnector).andThen(cut.get("unknown")).test().assertNoValues().assertComplete(); + } + + @Test + void should_return_registered_connector() { + cut + .register(exchangeConnector) + .andThen(cut.get(exchangeConnector.targetId())) + .test() + .assertValue(exchangeConnector) + .assertComplete(); + } + } + + @Nested + class Register { + + @Test + void should_register() { + cut + .register(exchangeConnector) + .andThen(cut.get(exchangeConnector.targetId())) + .test() + .assertValue(exchangeConnector) + .assertComplete(); + verify(exchangeConnector).initialize(); + verify(exchangeConnector).addCommandHandlers(any()); + } + + @Test + void should_unregister_if_connector_initialization_failed_during_registration() { + when(exchangeConnector.initialize()).thenReturn(Completable.error(new RuntimeException())); + cut.register(exchangeConnector).test().assertError(RuntimeException.class); + cut.get(exchangeConnector.targetId()).test().assertNoValues().assertComplete(); + verify(exchangeConnector).initialize(); + verify(exchangeConnector).close(); + } + } + + @Nested + class Unregister { + + @Test + void should_unregister() { + cut + .register(exchangeConnector) + .andThen(cut.unregister(exchangeConnector)) + .andThen(cut.get(exchangeConnector.targetId())) + .test() + .assertNoValues() + .assertComplete(); + verify(exchangeConnector).close(); + } + + @Test + void should_unregister_even_if_connector_closing_failed() { + when(exchangeConnector.close()).thenReturn(Completable.error(new RuntimeException())); + + cut.register(exchangeConnector).andThen(cut.unregister(exchangeConnector)).test().assertError(RuntimeException.class); + cut.get(exchangeConnector.targetId()).test().assertNoValues().assertComplete(); + verify(exchangeConnector).close(); + } + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/command/goodbye/GoodByeCommandHandlerTest.java b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/command/goodbye/GoodByeCommandHandlerTest.java new file mode 100644 index 0000000..b8b0d6b --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/command/goodbye/GoodByeCommandHandlerTest.java @@ -0,0 +1,73 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.core.command.goodbye; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.goodbye.GoodByeCommand; +import io.gravitee.exchange.api.command.goodbye.GoodByeCommandPayload; +import io.gravitee.exchange.api.connector.ExchangeConnector; +import io.reactivex.rxjava3.core.Completable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@ExtendWith(MockitoExtension.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class GoodByeCommandHandlerTest { + + @Mock + private ExchangeConnector exchangeConnector; + + private GoodByeCommandHandler cut; + + @BeforeEach + public void beforeEach() { + cut = new GoodByeCommandHandler(exchangeConnector); + } + + @Test + void should_handle_good_bye_command() { + assertThat(cut.supportType()).isEqualTo(GoodByeCommand.COMMAND_TYPE); + } + + @Test + void should_reply_on_goodbye_command() { + GoodByeCommand goodByeCommand = new GoodByeCommand(GoodByeCommandPayload.builder().targetId("targetId").reconnect(true).build()); + cut + .handle(goodByeCommand) + .test() + .assertComplete() + .assertValue(goodByeReply -> { + assertThat(goodByeReply.getCommandId()).isEqualTo(goodByeCommand.getId()); + assertThat(goodByeReply.getCommandStatus()).isEqualTo(CommandStatus.SUCCEEDED); + assertThat(goodByeReply.getPayload().getTargetId()).isEqualTo(goodByeCommand.getPayload().getTargetId()); + return true; + }); + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/command/healtcheck/HealthCheckCommandHandlerTest.java b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/command/healtcheck/HealthCheckCommandHandlerTest.java new file mode 100644 index 0000000..e6f2748 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/command/healtcheck/HealthCheckCommandHandlerTest.java @@ -0,0 +1,71 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.core.command.healtcheck; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckCommand; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckCommandPayload; +import io.gravitee.exchange.api.connector.ExchangeConnector; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@ExtendWith(MockitoExtension.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class HealthCheckCommandHandlerTest { + + @Mock + private ExchangeConnector exchangeConnector; + + private HealthCheckCommandHandler cut; + + @BeforeEach + public void beforeEach() { + cut = new HealthCheckCommandHandler(exchangeConnector); + } + + @Test + void should_handle_good_bye_command() { + assertThat(cut.supportType()).isEqualTo(HealthCheckCommand.COMMAND_TYPE); + } + + @Test + void should_answer_with_healthy_payload() { + when(exchangeConnector.targetId()).thenReturn("targetId"); + HealthCheckCommand healthCheckCommand = new HealthCheckCommand(HealthCheckCommandPayload.builder().build()); + cut + .handle(healthCheckCommand) + .test() + .assertComplete() + .assertValue(goodByeReply -> { + assertThat(goodByeReply.getCommandId()).isEqualTo(healthCheckCommand.getId()); + assertThat(goodByeReply.getCommandStatus()).isEqualTo(CommandStatus.SUCCEEDED); + assertThat(goodByeReply.getPayload().healthy()).isTrue(); + return true; + }); + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/command/primary/PrimaryCommandHandlerTest.java b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/command/primary/PrimaryCommandHandlerTest.java new file mode 100644 index 0000000..7c47e7c --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/command/primary/PrimaryCommandHandlerTest.java @@ -0,0 +1,73 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.core.command.primary; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.primary.PrimaryCommand; +import io.gravitee.exchange.api.command.primary.PrimaryCommandPayload; +import io.gravitee.exchange.api.connector.ExchangeConnector; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@ExtendWith(MockitoExtension.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class PrimaryCommandHandlerTest { + + @Mock + private ExchangeConnector exchangeConnector; + + private PrimaryCommandHandler cut; + + @BeforeEach + public void beforeEach() { + cut = new PrimaryCommandHandler(exchangeConnector); + } + + @Test + void should_handle_good_bye_command() { + assertThat(cut.supportType()).isEqualTo(PrimaryCommand.COMMAND_TYPE); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void should_set_primary_on_connector(final boolean primary) { + PrimaryCommand primaryCommand = new PrimaryCommand(new PrimaryCommandPayload(primary)); + cut + .handle(primaryCommand) + .test() + .assertComplete() + .assertValue(primaryReply -> { + assertThat(primaryReply.getCommandId()).isEqualTo(primaryCommand.getId()); + assertThat(primaryReply.getCommandStatus()).isEqualTo(CommandStatus.SUCCEEDED); + return true; + }); + verify(exchangeConnector).setPrimary(primary); + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-embedded/pom.xml b/gravitee-exchange-connector/gravitee-exchange-connector-embedded/pom.xml new file mode 100644 index 0000000..8d5ae6f --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-embedded/pom.xml @@ -0,0 +1,54 @@ + + + + 4.0.0 + + + io.gravitee.exchange + gravitee-exchange-connector + 1.0.0-alpha.7 + + + gravitee-exchange-connector-embedded + Gravitee.io - Exchange - Connector Embedded + + + io.gravitee.exchange + gravitee-exchange-api + ${project.version} + + + io.gravitee.exchange + gravitee-exchange-connector-core + ${project.version} + + + org.slf4j + slf4j-api + provided + + + io.reactivex.rxjava3 + rxjava + provided + + + \ No newline at end of file diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-embedded/src/main/java/io/gravitee/exchange/connector/embedded/EmbeddedExchangeConnector.java b/gravitee-exchange-connector/gravitee-exchange-connector-embedded/src/main/java/io/gravitee/exchange/connector/embedded/EmbeddedExchangeConnector.java new file mode 100644 index 0000000..c89a13b --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-embedded/src/main/java/io/gravitee/exchange/connector/embedded/EmbeddedExchangeConnector.java @@ -0,0 +1,83 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.embedded; + +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.connector.ConnectorChannel; +import io.gravitee.exchange.api.connector.ExchangeConnector; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Single; +import java.util.List; +import java.util.Map; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@SuperBuilder +@NoArgsConstructor +public class EmbeddedExchangeConnector implements ExchangeConnector { + + protected ConnectorChannel connectorChannel; + + @Builder.Default + private boolean primary = true; + + @Override + public Completable initialize() { + return connectorChannel.initialize(); + } + + @Override + public Completable close() { + return connectorChannel.close(); + } + + @Override + public String targetId() { + return connectorChannel.targetId(); + } + + @Override + public boolean isActive() { + return connectorChannel.isActive(); + } + + @Override + public boolean isPrimary() { + return primary; + } + + @Override + public void setPrimary(final boolean isPrimary) { + this.primary = isPrimary; + } + + @Override + public Single> sendCommand(final Command command) { + return connectorChannel.send(command); + } + + @Override + public void addCommandHandlers(final List, ? extends Reply>> commandHandlers) { + this.connectorChannel.addCommandHandlers(commandHandlers); + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-embedded/src/test/java/io/gravitee/exchange/connector/embedded/EmbeddedExchangeConnectorTest.java b/gravitee-exchange-connector/gravitee-exchange-connector-embedded/src/test/java/io/gravitee/exchange/connector/embedded/EmbeddedExchangeConnectorTest.java new file mode 100644 index 0000000..6daef79 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-embedded/src/test/java/io/gravitee/exchange/connector/embedded/EmbeddedExchangeConnectorTest.java @@ -0,0 +1,152 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.embedded; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.connector.ConnectorChannel; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.plugins.RxJavaPlugins; +import io.reactivex.rxjava3.schedulers.TestScheduler; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@ExtendWith(MockitoExtension.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class EmbeddedExchangeConnectorTest { + + @Mock + private ConnectorChannel connectorChannel; + + private EmbeddedExchangeConnector cut; + + @BeforeEach + public void beforeEach() { + cut = EmbeddedExchangeConnector.builder().connectorChannel(connectorChannel).build(); + } + + @Nested + class Primary { + + @Test + void should_be_primary_by_default() { + EmbeddedExchangeConnector exchangeConnector = new EmbeddedExchangeConnector(); + assertThat(exchangeConnector.isPrimary()).isTrue(); + } + + @Test + void should_be_primary_using_builder() { + EmbeddedExchangeConnector exchangeConnector = EmbeddedExchangeConnector.builder().build(); + assertThat(exchangeConnector.isPrimary()).isTrue(); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void should_set_primary_using_builder(final boolean primary) { + EmbeddedExchangeConnector exchangeConnector = EmbeddedExchangeConnector.builder().build(); + exchangeConnector.setPrimary(primary); + assertThat(exchangeConnector.isPrimary()).isEqualTo(primary); + } + } + + @Nested + class DelegateChannel { + + @Test + void should_delegate_initialize_to_channel() { + when(connectorChannel.initialize()).thenReturn(Completable.complete()); + cut.initialize().test().assertComplete(); + verify(connectorChannel).initialize(); + } + + @Test + void should_delegate_close_to_channel() { + when(connectorChannel.close()).thenReturn(Completable.complete()); + cut.close().test().assertComplete(); + verify(connectorChannel).close(); + } + + @Test + void should_delegate_target_id_to_channel() { + when(connectorChannel.targetId()).thenReturn("targetId"); + assertThat(cut.targetId()).isEqualTo("targetId"); + verify(connectorChannel).targetId(); + } + + @Test + void should_delegate_add_command_handlers_to_channel() { + List, ? extends Reply>> commandHandlers = List.of(); + cut.addCommandHandlers(commandHandlers); + verify(connectorChannel).addCommandHandlers(commandHandlers); + } + } + + @Nested + class Commands { + + @Mock + private Command command; + + @Mock + private Reply reply; + + private TestScheduler testScheduler; + + @BeforeEach + public void beforeEach() { + testScheduler = new TestScheduler(); + // set calls to Schedulers.computation() to use our test scheduler + RxJavaPlugins.setComputationSchedulerHandler(ignore -> testScheduler); + } + + @AfterEach + public void after() { + // reset it + RxJavaPlugins.setComputationSchedulerHandler(null); + } + + @Test + void should_send_commands_to_channel() { + when(connectorChannel.send(any())).thenReturn(Single.just(reply)); + EmbeddedExchangeConnector cut = EmbeddedExchangeConnector.builder().connectorChannel(connectorChannel).build(); + cut.sendCommand(command).test().assertValue(reply); + + verify(connectorChannel).send(command); + } + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/pom.xml b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/pom.xml new file mode 100644 index 0000000..1ee2e61 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/pom.xml @@ -0,0 +1,91 @@ + + + + 4.0.0 + + + io.gravitee.exchange + gravitee-exchange-connector + 1.0.0-alpha.7 + + + gravitee-exchange-connector-websocket + Gravitee.io - Exchange - Connector Websocket + + + io.gravitee.exchange + gravitee-exchange-api + ${project.version} + + + io.gravitee.exchange + gravitee-exchange-connector-core + ${project.version} + + + io.gravitee.exchange + gravitee-exchange-connector-embedded + ${project.version} + + + org.springframework + spring-context + provided + + + io.vertx + vertx-core + provided + + + io.vertx + vertx-rx-java3 + provided + + + io.reactivex.rxjava3 + rxjava + provided + + + io.gravitee.common + gravitee-common + test + + + io.gravitee.exchange + gravitee-exchange-api + ${project.version} + test-jar + test + + + io.vertx + vertx-junit5 + test + + + org.wiremock + wiremock + test + + + \ No newline at end of file diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/WebSocketExchangeConnector.java b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/WebSocketExchangeConnector.java new file mode 100644 index 0000000..657cad3 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/WebSocketExchangeConnector.java @@ -0,0 +1,159 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.websocket; + +import static io.gravitee.exchange.api.controller.ws.WebsocketControllerConstants.EXCHANGE_PROTOCOL_HEADER; + +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.controller.ws.WebsocketControllerConstants; +import io.gravitee.exchange.api.websocket.command.ExchangeSerDe; +import io.gravitee.exchange.api.websocket.protocol.ProtocolVersion; +import io.gravitee.exchange.connector.embedded.EmbeddedExchangeConnector; +import io.gravitee.exchange.connector.websocket.channel.WebSocketConnectorChannel; +import io.gravitee.exchange.connector.websocket.client.WebSocketConnectorClientFactory; +import io.gravitee.exchange.connector.websocket.exception.WebSocketConnectorException; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Maybe; +import io.reactivex.rxjava3.core.Single; +import io.vertx.core.http.WebSocketConnectOptions; +import io.vertx.rxjava3.core.Vertx; +import io.vertx.rxjava3.core.http.HttpClient; +import io.vertx.rxjava3.core.http.WebSocket; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.experimental.SuperBuilder; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@RequiredArgsConstructor +@SuperBuilder +@Slf4j +public class WebSocketExchangeConnector extends EmbeddedExchangeConnector { + + private final ProtocolVersion protocolVersion; + private final List, ? extends Reply>> commandHandlers; + private final List, ? extends Command, ? extends Reply>> commandAdapters; + private final List, ? extends Reply>> replyAdapters; + private final Vertx vertx; + private final WebSocketConnectorClientFactory webSocketConnectorClientFactory; + private final ExchangeSerDe exchangeSerDe; + + @Override + public Completable initialize() { + return Completable + .fromRunnable(() -> setPrimary(false)) + .andThen(this.connect()) + .flatMapCompletable(webSocket -> { + connectorChannel = + new WebSocketConnectorChannel( + commandHandlers, + commandAdapters, + replyAdapters, + vertx, + webSocket, + protocolVersion.adapterFactory().apply(exchangeSerDe) + ); + return connectorChannel + .initialize() + .doOnComplete(() -> + webSocket.closeHandler(v -> { + if (!Objects.equals(webSocket.closeStatusCode(), (short) 1000)) { + log.warn("Exchange Connector closed abnormally, reconnecting."); + initialize().onErrorComplete().subscribe(); + } + }) + ); + }) + .retryWhen(errors -> + errors.flatMap(err -> { + if (err instanceof WebSocketConnectorException connectorException && connectorException.isRetryable()) { + return Flowable.timer(5000, TimeUnit.MILLISECONDS); + } + log.error("Unable to connect to Exchange Connect Endpoint, stop retrying.", err); + return Flowable.error(err); + }) + ); + } + + private Single connect() { + return Maybe + .fromCallable(webSocketConnectorClientFactory::nextEndpoint) + .switchIfEmpty( + Maybe.fromRunnable(() -> { + throw new WebSocketConnectorException( + "No Exchange Controller Endpoint is defined or available. Please check your configuration", + false + ); + }) + ) + .toSingle() + .flatMap(webSocketEndpoint -> { + log.debug("Trying to connect to Exchange Controller WebSocket [{}]", webSocketEndpoint.getUri()); + HttpClient httpClient = webSocketConnectorClientFactory.createHttpClient(webSocketEndpoint); + WebSocketConnectOptions webSocketConnectOptions = new WebSocketConnectOptions() + .setURI(webSocketEndpoint.resolvePath(WebsocketControllerConstants.EXCHANGE_CONTROLLER_PATH)) + .addHeader(EXCHANGE_PROTOCOL_HEADER, protocolVersion.version()); + + if (webSocketConnectorClientFactory.getConfiguration().headers() != null) { + webSocketConnectorClientFactory.getConfiguration().headers().forEach(webSocketConnectOptions::addHeader); + } + return httpClient + .rxWebSocket(webSocketConnectOptions) + .doOnSuccess(webSocket -> { + webSocketEndpoint.resetRetryCount(); + log.info( + "Connector is now connected to Exchange Controller through websocket via [{}]", + webSocketEndpoint.getUri().toString() + ); + }) + .onErrorResumeNext(throwable -> { + int retryCount = webSocketEndpoint.getRetryCount(); + int maxRetryCount = webSocketEndpoint.getMaxRetryCount(); + if (retryCount < maxRetryCount) { + log.error( + "Unable to connect to Exchange Connect Endpoint: {}/{} time, retrying...", + retryCount, + maxRetryCount, + throwable + ); + } else { + log.error( + "Unable to connect to Exchange Connect Endpoint. Max retries attempt reached, changing endpoint.", + throwable + ); + } + // Force the HTTP client to close after a defect. + return httpClient + .close() + .andThen( + Single.error( + new WebSocketConnectorException("Unable to connect to Exchange Connect Endpoint", throwable, true) + ) + ); + }); + }); + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/channel/WebSocketConnectorChannel.java b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/channel/WebSocketConnectorChannel.java new file mode 100644 index 0000000..cd9e110 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/channel/WebSocketConnectorChannel.java @@ -0,0 +1,90 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.websocket.channel; + +import io.gravitee.exchange.api.channel.exception.ChannelException; +import io.gravitee.exchange.api.channel.exception.ChannelInitializationException; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.command.hello.HelloCommand; +import io.gravitee.exchange.api.command.hello.HelloCommandPayload; +import io.gravitee.exchange.api.connector.ConnectorChannel; +import io.gravitee.exchange.api.websocket.channel.AbstractWebSocketChannel; +import io.gravitee.exchange.api.websocket.protocol.ProtocolAdapter; +import io.gravitee.exchange.connector.websocket.exception.WebSocketConnectorException; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Single; +import io.vertx.rxjava3.core.Vertx; +import io.vertx.rxjava3.core.http.WebSocketBase; +import java.util.List; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Slf4j +public class WebSocketConnectorChannel extends AbstractWebSocketChannel implements ConnectorChannel { + + public WebSocketConnectorChannel( + final List, ? extends Reply>> commandHandlers, + final List, ? extends Command, ? extends Reply>> commandAdapters, + final List, ? extends Reply>> replyAdapters, + final Vertx vertx, + final WebSocketBase webSocket, + final ProtocolAdapter protocolAdapter + ) { + super(commandHandlers, commandAdapters, replyAdapters, vertx, webSocket, protocolAdapter); + } + + @Override + public Completable initialize() { + return super + .initialize() + .andThen( + Single.defer(() -> { + HelloCommand helloCommand = new HelloCommand(new HelloCommandPayload(UUID.randomUUID().toString())); + return sendHelloCommand(helloCommand) + .onErrorResumeNext(throwable -> { + if (throwable instanceof ChannelException) { + return Single.error(new WebSocketConnectorException("Hello handshake failed", throwable, true)); + } else { + return Single.error(throwable); + } + }) + .doOnSuccess(reply -> { + if (reply.getCommandStatus() == CommandStatus.SUCCEEDED) { + this.targetId = reply.getPayload().getTargetId(); + this.active = true; + } else { + throw new ChannelInitializationException("Unable to parse hello reply payload"); + } + }); + }) + ) + .ignoreElement(); + } + + @Override + protected boolean expectHelloCommand() { + return false; + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/client/WebSocketClientConfiguration.java b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/client/WebSocketClientConfiguration.java new file mode 100644 index 0000000..d2378c9 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/client/WebSocketClientConfiguration.java @@ -0,0 +1,164 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.websocket.client; + +import io.gravitee.exchange.api.configuration.IdentifyConfiguration; +import io.vertx.core.http.HttpServerOptions; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@RequiredArgsConstructor +public class WebSocketClientConfiguration { + + public static final String HEADERS_KEY = "connector.ws.headers"; + public static final String MAX_RETRY_KEY = "connector.ws.maxRetry"; + public static final int MAX_RETRY_DEFAULT = 5; + public static final String TRUST_ALL_KEY = "connector.ws.ssl.trustAll"; + public static final boolean TRUST_ALL_DEFAULT = false; + public static final String VERIFY_HOST_KEY = "connector.ws.ssl.verifyHost"; + public static final boolean VERIFY_HOST_DEFAULT = true; + public static final String KEYSTORE_TYPE_KEY = "connector.ws.ssl.keystore.type"; + public static final String KEYSTORE_PATH_KEY = "connector.ws.ssl.keystore.path"; + public static final String KEYSTORE_PASSWORD_KEY = "connector.ws.ssl.keystore.password"; + public static final String TRUSTSTORE_TYPE_KEY = "connector.ws.ssl.truststore.type"; + public static final String TRUSTSTORE_PATH_KEY = "connector.ws.ssl.truststore.path"; + public static final String TRUSTSTORE_PASSWORD_KEY = "connector.ws.ssl.truststore.password"; + public static final String MAX_WEB_SOCKET_FRAME_SIZE_KEY = "connector.ws.maxWebSocketFrameSize"; + public static final int MAX_WEB_SOCKET_FRAME_SIZE_DEFAULT = 65536; + public static final String MAX_WEB_SOCKET_MESSAGE_SIZE_KEY = "connector.ws.maxWebSocketMessageSize"; + public static final int MAX_WEB_SOCKET_MESSAGE_SIZE_DEFAULT = 13107200; + public static final String ENDPOINTS_KEY = "connector.ws.endpoints"; + private final IdentifyConfiguration identifyConfiguration; + + private List endpoints; + private Map headers; + + public Map headers() { + if (headers == null) { + headers = readHeaders(); + } + return headers; + } + + private Map readHeaders() { + int endpointIndex = 0; + String key = ("%s[%s]").formatted(HEADERS_KEY, endpointIndex); + Map computedHeaders = new HashMap<>(); + while (identifyConfiguration.containsProperty(key + ".name")) { + String name = identifyConfiguration.getProperty(key + ".name"); + String value = identifyConfiguration.getProperty(key + ".value"); + if (name != null && value != null) { + computedHeaders.put(name, value); + } + endpointIndex++; + key = ("%s[%s]").formatted(HEADERS_KEY, endpointIndex); + } + return computedHeaders; + } + + public int maxRetry() { + return identifyConfiguration.getProperty(MAX_RETRY_KEY, Integer.class, MAX_RETRY_DEFAULT); + } + + public boolean trustAll() { + return identifyConfiguration.getProperty(TRUST_ALL_KEY, Boolean.class, TRUST_ALL_DEFAULT); + } + + public boolean verifyHost() { + return identifyConfiguration.getProperty(VERIFY_HOST_KEY, Boolean.class, VERIFY_HOST_DEFAULT); + } + + public String keyStoreType() { + return identifyConfiguration.getProperty(KEYSTORE_TYPE_KEY); + } + + public String keyStorePath() { + return identifyConfiguration.getProperty(KEYSTORE_PATH_KEY); + } + + public String keyStorePassword() { + return identifyConfiguration.getProperty(KEYSTORE_PASSWORD_KEY); + } + + public String trustStoreType() { + return identifyConfiguration.getProperty(TRUSTSTORE_TYPE_KEY); + } + + public String trustStorePath() { + return identifyConfiguration.getProperty(TRUSTSTORE_PATH_KEY); + } + + public String trustStorePassword() { + return identifyConfiguration.getProperty(TRUSTSTORE_PASSWORD_KEY); + } + + /** + * Max size of a WebSocket frame. + * Be careful when changing this value, it needs to be a good trade-off between: + *

    + *
  • memory consumption (the bigger the value, the more memory is used)
  • + *
  • performance (the smaller the value, the more CPU is used)
  • + *
  • network usage (the smaller the value, the more network calls are made)
  • + *
+ *

+ * Default value is the same as the one in Vert.x, 65536 bytes (64KB). + * + * @see HttpServerOptions#DEFAULT_MAX_WEBSOCKET_FRAME_SIZE + */ + public int maxWebSocketFrameSize() { + return identifyConfiguration.getProperty(MAX_WEB_SOCKET_FRAME_SIZE_KEY, Integer.class, MAX_WEB_SOCKET_FRAME_SIZE_DEFAULT); + } + + /** + * A WebSocket messages can be composed of several WebSocket frames. + * This value is the maximum size of a WebSocket message. + *

+ * It should be a multiple of {@link #maxWebSocketFrameSize}. + *

+ * Default value is 200 x {@link #maxWebSocketFrameSize} = 13MB. + * It can sound big but when doing API Promotion with APIM, the payload can be huge as it includes the doc pages, images etc. + * + * @see HttpServerOptions#DEFAULT_MAX_WEBSOCKET_MESSAGE_SIZE + */ + public int maxWebSocketMessageSize() { + return identifyConfiguration.getProperty(MAX_WEB_SOCKET_MESSAGE_SIZE_KEY, Integer.class, MAX_WEB_SOCKET_MESSAGE_SIZE_DEFAULT); + } + + public List endpoints() { + if (endpoints == null) { + endpoints = readEndpoints(); + } + + return endpoints; + } + + private List readEndpoints() { + List endpointsConfiguration = new ArrayList<>(); + List propertyList = identifyConfiguration.getPropertyList(ENDPOINTS_KEY); + if (propertyList != null) { + int maxRetryCount = maxRetry(); + endpointsConfiguration.addAll(propertyList.stream().map(url -> new WebSocketEndpoint(url, maxRetryCount)).toList()); + } + return endpointsConfiguration; + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/client/WebSocketConnectorClientFactory.java b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/client/WebSocketConnectorClientFactory.java new file mode 100644 index 0000000..25b5c8f --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/client/WebSocketConnectorClientFactory.java @@ -0,0 +1,123 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.websocket.client; + +import io.gravitee.exchange.connector.websocket.exception.WebSocketConnectorException; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.net.JksOptions; +import io.vertx.core.net.PemTrustOptions; +import io.vertx.core.net.PfxOptions; +import io.vertx.rxjava3.core.Vertx; +import io.vertx.rxjava3.core.http.HttpClient; +import java.net.URI; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Slf4j +@RequiredArgsConstructor +public class WebSocketConnectorClientFactory { + + private static final String KEYSTORE_FORMAT_JKS = "JKS"; + private static final String KEYSTORE_FORMAT_PEM = "PEM"; + private static final String KEYSTORE_FORMAT_PKCS12 = "PKCS12"; + + private final AtomicInteger counter = new AtomicInteger(0); + private final Vertx vertx; + + @Getter + private final WebSocketClientConfiguration configuration; + + public WebSocketEndpoint nextEndpoint() { + List endpoints = configuration.endpoints(); + + if (endpoints.isEmpty()) { + return null; + } + + WebSocketEndpoint endpoint = endpoints.get(Math.abs(counter.getAndIncrement() % endpoints.size())); + + endpoint.incrementRetryCount(); + if (endpoint.isRemovable()) { + log.info( + "Websocket Exchange Connector connects to endpoint at {} more than {} times. Removing instance...", + endpoint.getUri().toString(), + endpoint.getMaxRetryCount() + ); + configuration.endpoints().remove(endpoint); + return nextEndpoint(); + } + + return endpoint; + } + + public HttpClient createHttpClient(WebSocketEndpoint websocketEndpoint) { + URI target = websocketEndpoint.getUri(); + HttpClientOptions options = new HttpClientOptions(); + options.setDefaultHost(websocketEndpoint.getHost()); + options.setDefaultPort(websocketEndpoint.getPort()); + + if (isSecureProtocol(target.getScheme())) { + options.setSsl(true); + options.setTrustAll(configuration.trustAll()); + options.setVerifyHost(configuration.verifyHost()); + } + if (configuration.keyStoreType() != null) { + if (configuration.keyStoreType().equalsIgnoreCase(KEYSTORE_FORMAT_JKS)) { + options.setKeyStoreOptions( + new JksOptions().setPath(configuration.keyStorePath()).setPassword(configuration.keyStorePassword()) + ); + } else if (configuration.keyStoreType().equalsIgnoreCase(KEYSTORE_FORMAT_PKCS12)) { + options.setPfxKeyCertOptions( + new PfxOptions().setPath(configuration.keyStorePath()).setPassword(configuration.keyStorePassword()) + ); + } else { + throw new WebSocketConnectorException("Unsupported keystore type", false); + } + } + + if (configuration.trustStoreType() != null) { + if (configuration.trustStoreType().equalsIgnoreCase(KEYSTORE_FORMAT_JKS)) { + options.setTrustStoreOptions( + new JksOptions().setPath(configuration.trustStorePath()).setPassword(configuration.trustStorePassword()) + ); + } else if (configuration.trustStoreType().equalsIgnoreCase(KEYSTORE_FORMAT_PKCS12)) { + options.setPfxTrustOptions( + new PfxOptions().setPath(configuration.trustStorePath()).setPassword(configuration.trustStorePassword()) + ); + } else if (configuration.trustStoreType().equalsIgnoreCase(KEYSTORE_FORMAT_PEM)) { + options.setPemTrustOptions(new PemTrustOptions().addCertPath(configuration.trustStorePath())); + } else { + throw new WebSocketConnectorException("Unsupported truststore type", false); + } + } + + options.setMaxWebSocketFrameSize(configuration.maxWebSocketFrameSize()); + options.setMaxWebSocketMessageSize(configuration.maxWebSocketMessageSize()); + + return vertx.createHttpClient(options); + } + + private boolean isSecureProtocol(String scheme) { + return scheme.charAt(scheme.length() - 1) == 's' && scheme.length() > 2; + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/client/WebSocketEndpoint.java b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/client/WebSocketEndpoint.java new file mode 100644 index 0000000..fefaea9 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/client/WebSocketEndpoint.java @@ -0,0 +1,74 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.websocket.client; + +import java.net.URI; +import lombok.Builder; +import lombok.Getter; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Getter +public class WebSocketEndpoint { + + private static final String HTTPS_SCHEME = "https"; + private static final int DEFAULT_HTTP_PORT = 80; + private static final int DEFAULT_HTTPS_PORT = 443; + public static final int DEFAULT_MAX_RETRY_COUNT = 5; + + private final URI uri; + private final int maxRetryCount; + private int retryCount; + + @Builder + public WebSocketEndpoint(final String url, final int maxRetryCount) { + this.uri = URI.create(url); + this.maxRetryCount = maxRetryCount > 0 ? maxRetryCount : DEFAULT_MAX_RETRY_COUNT; + this.retryCount = 0; + } + + public void incrementRetryCount() { + this.retryCount++; + } + + public void resetRetryCount() { + this.retryCount = 0; + } + + public int getPort() { + if (uri.getPort() != -1) { + return uri.getPort(); + } else if (HTTPS_SCHEME.equals(uri.getScheme())) { + return DEFAULT_HTTPS_PORT; + } else { + return DEFAULT_HTTP_PORT; + } + } + + public String getHost() { + return uri.getHost(); + } + + public String resolvePath(String path) { + return uri.resolve(path).getRawPath(); + } + + public boolean isRemovable() { + return this.retryCount > maxRetryCount; + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/exception/WebSocketConnectorException.java b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/exception/WebSocketConnectorException.java new file mode 100644 index 0000000..52ba3a3 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/exception/WebSocketConnectorException.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.websocket.exception; + +import lombok.Getter; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Getter +public class WebSocketConnectorException extends RuntimeException { + + private final boolean retryable; + + public WebSocketConnectorException(final String message, final boolean retryable) { + super(message); + this.retryable = retryable; + } + + public WebSocketConnectorException(final String message, final Throwable cause, final boolean retryable) { + super(message, cause); + this.retryable = retryable; + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/spring/ConnectorWebSocketConfiguration.java b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/spring/ConnectorWebSocketConfiguration.java new file mode 100644 index 0000000..e31d5a3 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/spring/ConnectorWebSocketConfiguration.java @@ -0,0 +1,28 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.websocket.spring; + +import io.gravitee.exchange.connector.core.spring.ConnectorCoreConfiguration; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Configuration +@Import({ ConnectorCoreConfiguration.class }) +public class ConnectorWebSocketConfiguration {} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/AbstractWebSocketConnectorTest.java b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/AbstractWebSocketConnectorTest.java new file mode 100644 index 0000000..f4012aa --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/AbstractWebSocketConnectorTest.java @@ -0,0 +1,87 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.websocket; + +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.hello.HelloCommand; +import io.gravitee.exchange.api.command.hello.HelloReply; +import io.gravitee.exchange.api.command.hello.HelloReplyPayload; +import io.gravitee.exchange.api.websocket.channel.test.AbstractWebSocketTest; +import io.gravitee.exchange.api.websocket.protocol.ProtocolAdapter; +import io.gravitee.exchange.api.websocket.protocol.ProtocolExchange; +import io.vertx.junit5.VertxExtension; +import io.vertx.rxjava3.core.http.ServerWebSocket; +import java.util.function.Consumer; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@ExtendWith(VertxExtension.class) +public abstract class AbstractWebSocketConnectorTest extends AbstractWebSocketTest { + + protected void replyHello(final ServerWebSocket serverWebSocket, final ProtocolAdapter protocolAdapter) { + this.replyHello(serverWebSocket, protocolAdapter, cmd -> {}); + } + + protected void replyHello( + final ServerWebSocket serverWebSocket, + final ProtocolAdapter protocolAdapter, + Consumer> commandHandler + ) { + serverWebSocket.binaryMessageHandler(buffer -> { + ProtocolExchange websocketExchange = protocolAdapter.read(buffer); + if (websocketExchange.type() == ProtocolExchange.Type.COMMAND) { + Command command = websocketExchange.asCommand(); + if (command.getType().equals(HelloCommand.COMMAND_TYPE)) { + HelloReply helloReply = new HelloReply(command.getId(), new HelloReplyPayload("targetId")); + serverWebSocket + .writeBinaryMessage( + protocolAdapter.write( + ProtocolExchange + .builder() + .type(ProtocolExchange.Type.REPLY) + .exchangeType(helloReply.getType()) + .exchange(helloReply) + .build() + ) + ) + .subscribe(); + } else if (command.getType().equals(io.gravitee.exchange.api.websocket.protocol.legacy.hello.HelloCommand.COMMAND_TYPE)) { + io.gravitee.exchange.api.websocket.protocol.legacy.hello.HelloReply helloReply = new io.gravitee.exchange.api.websocket.protocol.legacy.hello.HelloReply( + command.getId(), + new io.gravitee.exchange.api.websocket.protocol.legacy.hello.HelloReplyPayload("targetId") + ); + serverWebSocket + .writeBinaryMessage( + protocolAdapter.write( + ProtocolExchange + .builder() + .type(ProtocolExchange.Type.REPLY) + .exchangeType(helloReply.getType()) + .exchange(helloReply) + .build() + ) + ) + .subscribe(); + } else { + commandHandler.accept(command); + } + } + }); + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/WebSocketExchangeConnectorTest.java b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/WebSocketExchangeConnectorTest.java new file mode 100644 index 0000000..ecc524b --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/WebSocketExchangeConnectorTest.java @@ -0,0 +1,104 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.websocket; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.gravitee.exchange.api.configuration.IdentifyConfiguration; +import io.gravitee.exchange.api.websocket.protocol.ProtocolVersion; +import io.gravitee.exchange.connector.websocket.client.WebSocketClientConfiguration; +import io.gravitee.exchange.connector.websocket.client.WebSocketConnectorClientFactory; +import io.gravitee.exchange.connector.websocket.exception.WebSocketConnectorException; +import io.vertx.junit5.Checkpoint; +import io.vertx.junit5.VertxTestContext; +import io.vertx.rxjava3.core.http.ServerWebSocket; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +class WebSocketExchangeConnectorTest extends AbstractWebSocketConnectorTest { + + private MockEnvironment environment; + private WebSocketExchangeConnector websocketExchangeConnector; + + @BeforeEach + public void beforeEach() { + environment = new MockEnvironment(); + environment.setProperty("exchange.connector.ws.endpoints[0]", "http://localhost:%s".formatted(serverPort)); + WebSocketConnectorClientFactory webSocketConnectorClientFactory = new WebSocketConnectorClientFactory( + vertx, + new WebSocketClientConfiguration(new IdentifyConfiguration(environment)) + ); + this.websocketExchangeConnector = + new WebSocketExchangeConnector( + ProtocolVersion.V1, + List.of(), + List.of(), + List.of(), + vertx, + webSocketConnectorClientFactory, + exchangeSerDe + ); + } + + @AfterEach + public void afterEach() { + websocketServerHandler = null; + } + + @Test + void should_initialize_connector_including_hello_handshake() { + websocketServerHandler = serverWebSocket -> this.replyHello(serverWebSocket, protocolAdapter(ProtocolVersion.V1)); + websocketExchangeConnector.initialize().test().awaitDone(30, TimeUnit.SECONDS).assertComplete(); + } + + @Test + void should_not_fail_with_timeout_when_initializing_connector_without_hello_reply() { + websocketExchangeConnector.initialize().test().assertNotComplete(); + } + + @Test + void should_not_fail_with_timeout_when_initializing_connector_without_endpoint() { + environment.setProperty("exchange.connector.ws.endpoints[0]", ""); + websocketExchangeConnector.initialize().test().assertError(WebSocketConnectorException.class); + } + + @Test + void should_reconnect_after_unexpected_close(VertxTestContext testContext) throws InterruptedException { + AtomicReference ws = new AtomicReference<>(); + Checkpoint checkpoint = testContext.checkpoint(2); + websocketServerHandler = + serverWebSocket -> { + replyHello(serverWebSocket, protocolAdapter(ProtocolVersion.V1)); + ws.set(serverWebSocket); + checkpoint.flag(); + }; + // Initialize first + websocketExchangeConnector.initialize().test().awaitDone(10, TimeUnit.SECONDS).assertComplete(); + + // Close websocket to execute reconnect + ws.get().close((short) 1001); + assertThat(testContext.awaitCompletion(10, TimeUnit.SECONDS)).isTrue(); + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/channel/WebSocketConnectorChannelTest.java b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/channel/WebSocketConnectorChannelTest.java new file mode 100644 index 0000000..eda30eb --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/channel/WebSocketConnectorChannelTest.java @@ -0,0 +1,274 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.websocket.channel; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.gravitee.exchange.api.channel.exception.ChannelTimeoutException; +import io.gravitee.exchange.api.controller.ws.WebsocketControllerConstants; +import io.gravitee.exchange.api.websocket.channel.test.AbstractWebSocketTest; +import io.gravitee.exchange.api.websocket.channel.test.DummyCommand; +import io.gravitee.exchange.api.websocket.channel.test.DummyCommandHandler; +import io.gravitee.exchange.api.websocket.channel.test.DummyPayload; +import io.gravitee.exchange.api.websocket.channel.test.DummyReply; +import io.gravitee.exchange.api.websocket.protocol.ProtocolAdapter; +import io.gravitee.exchange.api.websocket.protocol.ProtocolExchange; +import io.gravitee.exchange.api.websocket.protocol.ProtocolVersion; +import io.gravitee.exchange.connector.websocket.AbstractWebSocketConnectorTest; +import io.gravitee.exchange.connector.websocket.exception.WebSocketConnectorException; +import io.reactivex.rxjava3.observers.TestObserver; +import io.reactivex.rxjava3.plugins.RxJavaPlugins; +import io.reactivex.rxjava3.schedulers.TestScheduler; +import io.vertx.core.http.WebSocketConnectOptions; +import io.vertx.junit5.Checkpoint; +import io.vertx.junit5.VertxTestContext; +import io.vertx.rxjava3.core.buffer.Buffer; +import io.vertx.rxjava3.core.http.HttpClient; +import io.vertx.rxjava3.core.http.ServerWebSocket; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +class WebSocketConnectorChannelTest extends AbstractWebSocketConnectorTest { + + @AfterEach + public void afterEach() { + RxJavaPlugins.reset(); + } + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_initialize_with_hello_reply(ProtocolVersion protocolVersion) { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + HttpClient httpClient = AbstractWebSocketTest.vertx.createHttpClient(); + WebSocketConnectOptions webSocketConnectOptions = new WebSocketConnectOptions() + .setHost("localhost") + .setPort(AbstractWebSocketTest.serverPort) + .setURI(WebsocketControllerConstants.EXCHANGE_CONTROLLER_PATH); + AbstractWebSocketTest.websocketServerHandler = ws -> this.replyHello(ws, protocolAdapter); + httpClient + .rxWebSocket(webSocketConnectOptions) + .flatMapCompletable(webSocket -> { + WebSocketConnectorChannel webSocketConnectorChannel = new WebSocketConnectorChannel( + List.of(), + List.of(), + List.of(), + AbstractWebSocketTest.vertx, + webSocket, + protocolAdapter + ); + return webSocketConnectorChannel.initialize().doFinally(webSocket::close); + }) + .test() + .awaitDone(10, TimeUnit.SECONDS) + .assertComplete(); + } + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_failed_to_initialize_after_time_out_without_hello_reply(ProtocolVersion protocolVersion) { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + HttpClient httpClient = AbstractWebSocketTest.vertx.createHttpClient(); + WebSocketConnectOptions webSocketConnectOptions = new WebSocketConnectOptions() + .setHost("localhost") + .setPort(AbstractWebSocketTest.serverPort) + .setURI(WebsocketControllerConstants.EXCHANGE_CONTROLLER_PATH); + TestScheduler testScheduler = new TestScheduler(); + // set calls to Schedulers.computation() to use our test scheduler + RxJavaPlugins.setComputationSchedulerHandler(ignore -> testScheduler); + // Advance in time when hello command is received so reply will timeout + AbstractWebSocketTest.websocketServerHandler = + ws -> ws.binaryMessageHandler(event -> testScheduler.advanceTimeBy(60, TimeUnit.SECONDS)); + + TestObserver testObserver = httpClient + .rxWebSocket(webSocketConnectOptions) + .flatMapCompletable(webSocket -> { + WebSocketConnectorChannel webSocketConnectorChannel = new WebSocketConnectorChannel( + List.of(), + List.of(), + List.of(), + AbstractWebSocketTest.vertx, + webSocket, + protocolAdapter + ); + return webSocketConnectorChannel.initialize().doFinally(webSocket::close); + }) + .test(); + testObserver.awaitDone(10, TimeUnit.SECONDS).assertError(WebSocketConnectorException.class); + } + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_send_command_and_receive_reply(ProtocolVersion protocolVersion) { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + AbstractWebSocketTest.websocketServerHandler = + serverWebSocket -> + this.replyHello( + serverWebSocket, + protocolAdapter, + command -> { + DummyReply dummyReply = new DummyReply(command.getId(), new DummyPayload()); + serverWebSocket + .writeBinaryMessage( + protocolAdapter.write( + ProtocolExchange + .builder() + .type(ProtocolExchange.Type.REPLY) + .exchangeType(dummyReply.getType()) + .exchange(dummyReply) + .build() + ) + ) + .subscribe(); + } + ); + DummyCommand command = new DummyCommand(new DummyPayload()); + AbstractWebSocketTest + .rxWebSocket() + .flatMap(webSocket -> { + WebSocketConnectorChannel webSocketConnectorChannel = new WebSocketConnectorChannel( + List.of(), + List.of(), + List.of(), + AbstractWebSocketTest.vertx, + webSocket, + protocolAdapter + ); + return webSocketConnectorChannel.initialize().andThen(webSocketConnectorChannel.send(command)).doFinally(webSocket::close); + }) + .test() + .awaitDone(10, TimeUnit.SECONDS) + .assertValue(reply -> { + assertThat(reply).isInstanceOf(DummyReply.class); + assertThat(reply.getCommandId()).isEqualTo(command.getId()); + return true; + }); + } + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_send_command_and_receive_empty_reply_after_timeout(ProtocolVersion protocolVersion) { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + TestScheduler testScheduler = new TestScheduler(); + // set calls to Schedulers.computation() to use our test scheduler + RxJavaPlugins.setComputationSchedulerHandler(ignore -> testScheduler); + // Advance in time when primary command is received so reply will timeout + AbstractWebSocketTest.websocketServerHandler = + serverWebSocket -> + this.replyHello(serverWebSocket, protocolAdapter, command -> testScheduler.advanceTimeBy(60, TimeUnit.SECONDS)); + DummyCommand command = new DummyCommand(new DummyPayload()); + + AbstractWebSocketTest + .rxWebSocket() + .flatMap(webSocket -> { + WebSocketConnectorChannel webSocketConnectorChannel = new WebSocketConnectorChannel( + List.of(), + List.of(), + List.of(), + AbstractWebSocketTest.vertx, + webSocket, + protocolAdapter + ); + return webSocketConnectorChannel.initialize().andThen(webSocketConnectorChannel.send(command)).doFinally(webSocket::close); + }) + .test() + .awaitDone(10, TimeUnit.SECONDS) + .assertError(ChannelTimeoutException.class); + } + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_receive_pong(ProtocolVersion protocolVersion, VertxTestContext vertxTestContext) throws InterruptedException { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + Checkpoint pongCheckpoint = vertxTestContext.checkpoint(); + AbstractWebSocketTest.websocketServerHandler = + serverWebSocket -> { + this.replyHello(serverWebSocket, protocolAdapter); + serverWebSocket.pongHandler(event -> pongCheckpoint.flag()); + serverWebSocket.writePing(Buffer.buffer("ping")); + }; + + AbstractWebSocketTest + .rxWebSocket() + .flatMapCompletable(webSocket -> { + WebSocketConnectorChannel webSocketConnectorChannel = new WebSocketConnectorChannel( + List.of(), + List.of(), + List.of(), + AbstractWebSocketTest.vertx, + webSocket, + protocolAdapter + ); + return webSocketConnectorChannel.initialize().doFinally(webSocket::close); + }) + .test() + .awaitDone(10, TimeUnit.SECONDS) + .assertComplete(); + assertThat(vertxTestContext.awaitCompletion(10, TimeUnit.SECONDS)).isTrue(); + } + + @ParameterizedTest + @EnumSource(ProtocolVersion.class) + void should_handle_command(ProtocolVersion protocolVersion, VertxTestContext vertxTestContext) throws InterruptedException { + ProtocolAdapter protocolAdapter = protocolAdapter(protocolVersion); + Checkpoint handlerCheckpoint = vertxTestContext.checkpoint(); + + AtomicReference webSocketAtomicReference = new AtomicReference<>(); + AbstractWebSocketTest.websocketServerHandler = + ws -> { + webSocketAtomicReference.set(ws); + this.replyHello(ws, protocolAdapter); + }; + AbstractWebSocketTest + .rxWebSocket() + .flatMapCompletable(webSocket -> { + WebSocketConnectorChannel webSocketConnectorChannel = new WebSocketConnectorChannel( + List.of(new DummyCommandHandler(handlerCheckpoint)), + List.of(), + List.of(), + AbstractWebSocketTest.vertx, + webSocket, + protocolAdapter + ); + return webSocketConnectorChannel.initialize(); + }) + .test() + .awaitDone(10, TimeUnit.SECONDS) + .assertComplete(); + + webSocketAtomicReference + .get() + .writeBinaryMessage( + protocolAdapter.write( + ProtocolExchange + .builder() + .type(ProtocolExchange.Type.COMMAND) + .exchangeType(DummyCommand.COMMAND_TYPE) + .exchange(new DummyCommand(new DummyPayload())) + .build() + ) + ); + + assertThat(vertxTestContext.awaitCompletion(10, TimeUnit.SECONDS)).isTrue(); + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/client/WebSocketClientConfigurationTest.java b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/client/WebSocketClientConfigurationTest.java new file mode 100644 index 0000000..4d530ee --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/client/WebSocketClientConfigurationTest.java @@ -0,0 +1,219 @@ +package io.gravitee.exchange.connector.websocket.client;/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; +import static org.assertj.core.groups.Tuple.tuple; + +import io.gravitee.exchange.api.configuration.IdentifyConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class WebSocketClientConfigurationTest { + + private MockEnvironment environment; + + @BeforeEach + void beforeEach() { + environment = new MockEnvironment(); + } + + @Nested + class DefaultPrefix { + + protected WebSocketClientConfiguration cut; + protected String prefix; + + @BeforeEach + void beforeEach() { + if (prefix == null) { + IdentifyConfiguration identifyConfiguration = new IdentifyConfiguration(environment); + prefix = identifyConfiguration.id(); + cut = new WebSocketClientConfiguration(identifyConfiguration); + } else { + cut = new WebSocketClientConfiguration(new IdentifyConfiguration(environment, prefix)); + } + } + + @Test + void should_return_headers() { + environment + .withProperty("%s.connector.ws.headers[0].name".formatted(prefix), "name") + .withProperty("%s.connector.ws.headers[0].value".formatted(prefix), "value") + .withProperty("%s.connector.ws.headers[1].name".formatted(prefix), "name1") + .withProperty("%s.connector.ws.headers[1].value".formatted(prefix), "value1"); + assertThat(cut.headers()).containsOnly(entry("name", "value"), entry("name1", "value1")); + } + + @Test + void should_return_empty_headers_without_configuration() { + assertThat(cut.headers()).isEmpty(); + } + + @Test + void should_return_max_retry() { + environment.withProperty("%s.connector.ws.maxRetry".formatted(prefix), "123456"); + assertThat(cut.maxRetry()).isEqualTo(123456); + } + + @Test + void should_return_default_max_retry_without_configuration() { + assertThat(cut.maxRetry()).isEqualTo(5); + } + + @Test + void should_return_trust_all() { + environment.withProperty("%s.connector.ws.ssl.trustAll".formatted(prefix), "true"); + assertThat(cut.trustAll()).isTrue(); + } + + @Test + void should_return_default_trust_all_without_configuration() { + assertThat(cut.trustAll()).isFalse(); + } + + @Test + void should_return_verify_host() { + environment.withProperty("%s.connector.ws.ssl.verifyHost".formatted(prefix), "false"); + assertThat(cut.verifyHost()).isFalse(); + } + + @Test + void should_return_default_verify_host_without_configuration() { + assertThat(cut.verifyHost()).isTrue(); + } + + @Test + void should_return_key_store_type() { + environment.withProperty("%s.connector.ws.ssl.keystore.type".formatted(prefix), "PEM"); + assertThat(cut.keyStoreType()).isEqualTo("PEM"); + } + + @Test + void should_return_null_key_store_type_without_configuration() { + assertThat(cut.keyStoreType()).isNull(); + } + + @Test + void should_return_key_store_path() { + environment.withProperty("%s.connector.ws.ssl.keystore.path".formatted(prefix), "/path"); + assertThat(cut.keyStorePath()).isEqualTo("/path"); + } + + @Test + void should_return_null_key_store_path_without_configuration() { + assertThat(cut.keyStorePath()).isNull(); + } + + @Test + void should_return_key_store_password() { + environment.withProperty("%s.connector.ws.ssl.keystore.password".formatted(prefix), "pwd"); + assertThat(cut.keyStorePassword()).isEqualTo("pwd"); + } + + @Test + void should_return_null_key_store_password_without_configuration() { + assertThat(cut.keyStorePassword()).isNull(); + } + + @Test + void should_return_trust_store_type() { + environment.withProperty("%s.connector.ws.ssl.truststore.type".formatted(prefix), "PEM"); + assertThat(cut.trustStoreType()).isEqualTo("PEM"); + } + + @Test + void should_return_null_trust_store_type_without_configuration() { + assertThat(cut.trustStoreType()).isNull(); + } + + @Test + void should_return_trust_store_path() { + environment.withProperty("%s.connector.ws.ssl.truststore.path".formatted(prefix), "/path"); + assertThat(cut.trustStorePath()).isEqualTo("/path"); + } + + @Test + void should_return_null_trust_store_path_without_configuration() { + assertThat(cut.trustStorePath()).isNull(); + } + + @Test + void should_return_trust_store_password() { + environment.withProperty("%s.connector.ws.ssl.truststore.password".formatted(prefix), "pwd"); + assertThat(cut.trustStorePassword()).isEqualTo("pwd"); + } + + @Test + void should_return_null_trust_store_password_without_configuration() { + assertThat(cut.trustStorePassword()).isNull(); + } + + @Test + void should_return_max_web_socket_frame_size() { + environment.withProperty("%s.connector.ws.maxWebSocketFrameSize".formatted(prefix), "123456"); + assertThat(cut.maxWebSocketFrameSize()).isEqualTo(123456); + } + + @Test + void should_return_default_max_web_socket_frame_size_without_configuration() { + assertThat(cut.maxWebSocketFrameSize()).isEqualTo(65536); + } + + @Test + void should_return_max_web_socket_message_size() { + environment.withProperty("%s.connector.ws.maxWebSocketMessageSize".formatted(prefix), "123456"); + assertThat(cut.maxWebSocketMessageSize()).isEqualTo(123456); + } + + @Test + void should_return_default_max_web_socket_message_size_without_configuration() { + assertThat(cut.maxWebSocketMessageSize()).isEqualTo(13107200); + } + + @Test + void should_return_endpoints() { + environment + .withProperty("%s.connector.ws.endpoints[0]".formatted(prefix), "http://endpoint:1234") + .withProperty("%s.connector.ws.endpoints[1]".formatted(prefix), "http://endpoint2:5678"); + assertThat(cut.endpoints()).extracting("host", "port").contains(tuple("endpoint", 1234), tuple("endpoint2", 5678)); + } + + @Test + void should_return_empty_endpoints_without_configuration() { + assertThat(cut.endpoints()).isEmpty(); + } + } + + @Nested + class CustomPrefix extends DefaultPrefix { + + @BeforeEach + void beforeEach() { + prefix = "custom"; + super.beforeEach(); + } + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/client/WebSocketConnectorClientFactoryTest.java b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/client/WebSocketConnectorClientFactoryTest.java new file mode 100644 index 0000000..6f3fef3 --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/client/WebSocketConnectorClientFactoryTest.java @@ -0,0 +1,292 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.websocket.client; + +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.google.common.io.Resources; +import io.gravitee.exchange.api.configuration.IdentifyConfiguration; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpMethod; +import io.vertx.junit5.VertxExtension; +import io.vertx.rxjava3.core.http.HttpClient; +import io.vertx.rxjava3.core.http.HttpClientRequest; +import java.io.File; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLHandshakeException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.mock.env.MockEnvironment; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@ExtendWith(VertxExtension.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class WebSocketConnectorClientFactoryTest { + + private io.vertx.rxjava3.core.Vertx vertx; + private MockEnvironment environment; + private WebSocketClientConfiguration webSocketClientConfiguration; + private WebSocketConnectorClientFactory cut; + + @BeforeEach + public void beforeEach(Vertx vertx) { + this.vertx = io.vertx.rxjava3.core.Vertx.newInstance(vertx); + this.environment = new MockEnvironment(); + this.webSocketClientConfiguration = new WebSocketClientConfiguration(new IdentifyConfiguration(environment)); + cut = new WebSocketConnectorClientFactory(this.vertx, webSocketClientConfiguration); + } + + @Nested + class Endpoints { + + @Test + void should_return_null_without_endpoint() { + WebSocketEndpoint webSocketEndpoint = cut.nextEndpoint(); + assertThat(webSocketEndpoint).isNull(); + } + + @Test + void should_return_next_endpoint() { + environment.setProperty("exchange.connector.ws.endpoints[0]", "http://endpoint:1234"); + WebSocketEndpoint webSocketEndpoint = cut.nextEndpoint(); + assertThat(webSocketEndpoint).isNotNull(); + } + + @Test + void should_return_null_when_max_retry_reach() { + environment.setProperty("exchange.connector.ws.endpoints[0]", "http://endpoint:1234"); + for (int i = 0; i < WebSocketEndpoint.DEFAULT_MAX_RETRY_COUNT; i++) { + cut.nextEndpoint(); + } + WebSocketEndpoint webSocketEndpoint = cut.nextEndpoint(); + assertThat(webSocketEndpoint).isNull(); + } + + @Test + void should_return_second_endpoint_when_retrying() { + environment + .withProperty("exchange.connector.ws.endpoints[0]", "http://endpoint:1234") + .withProperty("exchange.connector.ws.endpoints[1]", "http://endpoint2:5678"); + WebSocketEndpoint webSocketEndpoint1 = cut.nextEndpoint(); + assertThat(webSocketEndpoint1).isNotNull(); + assertThat(webSocketEndpoint1.getPort()).isEqualTo(1234); + WebSocketEndpoint webSocketEndpoint2 = cut.nextEndpoint(); + assertThat(webSocketEndpoint2).isNotNull(); + assertThat(webSocketEndpoint2.getPort()).isEqualTo(5678); + } + } + + @Nested + class CreateHttpClient_NoSSL { + + private static WireMockServer wireMockServer; + private static WebSocketEndpoint webSocketEndpoint; + + @BeforeAll + static void setup() { + final WireMockConfiguration wireMockConfiguration = wireMockConfig() + .dynamicPort() + .dynamicHttpsPort() + .keystorePath(toPath("keystore.jks")) + .keystorePassword("password") + .trustStorePath(toPath("truststore.jks")) + .trustStorePassword("password"); + wireMockServer = new WireMockServer(wireMockConfiguration); + wireMockServer.start(); + webSocketEndpoint = WebSocketEndpoint.builder().url("http://localhost:%s".formatted(wireMockServer.port())).build(); + } + + @AfterAll + static void tearDown() { + wireMockServer.stop(); + wireMockServer.shutdownServer(); + } + + @Test + void should_create_http_client_from_default_configuration() { + HttpClient httpClient = cut.createHttpClient(webSocketEndpoint); + assertThat(httpClient).isNotNull(); + httpClient + .rxRequest(HttpMethod.GET, "/test") + .flatMap(HttpClientRequest::rxSend) + .test() + .awaitDone(30, TimeUnit.SECONDS) + .assertComplete(); + } + } + + @Nested + class CreateHttpClient_SSL { + + private static WireMockServer wireMockServer; + private static WebSocketEndpoint webSocketEndpoint; + + @BeforeAll + static void setup() { + final WireMockConfiguration wireMockConfiguration = wireMockConfig() + .dynamicPort() + .dynamicHttpsPort() + .keystorePath(toPath("keystore.jks")) + .keystorePassword("password") + .trustStorePath(toPath("truststore.jks")) + .trustStorePassword("password"); + wireMockServer = new WireMockServer(wireMockConfiguration); + wireMockServer.start(); + webSocketEndpoint = WebSocketEndpoint.builder().url("https://localhost:%s".formatted(wireMockServer.httpsPort())).build(); + } + + @AfterAll + static void tearDown() { + wireMockServer.stop(); + wireMockServer.shutdownServer(); + } + + @Test + void should_create_http_client_without_trust_store() { + HttpClient httpClient = cut.createHttpClient(webSocketEndpoint); + assertThat(httpClient).isNotNull(); + httpClient + .rxRequest(HttpMethod.GET, "/test") + .flatMap(HttpClientRequest::rxSend) + .test() + .awaitDone(30, TimeUnit.SECONDS) + .assertError(throwable -> { + assertThat(throwable.getCause()).isInstanceOf(SSLHandshakeException.class); + assertThat(throwable.getCause().getMessage()) + .isEqualTo( + "PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target" + ); + return true; + }); + } + + @Test + void should_create_http_client_with_trust_all() { + environment.setProperty("exchange.connector.ws.ssl.trustAll", "true"); + HttpClient httpClient = cut.createHttpClient(webSocketEndpoint); + assertThat(httpClient).isNotNull(); + httpClient + .rxRequest(HttpMethod.GET, "/test") + .flatMap(HttpClientRequest::rxSend) + .test() + .awaitDone(30, TimeUnit.SECONDS) + .assertComplete(); + } + + @Test + void should_create_http_client_with_trust_store() { + environment + .withProperty("exchange.connector.ws.ssl.truststore.type", "JKS") + .withProperty("exchange.connector.ws.ssl.truststore.path", toPath("truststore.jks")) + .withProperty("exchange.connector.ws.ssl.truststore.password", "password"); + HttpClient httpClient = cut.createHttpClient(webSocketEndpoint); + assertThat(httpClient).isNotNull(); + httpClient + .rxRequest(HttpMethod.GET, "/test") + .flatMap(HttpClientRequest::rxSend) + .test() + .awaitDone(30, TimeUnit.SECONDS) + .assertComplete(); + } + } + + @Nested + class CreateHttpClient_MTLS { + + private static WireMockServer wireMockServer; + private static WebSocketEndpoint webSocketEndpoint; + + @BeforeAll + static void setup() { + final WireMockConfiguration wireMockConfiguration = wireMockConfig() + .dynamicPort() + .dynamicHttpsPort() + .keystorePath(toPath("keystore.jks")) + .keystorePassword("password") + .trustStorePath(toPath("truststore.jks")) + .trustStorePassword("password") + .needClientAuth(true); + wireMockServer = new WireMockServer(wireMockConfiguration); + wireMockServer.start(); + webSocketEndpoint = WebSocketEndpoint.builder().url("https://localhost:%s".formatted(wireMockServer.httpsPort())).build(); + } + + @AfterAll + static void tearDown() { + wireMockServer.stop(); + wireMockServer.shutdownServer(); + } + + @Test + void should_create_http_client_without_keystore_store() { + environment + .withProperty("exchange.connector.ws.ssl.truststore.type", "JKS") + .withProperty("exchange.connector.ws.ssl.truststore.path", toPath("truststore.jks")) + .withProperty("exchange.connector.ws.ssl.truststore.password", "password"); + HttpClient httpClient = cut.createHttpClient(webSocketEndpoint); + assertThat(httpClient).isNotNull(); + httpClient + .rxRequest(HttpMethod.GET, "/test") + .flatMap(HttpClientRequest::rxSend) + .test() + .awaitDone(30, TimeUnit.SECONDS) + .assertError(throwable -> { + assertThat(throwable.getCause()).isInstanceOf(SSLHandshakeException.class); + assertThat(throwable.getCause().getMessage()).isEqualTo("Received fatal alert: bad_certificate"); + return true; + }); + } + + @Test + void should_create_http_client_with_keystore() { + environment + .withProperty("exchange.connector.ws.ssl.truststore.type", "JKS") + .withProperty("exchange.connector.ws.ssl.truststore.path", toPath("truststore.jks")) + .withProperty("exchange.connector.ws.ssl.truststore.password", "password") + .withProperty("exchange.connector.ws.ssl.keystore.type", "JKS") + .withProperty("exchange.connector.ws.ssl.keystore.path", toPath("keystore.jks")) + .withProperty("exchange.connector.ws.ssl.keystore.password", "password"); + HttpClient httpClient = cut.createHttpClient(webSocketEndpoint); + assertThat(httpClient).isNotNull(); + httpClient + .rxRequest(HttpMethod.GET, "/test") + .flatMap(HttpClientRequest::rxSend) + .test() + .awaitDone(30, TimeUnit.SECONDS) + .assertComplete(); + } + } + + private static String toPath(String resourcePath) { + try { + return new File(Resources.getResource(resourcePath).toURI()).getCanonicalPath(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/client/WebSocketEndpointTest.java b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/client/WebSocketEndpointTest.java new file mode 100644 index 0000000..7770cec --- /dev/null +++ b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/client/WebSocketEndpointTest.java @@ -0,0 +1,97 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.connector.websocket.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class WebSocketEndpointTest { + + private static Stream urls() { + return Stream.of( + // URL / host / port / root path + Arguments.of("http://localhost:8062", "localhost", 8062), + Arguments.of("http://localhost:8062/", "localhost", 8062), + Arguments.of("https://localhost:8063", "localhost", 8063), + Arguments.of("https://localhost:8063/", "localhost", 8063), + Arguments.of("https://localhost:8064/root", "localhost", 8064), + Arguments.of("https://localhost:8064/root/", "localhost", 8064) + ); + } + + @ParameterizedTest + @MethodSource("urls") + void should_create_default_websocket_endpoint(String baseUrl, String host, int port) { + WebSocketEndpoint endpoint = new WebSocketEndpoint(baseUrl, -1); + assertThat(endpoint.getHost()).isEqualTo(host); + assertThat(endpoint.getPort()).isEqualTo(port); + assertThat(endpoint.getMaxRetryCount()).isEqualTo(5); + assertThat(endpoint.getRetryCount()).isZero(); + } + + @ParameterizedTest + @MethodSource("urls") + void should_resolve_path(String baseUrl) { + WebSocketEndpoint endpoint = new WebSocketEndpoint(baseUrl, -1); + assertThat(endpoint.resolvePath("/path")).isEqualTo("/path"); + } + + @Test + void should_create_websocket_endpoint_with_max_retry() { + WebSocketEndpoint endpoint = new WebSocketEndpoint("http://localhost:8062", 10); + assertThat(endpoint.getMaxRetryCount()).isEqualTo(10); + assertThat(endpoint.getRetryCount()).isZero(); + } + + @Test + void should_increment_retry_counter() { + WebSocketEndpoint endpoint = new WebSocketEndpoint("http://localhost:8062", -1); + assertThat(endpoint.getRetryCount()).isZero(); + assertThat(endpoint.getMaxRetryCount()).isEqualTo(5); + endpoint.incrementRetryCount(); + assertThat(endpoint.getRetryCount()).isEqualTo(1); + } + + @Test + void should_resent__retry_counter() { + WebSocketEndpoint endpoint = new WebSocketEndpoint("http://localhost:8062", -1); + endpoint.incrementRetryCount(); + assertThat(endpoint.getRetryCount()).isEqualTo(1); + endpoint.resetRetryCount(); + assertThat(endpoint.getRetryCount()).isZero(); + } + + @Test + void should_be_removable_when_retry_is_higher_than_max() { + WebSocketEndpoint endpoint = new WebSocketEndpoint("http://localhost:8062", 1); + endpoint.incrementRetryCount(); + assertThat(endpoint.isRemovable()).isFalse(); + endpoint.incrementRetryCount(); + assertThat(endpoint.isRemovable()).isTrue(); + } +} diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/resources/keystore.jks b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/resources/keystore.jks new file mode 100644 index 0000000..3e2f128 Binary files /dev/null and b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/resources/keystore.jks differ diff --git a/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/resources/truststore.jks b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/resources/truststore.jks new file mode 100644 index 0000000..9538012 Binary files /dev/null and b/gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/resources/truststore.jks differ diff --git a/gravitee-exchange-connector/pom.xml b/gravitee-exchange-connector/pom.xml new file mode 100644 index 0000000..6dda3fc --- /dev/null +++ b/gravitee-exchange-connector/pom.xml @@ -0,0 +1,39 @@ + + + + 4.0.0 + + + io.gravitee.exchange + gravitee-exchange + 1.0.0-alpha.7 + + + gravitee-exchange-connector + Gravitee.io - Exchange - Connector + pom + + + gravitee-exchange-connector-core + gravitee-exchange-connector-embedded + gravitee-exchange-connector-websocket + + \ No newline at end of file diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/pom.xml b/gravitee-exchange-controller/gravitee-exchange-controller-core/pom.xml new file mode 100644 index 0000000..4593c62 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/pom.xml @@ -0,0 +1,71 @@ + + + + 4.0.0 + + + io.gravitee.exchange + gravitee-exchange-controller + 1.0.0-alpha.7 + + + gravitee-exchange-controller-core + Gravitee.io - Exchange - Controller Core + + + + io.gravitee.exchange + gravitee-exchange-api + ${project.version} + + + io.gravitee.common + gravitee-common + provided + + + io.gravitee.node + gravitee-node-api + provided + + + org.apache.commons + commons-lang3 + provided + + + io.reactivex.rxjava3 + rxjava + provided + + + io.vertx + vertx-core + provided + + + io.gravitee.node + gravitee-node-cache-common + ${gravitee-node.version} + test + + + \ No newline at end of file diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/DefaultExchangeController.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/DefaultExchangeController.java new file mode 100644 index 0000000..e74a6dd --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/DefaultExchangeController.java @@ -0,0 +1,409 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core; + +import io.gravitee.common.service.AbstractService; +import io.gravitee.exchange.api.batch.Batch; +import io.gravitee.exchange.api.batch.BatchCommand; +import io.gravitee.exchange.api.batch.BatchObserver; +import io.gravitee.exchange.api.batch.BatchStatus; +import io.gravitee.exchange.api.batch.KeyBatchObserver; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.configuration.IdentifyConfiguration; +import io.gravitee.exchange.api.controller.ControllerChannel; +import io.gravitee.exchange.api.controller.ExchangeController; +import io.gravitee.exchange.api.controller.metrics.ChannelMetric; +import io.gravitee.exchange.api.controller.metrics.TargetMetric; +import io.gravitee.exchange.controller.core.batch.BatchStore; +import io.gravitee.exchange.controller.core.batch.exception.BatchDisabledException; +import io.gravitee.exchange.controller.core.cluster.ControllerClusterManager; +import io.gravitee.node.api.cache.CacheConfiguration; +import io.gravitee.node.api.cache.CacheManager; +import io.gravitee.node.api.cluster.ClusterManager; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.support.CronTrigger; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Slf4j +public class DefaultExchangeController extends AbstractService implements ExchangeController { + + private final Map> keyBasedBatchObservers = new ConcurrentHashMap<>(); + private final Map idBasedBatchObservers = new ConcurrentHashMap<>(); + protected final IdentifyConfiguration identifyConfiguration; + protected final ClusterManager clusterManager; + protected final CacheManager cacheManager; + protected final ControllerClusterManager controllerClusterManager; + private BatchStore batchStore; + private ScheduledFuture scheduledFuture; + + public DefaultExchangeController( + final IdentifyConfiguration identifyConfiguration, + final ClusterManager clusterManager, + final CacheManager cacheManager + ) { + this.identifyConfiguration = identifyConfiguration; + this.clusterManager = clusterManager; + this.cacheManager = cacheManager; + this.controllerClusterManager = new ControllerClusterManager(identifyConfiguration, clusterManager, cacheManager); + } + + @Override + protected void doStart() throws Exception { + log.debug("[{}] Starting {} controller", this.identifyConfiguration.id(), this.getClass().getSimpleName()); + super.doStart(); + controllerClusterManager.start(); + startBatchFeature(); + } + + private void startBatchFeature() { + boolean enabled = isBatchFeatureEnabled(); + if (enabled) { + if (batchStore == null) { + batchStore = + new BatchStore( + cacheManager.getOrCreateCache( + identifyConfiguration.identifyName("controller-batch-store"), + CacheConfiguration.builder().timeToLiveInMs(3600000).distributed(true).build() + ) + ); + } + resetPendingBatches(); + startBatchScheduler(); + } + } + + private void startBatchScheduler() { + log.debug("[{}] Starting batch scheduler", this.identifyConfiguration.id()); + ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); + taskScheduler.setThreadNamePrefix(this.identifyConfiguration.identifyName("controller-batch-scheduler-")); + taskScheduler.initialize(); + scheduledFuture = + taskScheduler.schedule( + () -> { + if (clusterManager.self().primary()) { + log.debug("[{}] Executing Batch scheduled tasks", this.identifyConfiguration.id()); + this.batchStore.findByStatus(BatchStatus.PENDING) + .doOnNext(batch -> + log.info( + "[{}] Retrying batch '{}' with key '{}' and target id '{}'", + this.identifyConfiguration.id(), + batch.id(), + batch.key(), + batch.targetId() + ) + ) + .flatMapSingle(this::sendBatchCommands) + .ignoreElements() + .blockingAwait(); + log.debug("[{}] Batch scheduled tasks executed", this.identifyConfiguration.id()); + } + }, + new CronTrigger(identifyConfiguration.getProperty("controller.batch.cron", String.class, "*/60 * * * * *")) + ); + } + + private void resetPendingBatches() { + if (clusterManager.self().primary()) { + this.batchStore.findByStatus(BatchStatus.IN_PROGRESS) + .flatMapSingle(batch -> updateBatch(batch.reset())) + .ignoreElements() + .blockingAwait(); + } + } + + @Override + protected void doStop() throws Exception { + log.debug("[{}] Stopping {} controller", this.identifyConfiguration.id(), this.getClass().getSimpleName()); + super.doStop(); + controllerClusterManager.stop(); + stopBatchFeature(); + } + + private void stopBatchFeature() { + boolean enabled = isBatchFeatureEnabled(); + if (enabled) { + if (scheduledFuture != null) { + scheduledFuture.cancel(true); + } + if (batchStore != null) { + batchStore.clear(); + } + } + } + + @Override + public Flowable targetsMetric() { + return controllerClusterManager.targetsMetric(); + } + + @Override + public Flowable channelsMetric(final String targetId) { + return controllerClusterManager.channelsMetric(targetId); + } + + @Override + public Completable register(final ControllerChannel channel) { + return controllerClusterManager + .register(channel) + .doOnComplete(() -> + log.debug( + "[{}] Channel '{}' for target '{}' has been registered", + this.identifyConfiguration.id(), + channel.id(), + channel.targetId() + ) + ) + .doOnError(throwable -> + log.warn( + "[{}] Unable to register channel '{}' for target '{}'", + this.identifyConfiguration.id(), + channel.id(), + channel.targetId(), + throwable + ) + ); + } + + @Override + public Completable unregister(final ControllerChannel channel) { + return controllerClusterManager + .unregister(channel) + .doOnComplete(() -> + log.debug( + "[{}] Channel '{}' for target '{}' has been unregistered", + this.identifyConfiguration.id(), + channel.id(), + channel.targetId() + ) + ) + .doOnError(throwable -> + log.warn( + "[{}] Unable to unregister channel '{}' for target '{}'", + this.identifyConfiguration.id(), + channel.id(), + channel.targetId(), + throwable + ) + ); + } + + @Override + public Single> sendCommand(final Command command, final String targetId) { + return controllerClusterManager + .sendCommand(command, targetId) + .doOnSuccess(reply -> + log.debug( + "[{}] Command '{}' has been successfully sent to target '{}'", + this.identifyConfiguration.id(), + command.getId(), + targetId + ) + ) + .doOnError(throwable -> + log.warn( + "[{}] Unable to send command '{}' to target '{}'", + this.identifyConfiguration.id(), + command.getId(), + targetId, + throwable + ) + ); + } + + @Override + public void addKeyBasedBatchObserver(final KeyBatchObserver keyBasedObserver) { + this.keyBasedBatchObservers.compute( + keyBasedObserver.batchKey(), + (k, v) -> { + if (v == null) { + v = new ArrayList<>(); + } + v.add(keyBasedObserver); + return v; + } + ); + } + + @Override + public void removeKeyBasedBatchObserver(final KeyBatchObserver keyBasedObserver) { + this.keyBasedBatchObservers.computeIfPresent( + keyBasedObserver.batchKey(), + (k, v) -> { + v.remove(keyBasedObserver); + return v; + } + ); + } + + @Override + public Single executeBatch(final Batch batch) { + if (isBatchFeatureEnabled()) { + return this.batchStore.add(batch) + .doOnSuccess(b -> log.debug("[{}] Executing batch '{}' with key '{}'", this.identifyConfiguration.id(), b.id(), b.key())) + .flatMap(this::sendBatchCommands); + } else { + return Single.error(new BatchDisabledException()); + } + } + + @Override + public Completable executeBatch(final Batch batch, final BatchObserver batchObserver) { + return Completable + .fromRunnable(() -> this.idBasedBatchObservers.put(batch.id(), batchObserver)) + .andThen(executeBatch(batch).ignoreElement()) + .doOnError(throwable -> this.idBasedBatchObservers.remove(batch.id())); + } + + private Single sendBatchCommands(final Batch batch) { + return this.updateBatch(batch.start()) + .filter(a -> a.status().equals(BatchStatus.IN_PROGRESS)) + .doOnSuccess(b -> + log.debug( + "[{}] Batch '{}' for target '{}' and key '{}' in progress", + this.identifyConfiguration.id(), + b.id(), + b.targetId(), + b.key() + ) + ) + .flatMapSingle(updateBatch -> { + List commands = updateBatch + .batchCommands() + .stream() + .filter(command -> !Objects.equals(CommandStatus.SUCCEEDED, command.status())) + .toList(); + return sendCommands(updateBatch, commands); + }) + .doOnSuccess(b -> { + switch (b.status()) { + case PENDING -> log.info( + "[{}] Batch '{}' for target id '{}' and key '{}' is scheduled for retry", + this.identifyConfiguration.id(), + b.id(), + b.targetId(), + b.key() + ); + case SUCCEEDED -> { + log.info( + "[{}] Batch '{}' for target id '{}' and key '{}' has succeed", + this.identifyConfiguration.id(), + b.id(), + b.targetId(), + b.key() + ); + notifyObservers(b); + } + case ERROR -> { + log.info( + "[{}] Batch '{}' for target id '{}' and key '{}' stopped in error", + this.identifyConfiguration.id(), + b.id(), + b.targetId(), + b.key() + ); + notifyObservers(b); + } + } + }) + .defaultIfEmpty(batch); + } + + private void notifyObservers(final Batch batch) { + List batchObservers = new ArrayList<>(); + if (idBasedBatchObservers.containsKey(batch.id())) { + batchObservers.add(idBasedBatchObservers.get(batch.id())); + } + if (keyBasedBatchObservers.containsKey(batch.key())) { + batchObservers.addAll(keyBasedBatchObservers.get(batch.key())); + } + Flowable + .fromIterable(batchObservers) + .flatMapCompletable(batchObserver -> + batchObserver + .notify(batch) + .subscribeOn(Schedulers.computation()) + .doOnError(throwable -> + log.warn( + "[{}] Unable to notify batch observer with batch '{}' for target id '{}' and key '{}' has succeed", + this.identifyConfiguration.id(), + batch.id(), + batch.targetId(), + batch.key() + ) + ) + .doOnComplete(() -> + log.debug( + "[{}] Notify batch observer in success with batch '{}' for target id '{}' and key '{}' has succeed", + this.identifyConfiguration.id(), + batch.id(), + batch.targetId(), + batch.key() + ) + ) + .onErrorComplete() + ) + .doOnComplete(() -> this.idBasedBatchObservers.remove(batch.id())) + .subscribe(); + } + + private Single sendCommands(final Batch batch, final List batchCommands) { + if (batchCommands.isEmpty()) { + return Single.just(batch); + } + + return Flowable + .fromIterable(batchCommands) + .concatMapSingle(batchCommand -> + Single + .just(batch.markCommandInProgress(batchCommand.command().getId())) + .flatMap(this::updateBatch) + .flatMap(updatedBatch -> + sendCommand(batchCommand.command(), updatedBatch.targetId()) + .map(reply -> updatedBatch.setCommandReply(batchCommand.command().getId(), reply)) + .onErrorReturn(throwable -> + updatedBatch.markCommandInError(batchCommand.command().getId(), throwable.getMessage()) + ) + ) + .flatMap(this::updateBatch) + ) + .takeWhile(updatedBatch -> updatedBatch.status() == BatchStatus.IN_PROGRESS) + .last(batch); + } + + private Single updateBatch(final Batch batch) { + return this.batchStore.update(batch); + } + + private boolean isBatchFeatureEnabled() { + return identifyConfiguration.getProperty("controller.batch.enabled", Boolean.class, true); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/BatchStore.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/BatchStore.java new file mode 100644 index 0000000..1407733 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/BatchStore.java @@ -0,0 +1,89 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.batch; + +import io.gravitee.exchange.api.batch.Batch; +import io.gravitee.exchange.api.batch.BatchStatus; +import io.gravitee.exchange.controller.core.batch.exception.BatchAlreadyExistsException; +import io.gravitee.exchange.controller.core.batch.exception.BatchNotExistException; +import io.gravitee.node.api.cache.Cache; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Maybe; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; +import java.util.Objects; +import lombok.RequiredArgsConstructor; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@RequiredArgsConstructor +public class BatchStore { + + private final Cache store; + + public void clear() { + store.clear(); + } + + public Maybe getById(String id) { + return Maybe + .fromCallable(() -> { + if (id == null) { + throw new IllegalArgumentException("Batch id cannot be null"); + } + return store.get(id); + }) + .subscribeOn(Schedulers.io()); + } + + public Flowable findByStatus(final BatchStatus status) { + return Flowable + .fromStream(store.values().stream().filter(batch -> Objects.equals(batch.status(), status))) + .subscribeOn(Schedulers.io()); + } + + public Single add(Batch batch) { + return Single + .fromCallable(() -> { + if (batch.id() == null) { + throw new IllegalArgumentException("Batch id cannot be null"); + } + if (store.containsKey(batch.id())) { + throw new BatchAlreadyExistsException(); + } + store.put(batch.id(), batch); + return batch; + }) + .subscribeOn(Schedulers.io()); + } + + public Single update(Batch batch) { + return Single + .fromCallable(() -> { + if (batch.id() == null) { + throw new IllegalArgumentException("Batch id cannot be null"); + } + if (!store.containsKey(batch.id())) { + throw new BatchNotExistException(); + } + store.put(batch.id(), batch); + return batch; + }) + .subscribeOn(Schedulers.io()); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/exception/BatchAlreadyExistsException.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/exception/BatchAlreadyExistsException.java new file mode 100644 index 0000000..86c3e34 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/exception/BatchAlreadyExistsException.java @@ -0,0 +1,18 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.batch.exception; + +public class BatchAlreadyExistsException extends RuntimeException {} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/exception/BatchDisabledException.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/exception/BatchDisabledException.java new file mode 100644 index 0000000..91b6ef9 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/exception/BatchDisabledException.java @@ -0,0 +1,23 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.batch.exception; + +public class BatchDisabledException extends RuntimeException { + + public BatchDisabledException() { + super("Batch command feature is disabled"); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/exception/BatchNotExistException.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/exception/BatchNotExistException.java new file mode 100644 index 0000000..e1383f2 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/exception/BatchNotExistException.java @@ -0,0 +1,18 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.batch.exception; + +public class BatchNotExistException extends RuntimeException {} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/ChannelManager.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/ChannelManager.java new file mode 100644 index 0000000..bc5b65a --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/ChannelManager.java @@ -0,0 +1,358 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.channel; + +import io.gravitee.common.service.AbstractService; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckCommand; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckCommandPayload; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckReply; +import io.gravitee.exchange.api.command.healtcheck.HealthCheckReplyPayload; +import io.gravitee.exchange.api.command.primary.PrimaryCommand; +import io.gravitee.exchange.api.command.primary.PrimaryCommandPayload; +import io.gravitee.exchange.api.command.primary.PrimaryReply; +import io.gravitee.exchange.api.configuration.IdentifyConfiguration; +import io.gravitee.exchange.api.controller.ControllerChannel; +import io.gravitee.exchange.api.controller.metrics.ChannelMetric; +import io.gravitee.exchange.api.controller.metrics.TargetMetric; +import io.gravitee.exchange.controller.core.channel.exception.NoChannelFoundException; +import io.gravitee.exchange.controller.core.channel.primary.PrimaryChannelElectedEvent; +import io.gravitee.exchange.controller.core.channel.primary.PrimaryChannelManager; +import io.gravitee.node.api.cache.CacheManager; +import io.gravitee.node.api.cluster.ClusterManager; +import io.gravitee.node.api.cluster.messaging.Topic; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Maybe; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.Disposable; +import java.util.List; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Slf4j +public class ChannelManager extends AbstractService { + + private static final int HEALTH_CHECK_DELAY = 30000; + private static final TimeUnit HEALTH_CHECK_DELAY_UNIT = TimeUnit.MILLISECONDS; + private final LocalChannelRegistry localChannelRegistry = new LocalChannelRegistry(); + private final PrimaryChannelManager primaryChannelManager; + private final IdentifyConfiguration identifyConfiguration; + private final ClusterManager clusterManager; + private Disposable healthCheckDisposable; + private Topic primaryChannelElectedEventTopic; + private String primaryChannelElectedSubscriptionId; + + public ChannelManager( + final IdentifyConfiguration identifyConfiguration, + final ClusterManager clusterManager, + final CacheManager cacheManager + ) { + this.identifyConfiguration = identifyConfiguration; + this.clusterManager = clusterManager; + this.primaryChannelManager = new PrimaryChannelManager(identifyConfiguration, clusterManager, cacheManager); + } + + @Override + protected void doStart() throws Exception { + log.debug("[{}] Starting channel manager", this.identifyConfiguration.id()); + super.doStart(); + primaryChannelManager.start(); + primaryChannelElectedEventTopic = clusterManager.topic(PrimaryChannelManager.PRIMARY_CHANNEL_EVENTS_ELECTED_TOPIC); + primaryChannelElectedSubscriptionId = + primaryChannelElectedEventTopic.addMessageListener(message -> handlePrimaryChannelElectedEvent(message.content())); + healthCheckDisposable = + Flowable + .generate( + () -> 0L, + (state, emitter) -> { + emitter.onNext(state); + return state + 1; + } + ) + .delay( + identifyConfiguration.getProperty("controller.channel.healthcheck.delay", Integer.class, HEALTH_CHECK_DELAY), + HEALTH_CHECK_DELAY_UNIT + ) + .rebatchRequests(1) + .doOnNext(aLong -> log.debug("[{}] Sending healthcheck command to all registered channels", this.identifyConfiguration.id()) + ) + .flatMapCompletable(interval -> sendHealthCheckCommand()) + .onErrorComplete() + .subscribe(); + } + + private void handlePrimaryChannelElectedEvent(final PrimaryChannelElectedEvent event) { + Completable + .defer(() -> { + String channelId = event.channelId(); + String targetId = event.targetId(); + log.debug( + "[{}] Handling primary channel elected event for channel '{}' on target '{}'.", + this.identifyConfiguration.id(), + channelId, + targetId + ); + + return Maybe + .fromOptional(localChannelRegistry.getById(channelId)) + .switchIfEmpty( + Maybe.fromRunnable(() -> + log.debug( + "[{}] Primary elected channel '{}' on target '{}' was not found from the local registry, ignore it.", + this.identifyConfiguration.id(), + channelId, + targetId + ) + ) + ) + .flatMapCompletable(channel -> sendPrimaryCommand(channel, true)) + .andThen( + Flowable + .fromIterable(localChannelRegistry.getAllByTargetId(targetId)) + .flatMapCompletable(controllerChannel -> sendPrimaryCommand(controllerChannel, false)) + ); + }) + .subscribe( + () -> log.debug("[{}] Primary channel elected event properly handled", this.identifyConfiguration.id()), + throwable -> + log.error( + "[{}] Unable to send primary commands to local registered channels", + this.identifyConfiguration.id(), + throwable + ) + ); + } + + private Completable sendPrimaryCommand(final ControllerChannel channel, final boolean isPrimary) { + String channelId = channel.id(); + String targetId = channel.targetId(); + log.debug( + "[{}] Sending primary command to channel '{}' on target '{}' with primary '{}'", + this.identifyConfiguration.id(), + channelId, + targetId, + isPrimary + ); + return channel + .send(new PrimaryCommand(new PrimaryCommandPayload(isPrimary))) + .doOnSuccess(primaryReply -> { + log.debug("[{}] Primary command successfully sent to channel '{}'", this.identifyConfiguration.id(), channelId); + if (primaryReply.getCommandStatus() == CommandStatus.SUCCEEDED) { + log.debug("[{}] Channel '{}' successfully replied from primary command", this.identifyConfiguration.id(), channelId); + } else if (primaryReply.getCommandStatus() == CommandStatus.ERROR) { + log.warn("[{}] Channel '{}' replied in error from primary command", this.identifyConfiguration.id(), channelId); + } + }) + .doOnError(throwable -> + log.warn("[{}] Unable to send primary command to channel '{}'", this.identifyConfiguration.id(), channelId, throwable) + ) + .ignoreElement(); + } + + private Completable sendHealthCheckCommand() { + return Flowable + .fromIterable(localChannelRegistry.getAll()) + .filter(ControllerChannel::isActive) + .flatMapCompletable(controllerChannel -> + controllerChannel + .send(new HealthCheckCommand(new HealthCheckCommandPayload())) + .cast(HealthCheckReply.class) + .doOnSuccess(reply -> { + log.debug( + "[{}] Health check command successfully sent for channel '{}' on target '{}'", + this.identifyConfiguration.id(), + controllerChannel.id(), + controllerChannel.targetId() + ); + HealthCheckReplyPayload payload = reply.getPayload(); + controllerChannel.enforceActiveStatus(payload.healthy()); + signalChannelAlive(controllerChannel, reply.getPayload().healthy()); + }) + .ignoreElement() + .onErrorResumeNext(throwable -> { + log.debug( + "[{}] Unable to send health check command for channel '{}' on target '{}'", + this.identifyConfiguration.id(), + controllerChannel.id(), + controllerChannel.targetId() + ); + controllerChannel.enforceActiveStatus(false); + signalChannelAlive(controllerChannel, false); + return Completable.complete(); + }) + ); + } + + @Override + protected void doStop() throws Exception { + log.debug("[{}] Stopping channel manager", this.identifyConfiguration.id()); + + // Unregister all local channel + Flowable + .fromIterable(this.localChannelRegistry.getAll()) + .flatMapCompletable(controllerChannel -> unregister(controllerChannel).onErrorComplete()) + .doOnComplete(() -> log.debug("[{}] All local channel unregistered.", this.identifyConfiguration.id())) + .blockingAwait(); + + primaryChannelManager.stop(); + if (healthCheckDisposable != null) { + healthCheckDisposable.dispose(); + } + if (primaryChannelElectedEventTopic != null && primaryChannelElectedSubscriptionId != null) { + primaryChannelElectedEventTopic.removeMessageListener(primaryChannelElectedSubscriptionId); + } + super.doStop(); + } + + public Flowable targetsMetric() { + return this.primaryChannelManager.candidatesChannel() + .flatMapSingle(candidatesChannelEntries -> { + String targetId = candidatesChannelEntries.getKey(); + List channelIds = candidatesChannelEntries.getValue(); + return this.primaryChannelManager.primaryChannelBy(targetId) + .defaultIfEmpty("unknown") + .flattenStreamAsFlowable(primaryChannel -> + channelIds + .stream() + .map(channelId -> ChannelMetric.builder().id(channelId).primary(channelId.equals(primaryChannel)).build()) + ) + .toList() + .map(channelMetrics -> TargetMetric.builder().id(targetId).channelMetrics(channelMetrics).build()); + }); + } + + public Flowable channelsMetric(final String targetId) { + return this.primaryChannelManager.primaryChannelBy(targetId) + .defaultIfEmpty("unknown") + .flatMapPublisher(primaryChannel -> + this.primaryChannelManager.candidatesChannel(targetId) + .flattenStreamAsFlowable(candidatesChannel -> + candidatesChannel + .stream() + .map(channelId -> ChannelMetric.builder().id(channelId).primary(channelId.equals(primaryChannel)).build()) + ) + ); + } + + public ControllerChannel getChannelById(final String id) { + return localChannelRegistry.getById(id).filter(ControllerChannel::isActive).orElse(null); + } + + public ControllerChannel getOneChannelByTargetId(final String targetId) { + return localChannelRegistry.getAllByTargetId(targetId).stream().filter(ControllerChannel::isActive).findFirst().orElse(null); + } + + public Completable register(ControllerChannel controllerChannel) { + return Completable + .fromRunnable(() -> localChannelRegistry.add(controllerChannel)) + .andThen(controllerChannel.initialize()) + .doOnComplete(() -> { + log.debug( + "[{}] Channel '{}' successfully register for target '{}'", + this.identifyConfiguration.id(), + controllerChannel.id(), + controllerChannel.targetId() + ); + signalChannelAlive(controllerChannel, true); + }) + .onErrorResumeNext(throwable -> { + log.warn( + "[{}] Unable to register channel '{}' for target '{}'", + this.identifyConfiguration.id(), + controllerChannel.id(), + controllerChannel.targetId(), + throwable + ); + return unregister(controllerChannel); + }); + } + + public Completable unregister(ControllerChannel controllerChannel) { + return Completable + .fromRunnable(() -> localChannelRegistry.remove(controllerChannel)) + .andThen(controllerChannel.close()) + .doOnComplete(() -> { + log.debug( + "[{}] Channel '{}' successfully unregister for target '{}'", + this.identifyConfiguration.id(), + controllerChannel.id(), + controllerChannel.targetId() + ); + signalChannelAlive(controllerChannel, false); + }) + .doOnError(throwable -> + log.warn( + "[{}] Unable to unregister channel '{}' for target '{}'", + this.identifyConfiguration.id(), + controllerChannel.id(), + controllerChannel.targetId(), + throwable + ) + ); + } + + public , R extends Reply> Single send(C command, String targetId) { + return Maybe + .fromCallable(() -> getOneChannelByTargetId(targetId)) + .doOnComplete(() -> + log.debug( + "[{}] No channel found for target '{}' to handle command '{}'", + this.identifyConfiguration.id(), + targetId, + command.getType() + ) + ) + .switchIfEmpty(Single.error(new NoChannelFoundException())) + .flatMap(controllerChannel -> { + log.debug( + "[{}] Sending command '{}' with id '{}' to channel '{}'", + this.identifyConfiguration.id(), + command.getType(), + command.getId(), + controllerChannel + ); + return controllerChannel.send(command); + }) + .doOnSuccess(reply -> + log.debug( + "[{}] Command '{}' with id '{}' successfully sent", + this.identifyConfiguration.id(), + command.getType(), + command.getId() + ) + ) + .doOnError(throwable -> + log.warn( + "[{}] Unable to send command or receive reply for command '{}' with id '{}'", + this.identifyConfiguration.id(), + command.getType(), + command.getId(), + throwable + ) + ); + } + + public void signalChannelAlive(final ControllerChannel controllerChannel, final boolean isAlive) { + this.primaryChannelManager.sendChannelEvent(controllerChannel, isAlive); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/LocalChannelRegistry.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/LocalChannelRegistry.java new file mode 100644 index 0000000..a859190 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/LocalChannelRegistry.java @@ -0,0 +1,53 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.channel; + +import io.gravitee.exchange.api.controller.ControllerChannel; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class LocalChannelRegistry { + + private final Map channels = new ConcurrentHashMap<>(); + + public void add(ControllerChannel channel) { + channels.put(channel.id(), channel); + } + + public boolean remove(ControllerChannel channel) { + return channels.remove(channel.id()) != null; + } + + public Optional getById(String channelId) { + return Optional.ofNullable(channels.get(channelId)); + } + + public List getAllByTargetId(String targetId) { + return channels.values().stream().filter(channel -> Objects.equals(targetId, channel.targetId())).toList(); + } + + public List getAll() { + return new ArrayList<>(channels.values()); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/exception/NoChannelFoundException.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/exception/NoChannelFoundException.java new file mode 100644 index 0000000..2adbee1 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/exception/NoChannelFoundException.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class NoChannelFoundException extends RuntimeException {} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/ChannelEvent.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/ChannelEvent.java new file mode 100644 index 0000000..01b9e0a --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/ChannelEvent.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.channel.primary; + +import java.io.Serializable; +import lombok.Builder; + +@Builder +public record ChannelEvent(String channelId, String targetId, boolean alive) implements Serializable {} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelCandidateStore.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelCandidateStore.java new file mode 100644 index 0000000..3b657b9 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelCandidateStore.java @@ -0,0 +1,90 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.channel.primary; + +import io.gravitee.node.api.cache.Cache; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Maybe; +import io.reactivex.rxjava3.core.Single; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class PrimaryChannelCandidateStore { + + private final Cache> store; + + public Flowable>> rxEntries() { + return store.rxEntrySet(); + } + + public Maybe> rxGet(final String targetId) { + if (targetId == null) { + return Maybe.error(new IllegalArgumentException("Target id cannot be null")); + } + return store.rxGet(targetId); + } + + public List get(final String targetId) { + if (targetId == null) { + throw new IllegalArgumentException("Target id cannot be null"); + } + return store.get(targetId); + } + + public void put(final String targetId, final String channelId) { + if (targetId == null) { + throw new IllegalArgumentException("Target id cannot be null"); + } + if (channelId == null) { + throw new IllegalArgumentException("Channel id cannot be null"); + } + store.compute( + targetId, + (key, channels) -> { + if (channels == null) { + return new ArrayList<>(List.of(channelId)); + } + channels.add(channelId); + return channels; + } + ); + } + + public void remove(final String targetId, final String channelId) { + if (targetId == null) { + throw new IllegalArgumentException("Target id cannot be null"); + } + if (channelId == null) { + throw new IllegalArgumentException("Channel id cannot be null"); + } + store.compute( + targetId, + (key, channels) -> { + if (channels != null) { + channels.remove(channelId); + + if (channels.isEmpty()) { + return null; + } + } + return channels; + } + ); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelElectedEvent.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelElectedEvent.java new file mode 100644 index 0000000..1586c7f --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelElectedEvent.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.channel.primary; + +import java.io.Serializable; +import lombok.Builder; + +@Builder +public record PrimaryChannelElectedEvent(String channelId, String targetId) implements Serializable {} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelManager.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelManager.java new file mode 100644 index 0000000..cba1e3a --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelManager.java @@ -0,0 +1,153 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.channel.primary; + +import io.gravitee.common.service.AbstractService; +import io.gravitee.exchange.api.configuration.IdentifyConfiguration; +import io.gravitee.exchange.api.controller.ControllerChannel; +import io.gravitee.node.api.cache.Cache; +import io.gravitee.node.api.cache.CacheConfiguration; +import io.gravitee.node.api.cache.CacheManager; +import io.gravitee.node.api.cluster.ClusterManager; +import io.gravitee.node.api.cluster.messaging.Topic; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Maybe; +import io.reactivex.rxjava3.core.Single; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomUtils; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@RequiredArgsConstructor +@Slf4j +public class PrimaryChannelManager extends AbstractService { + + public static final String PRIMARY_CHANNEL_EVENTS_TOPIC = "controller-primary-channel-events"; + public static final String PRIMARY_CHANNEL_EVENTS_ELECTED_TOPIC = "controller-primary-channel-elected-events"; + public static final String PRIMARY_CHANNEL_CACHE = "controller-primary-channel"; + public static final String PRIMARY_CHANNEL_CANDIDATE_CACHE = "controller-primary-channel-candidate"; + private final IdentifyConfiguration identifyConfiguration; + private final ClusterManager clusterManager; + private final CacheManager cacheManager; + private PrimaryChannelCandidateStore primaryChannelCandidateStore; + private Cache primaryChannelCache; + private String subscriptionListenerId; + private Topic primaryChannelEventTopic; + private Topic primaryChannelElectedEventTopic; + private CacheConfiguration cacheConfiguration; + + @Override + protected void doStart() throws Exception { + log.debug("[{}] Starting primary channel manager", this.identifyConfiguration.id()); + super.doStart(); + cacheConfiguration = CacheConfiguration.builder().distributed(true).build(); + if (primaryChannelCandidateStore == null) { + primaryChannelCandidateStore = + new PrimaryChannelCandidateStore( + cacheManager.getOrCreateCache(identifyConfiguration.identifyName(PRIMARY_CHANNEL_CANDIDATE_CACHE), cacheConfiguration) + ); + } + primaryChannelCache = cacheManager.getOrCreateCache(identifyConfiguration.identifyName(PRIMARY_CHANNEL_CACHE), cacheConfiguration); + primaryChannelEventTopic = clusterManager.topic(identifyConfiguration.identifyName(PRIMARY_CHANNEL_EVENTS_TOPIC)); + primaryChannelElectedEventTopic = clusterManager.topic(identifyConfiguration.identifyName(PRIMARY_CHANNEL_EVENTS_ELECTED_TOPIC)); + subscriptionListenerId = + primaryChannelEventTopic.addMessageListener(message -> { + ChannelEvent channelEvent = message.content(); + log.debug( + "[{}] New PrimaryChannelEvent received for channel '{}' on target '{}'", + this.identifyConfiguration.id(), + channelEvent.channelId(), + channelEvent.targetId() + ); + if (clusterManager.self().primary()) { + log.debug( + "[{}] Handling PrimaryChannelEvent for channel '{}' on target '{}'", + this.identifyConfiguration.id(), + channelEvent.channelId(), + channelEvent.targetId() + ); + handleChannelEvent(channelEvent); + } + }); + } + + @Override + protected void doStop() throws Exception { + log.debug("[{}] Stopping primary channel manager", this.identifyConfiguration.id()); + if (primaryChannelEventTopic != null && subscriptionListenerId != null) { + primaryChannelEventTopic.removeMessageListener(subscriptionListenerId); + } + super.doStop(); + } + + public Flowable>> candidatesChannel() { + return primaryChannelCandidateStore.rxEntries(); + } + + public Maybe> candidatesChannel(final String targetId) { + return primaryChannelCandidateStore.rxGet(targetId); + } + + public Maybe primaryChannelBy(final String targetId) { + return primaryChannelCache.rxGet(targetId); + } + + public void sendChannelEvent(final ControllerChannel controllerChannel, final boolean alive) { + primaryChannelEventTopic.publish( + ChannelEvent.builder().channelId(controllerChannel.id()).targetId(controllerChannel.targetId()).alive(alive).build() + ); + } + + private void handleChannelEvent(final ChannelEvent channelEvent) { + String targetId = channelEvent.targetId(); + String channelId = channelEvent.channelId(); + if (channelEvent.alive()) { + primaryChannelCandidateStore.put(targetId, channelId); + } else { + primaryChannelCandidateStore.remove(targetId, channelId); + } + electPrimaryChannel(targetId); + } + + private void electPrimaryChannel(final String targetId) { + String previousPrimaryChannelId = primaryChannelCache.get(targetId); + List channelIds = primaryChannelCandidateStore.get(targetId); + + if (null == channelIds || channelIds.isEmpty()) { + log.warn( + "[{}] Unable to elect a primary channel because there is no channel for target id '{}'", + this.identifyConfiguration.id(), + targetId + ); + primaryChannelCache.evict(targetId); + return; + } + if (!channelIds.contains(previousPrimaryChannelId)) { + //noinspection deprecation + @SuppressWarnings("java:S1874") + String newPrimaryChannelId = channelIds.get(RandomUtils.nextInt(0, channelIds.size())); + primaryChannelCache.put(targetId, newPrimaryChannelId); + primaryChannelElectedEventTopic.publish( + PrimaryChannelElectedEvent.builder().targetId(targetId).channelId(newPrimaryChannelId).build() + ); + } + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/ControllerClusterManager.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/ControllerClusterManager.java new file mode 100644 index 0000000..ea78612 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/ControllerClusterManager.java @@ -0,0 +1,240 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.cluster; + +import io.gravitee.common.service.AbstractService; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.configuration.IdentifyConfiguration; +import io.gravitee.exchange.api.controller.ControllerChannel; +import io.gravitee.exchange.api.controller.metrics.ChannelMetric; +import io.gravitee.exchange.api.controller.metrics.TargetMetric; +import io.gravitee.exchange.controller.core.channel.ChannelManager; +import io.gravitee.exchange.controller.core.cluster.command.ClusteredCommand; +import io.gravitee.exchange.controller.core.cluster.command.ClusteredReply; +import io.gravitee.exchange.controller.core.cluster.exception.ControllerClusterException; +import io.gravitee.exchange.controller.core.cluster.exception.ControllerClusterShutdownException; +import io.gravitee.exchange.controller.core.cluster.exception.ControllerClusterTimeoutException; +import io.gravitee.node.api.cache.CacheManager; +import io.gravitee.node.api.cluster.ClusterManager; +import io.gravitee.node.api.cluster.messaging.Message; +import io.gravitee.node.api.cluster.messaging.Queue; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.core.SingleEmitter; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Slf4j +@Service +public class ControllerClusterManager extends AbstractService { + + private final IdentifyConfiguration identifyConfiguration; + private final ClusterManager clusterManager; + private final ChannelManager channelManager; + private final Map>> resultEmittersByCommand = new ConcurrentHashMap<>(); + private final Map subscriptionsListenersByChannel = new ConcurrentHashMap<>(); + private final String replyQueueName; + + private Queue> clusteredReplyQueue; + private String clusteredReplySubscriptionId; + + public ControllerClusterManager( + final IdentifyConfiguration identifyConfiguration, + final ClusterManager clusterManager, + final CacheManager cacheManager + ) { + this.identifyConfiguration = identifyConfiguration; + this.clusterManager = clusterManager; + this.channelManager = new ChannelManager(identifyConfiguration, clusterManager, cacheManager); + this.replyQueueName = identifyConfiguration.identifyName("controller-cluster-replies-" + UUID.randomUUID()); + } + + @Override + protected void doStart() throws Exception { + log.debug("[{}] Starting controller cluster manager", identifyConfiguration.id()); + super.doStart(); + channelManager.start(); + + // Create a queue to receive replies on and start to listen it. + clusteredReplyQueue = clusterManager.queue(replyQueueName); + clusteredReplySubscriptionId = clusteredReplyQueue.addMessageListener(this::handleClusteredReply); + } + + private void handleClusteredReply(Message> clusteredReplyMessage) { + ClusteredReply clusteredReply = clusteredReplyMessage.content(); + SingleEmitter> emitter = resultEmittersByCommand.remove(clusteredReply.getCommandId()); + + if (emitter != null) { + if (clusteredReply.isError()) { + emitter.onError(clusteredReply.getControllerClusterException()); + } else { + emitter.onSuccess(clusteredReply.getReply()); + } + } + } + + @Override + protected void doStop() throws Exception { + log.debug("[{}] Stopping controller cluster manager", identifyConfiguration.id()); + super.doStop(); + + // Stop all command listeners. + final List channels = subscriptionsListenersByChannel + .values() + .stream() + .map(channelManager::getChannelById) + .filter(Objects::nonNull) + .toList(); + + channels.forEach(this::channelDisconnected); + + // Stop channel manager + channelManager.stop(); + + // Stop listening the reply queue. + if (clusteredReplyQueue != null && clusteredReplySubscriptionId != null) { + clusteredReplyQueue.removeMessageListener(clusteredReplySubscriptionId); + } + + // Finally, notify all pending Rx emitters with an error. + resultEmittersByCommand.forEach((type, emitter) -> emitter.onError(new ControllerClusterShutdownException())); + resultEmittersByCommand.clear(); + } + + public Flowable targetsMetric() { + return channelManager.targetsMetric(); + } + + public Flowable channelsMetric(final String targetId) { + return channelManager.channelsMetric(targetId); + } + + /** + * Indicates to the controller cluster that a ControllerChannel must be registered. + * It means that this current controller instance will be able to accept command addressed to the specified channel from anywhere in the cluster and forward them to the appropriate target. + * + * @param controllerChannel the newly connected channel. + */ + public Completable register(final ControllerChannel controllerChannel) { + return channelManager.register(controllerChannel).andThen(Completable.fromRunnable(() -> channelConnected(controllerChannel))); + } + + private void channelConnected(final ControllerChannel channel) { + final String targetId = channel.targetId(); + final String channelId = channel.id(); + final String queueName = getTargetQueueName(targetId); + final Queue> queue = clusterManager.queue(queueName); + String subscriptionId = queue.addMessageListener(this::onClusterCommand); + subscriptionsListenersByChannel.put(channelId, subscriptionId); + } + + private String getTargetQueueName(final String targetId) { + return this.identifyConfiguration.identifyName("cluster-command-" + targetId); + } + + private void onClusterCommand(final Message> clusteredCommandMessage) { + ClusteredCommand clusteredCommand = clusteredCommandMessage.content(); + final Queue> replyToQueue = clusterManager.queue(clusteredCommand.replyToQueue()); + + channelManager + .send(clusteredCommand.command(), clusteredCommand.targetId()) + .map(reply -> new ClusteredReply<>(clusteredCommand.command().getId(), reply)) + .onErrorReturn(throwable -> new ClusteredReply<>(clusteredCommand.command().getId(), new ControllerClusterException(throwable))) + .doOnSuccess(replyToQueue::add) + .subscribe(); + } + + /** + * Indicates to the controller cluster that a ControllerChannel must be unregistered. + * It means that this current controller instance will stop listening commands addressed to this channel. + * + * @param controllerChannel the channel disconnected. + */ + public Completable unregister(final ControllerChannel controllerChannel) { + return channelManager.unregister(controllerChannel).andThen(Completable.fromRunnable(() -> channelDisconnected(controllerChannel))); + } + + private void channelDisconnected(final ControllerChannel channel) { + final String channelId = channel.id(); + final String targetId = channel.targetId(); + final String listenerSubscriptionId = subscriptionsListenersByChannel.remove(channelId); + + if (listenerSubscriptionId != null) { + final String queueName = getTargetQueueName(targetId); + final Queue> queue = clusterManager.queue(queueName); + queue.removeMessageListener(listenerSubscriptionId); + } + } + + public Single> sendCommand(final Command command, final String targetId) { + final ClusteredCommand clusteredCommand = new ClusteredCommand<>(command, targetId, replyQueueName); + + return Single + .>create(emitter -> sendClusteredCommand(clusteredCommand, emitter)) + .timeout( + command.getReplyTimeoutMs(), + TimeUnit.MILLISECONDS, + Single.error(() -> { + log.warn( + "[{}] No reply received in time from cluster manager for command [{}, {}]", + this.identifyConfiguration.id(), + command.getType(), + command.getId() + ); + return new ControllerClusterTimeoutException(); + }) + ) + // Cleanup result emitters list if cancelled by the upstream or an error occurred. + .doFinally(() -> resultEmittersByCommand.remove(command.getId())); + } + + private void sendClusteredCommand(final ClusteredCommand clusteredCommand, final SingleEmitter> emitter) { + String targetId = clusteredCommand.targetId(); + final String queueName = getTargetQueueName(targetId); + + log.debug( + "[{}] Trying to send a command [{} ({})] to the target [{}] through the cluster.", + this.identifyConfiguration.id(), + clusteredCommand.command().getId(), + clusteredCommand.command().getType(), + targetId + ); + + try { + // Save the Rx emitter for later reuse (ie: when a reply will be sent in the reply queue). + resultEmittersByCommand.put(clusteredCommand.command().getId(), emitter); + + // Send the command to queue dedicated to the installation. + final Queue> queue = clusterManager.queue(queueName); + queue.add(clusteredCommand); + } catch (Exception e) { + log.error("[{}] Failed to send command to the installation [{}].", this.identifyConfiguration.id(), targetId, e); + emitter.onError(e); + } + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/command/ClusteredCommand.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/command/ClusteredCommand.java new file mode 100644 index 0000000..0792d8d --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/command/ClusteredCommand.java @@ -0,0 +1,25 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.cluster.command; + +import io.gravitee.exchange.api.command.Command; +import java.io.Serializable; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public record ClusteredCommand>(C command, String targetId, String replyToQueue) implements Serializable {} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/command/ClusteredReply.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/command/ClusteredReply.java new file mode 100644 index 0000000..ad53b2a --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/command/ClusteredReply.java @@ -0,0 +1,49 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.cluster.command; + +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.controller.core.cluster.exception.ControllerClusterException; +import java.io.Serializable; +import lombok.Getter; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Getter +public class ClusteredReply> implements Serializable { + + private final String commandId; + + private R reply; + + private ControllerClusterException controllerClusterException; + + public ClusteredReply(final String commandId, final R reply) { + this.commandId = commandId; + this.reply = reply; + } + + public ClusteredReply(final String commandId, final ControllerClusterException controllerClusterException) { + this.commandId = commandId; + this.controllerClusterException = controllerClusterException; + } + + public boolean isError() { + return controllerClusterException != null; + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/exception/ControllerClusterException.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/exception/ControllerClusterException.java new file mode 100644 index 0000000..3aa7164 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/exception/ControllerClusterException.java @@ -0,0 +1,31 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.cluster.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ControllerClusterException extends Exception { + + public ControllerClusterException() { + super(); + } + + public ControllerClusterException(final Throwable cause) { + super(cause); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/exception/ControllerClusterShutdownException.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/exception/ControllerClusterShutdownException.java new file mode 100644 index 0000000..f49b3ac --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/exception/ControllerClusterShutdownException.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.cluster.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ControllerClusterShutdownException extends ControllerClusterException { + + public ControllerClusterShutdownException() { + super(); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/exception/ControllerClusterTimeoutException.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/exception/ControllerClusterTimeoutException.java new file mode 100644 index 0000000..ebc3bfe --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/exception/ControllerClusterTimeoutException.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.cluster.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ControllerClusterTimeoutException extends ControllerClusterException { + + public ControllerClusterTimeoutException() { + super(); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/test/java/io/gravitee/exchange/controller/core/batch/BatchStoreTest.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/test/java/io/gravitee/exchange/controller/core/batch/BatchStoreTest.java new file mode 100644 index 0000000..c4d46c2 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/test/java/io/gravitee/exchange/controller/core/batch/BatchStoreTest.java @@ -0,0 +1,100 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.batch; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.gravitee.exchange.api.batch.Batch; +import io.gravitee.exchange.controller.core.batch.exception.BatchAlreadyExistsException; +import io.gravitee.exchange.controller.core.batch.exception.BatchNotExistException; +import io.gravitee.node.api.cache.Cache; +import io.gravitee.node.api.cache.CacheConfiguration; +import io.gravitee.node.plugin.cache.common.InMemoryCache; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class BatchStoreTest { + + private Cache store; + private BatchStore cut; + + @BeforeEach + public void beforeEach() { + store = new InMemoryCache<>("store", CacheConfiguration.builder().build()); + cut = new BatchStore(store); + } + + @Test + void should_add_batch_to_store() { + cut.add(Batch.builder().id("id").build()).test().awaitDone(10, TimeUnit.SECONDS).assertComplete(); + + assertThat(store.containsKey("id")).isTrue(); + } + + @Test + void should_throw_exception_when_adding_batch_without_id() { + cut.add(Batch.builder().id(null).build()).test().awaitDone(10, TimeUnit.SECONDS).assertError(IllegalArgumentException.class); + } + + @Test + void should_throw_exception_when_adding_already_existing_batch() { + Batch batch = Batch.builder().id("id").build(); + store.put(batch.id(), batch); + cut.add(Batch.builder().id("id").build()).test().awaitDone(10, TimeUnit.SECONDS).assertError(BatchAlreadyExistsException.class); + } + + @Test + void should_update_batch_to_store() { + Batch batch = Batch.builder().id("id").build(); + store.put(batch.id(), batch); + cut.update(batch).test().awaitDone(10, TimeUnit.SECONDS).assertComplete(); + + assertThat(store.containsKey("id")).isTrue(); + } + + @Test + void should_throw_exception_when_updating_batch_without_id() { + cut.update(Batch.builder().id(null).build()).test().awaitDone(10, TimeUnit.SECONDS).assertError(IllegalArgumentException.class); + } + + @Test + void should_throw_exception_when_updating_no_existing_batch() { + cut.update(Batch.builder().id("id").build()).test().awaitDone(10, TimeUnit.SECONDS).assertError(BatchNotExistException.class); + } + + @Test + void should_get_batch_from_store() { + Batch batch = Batch.builder().id("id").build(); + store.put(batch.id(), batch); + cut.getById("id").test().awaitDone(10, TimeUnit.SECONDS).assertValue(batch); + } + + @Test + void should_clear_store() { + Batch batch = Batch.builder().id("id").build(); + store.put(batch.id(), batch); + cut.clear(); + assertThat(store.containsKey("id")).isFalse(); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-core/src/test/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelCandidateStoreTest.java b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/test/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelCandidateStoreTest.java new file mode 100644 index 0000000..f3d7d6a --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-core/src/test/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelCandidateStoreTest.java @@ -0,0 +1,101 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.core.channel.primary; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.gravitee.node.api.cache.Cache; +import io.gravitee.node.api.cache.CacheConfiguration; +import io.gravitee.node.plugin.cache.common.InMemoryCache; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +class PrimaryChannelCandidateStoreTest { + + private Cache> store; + private PrimaryChannelCandidateStore cut; + + @BeforeEach + public void beforeEach() { + store = new InMemoryCache<>("store", CacheConfiguration.builder().build()); + cut = new PrimaryChannelCandidateStore(store); + } + + @Test + void should_get_from_store() { + store.put("targetId", List.of("channelId", "channelId2")); + assertThat(cut.get("targetId")).containsOnly("channelId", "channelId2"); + } + + @Test + void should_get_null_from_store() { + assertThat(cut.get("targetId")).isNull(); + } + + @Test + void should_throw_exception_when_getting_without_target_id() { + assertThatThrownBy(() -> cut.get(null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void should_put_to_store() { + cut.put("targetId", "channelId"); + assertThat(store.containsKey("targetId")).isTrue(); + } + + @Test + void should_throw_exception_when_putting_without_target_id() { + assertThatThrownBy(() -> cut.put(null, "channelId")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void should_throw_exception_when_putting_without_channel_id() { + assertThatThrownBy(() -> cut.put("targetId", null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void should_remove_from_store() { + store.put("targetId", new ArrayList<>(List.of("channelId"))); + cut.remove("targetId", "channelId"); + assertThat(store.containsKey("targetId")).isFalse(); + } + + @Test + void should_remove_only_one_channel_from_store() { + store.put("targetId", new ArrayList<>(List.of("channelId", "channelId2"))); + cut.remove("targetId", "channelId"); + assertThat(store.containsKey("targetId")).isTrue(); + assertThat(store.get("targetId")).containsOnly("channelId2"); + } + + @Test + void should_throw_exception_when_removing_without_target_id() { + assertThatThrownBy(() -> cut.remove(null, "channelId")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void should_throw_exception_when_removing_without_channel_id() { + assertThatThrownBy(() -> cut.remove("targetId", null)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-embedded/pom.xml b/gravitee-exchange-controller/gravitee-exchange-controller-embedded/pom.xml new file mode 100644 index 0000000..8abffa0 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-embedded/pom.xml @@ -0,0 +1,54 @@ + + + + 4.0.0 + + + io.gravitee.exchange + gravitee-exchange-controller + 1.0.0-alpha.7 + + + gravitee-exchange-controller-embedded + Gravitee.io - Exchange - Controller Embedded + + + io.gravitee.exchange + gravitee-exchange-api + ${project.version} + + + io.gravitee.exchange + gravitee-exchange-controller-core + ${project.version} + + + io.reactivex.rxjava3 + rxjava + provided + + + io.gravitee.common + gravitee-common + test + + + \ No newline at end of file diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-embedded/src/main/java/io/gravitee/exchange/controller/embedded/channel/EmbeddedChannel.java b/gravitee-exchange-controller/gravitee-exchange-controller-embedded/src/main/java/io/gravitee/exchange/controller/embedded/channel/EmbeddedChannel.java new file mode 100644 index 0000000..4fbd4db --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-embedded/src/main/java/io/gravitee/exchange/controller/embedded/channel/EmbeddedChannel.java @@ -0,0 +1,176 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.embedded.channel; + +import io.gravitee.exchange.api.channel.exception.ChannelInactiveException; +import io.gravitee.exchange.api.channel.exception.ChannelNoReplyException; +import io.gravitee.exchange.api.channel.exception.ChannelTimeoutException; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.connector.ConnectorChannel; +import io.gravitee.exchange.api.controller.ControllerChannel; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Single; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Slf4j +public class EmbeddedChannel implements ControllerChannel, ConnectorChannel { + + private final String id = UUID.randomUUID().toString(); + private final String targetId; + private final Map, ? extends Reply>> commandHandlers = new ConcurrentHashMap<>(); + protected final Map, ? extends Command, ? extends Reply>> commandAdapters = new ConcurrentHashMap<>(); + protected final Map, ? extends Reply>> replyAdapters = new ConcurrentHashMap<>(); + private boolean active = false; + + @Builder + public EmbeddedChannel( + final String targetId, + final List, ? extends Reply>> commandHandlers, + final List, ? extends Command, ? extends Reply>> commandAdapters, + final List, ? extends Reply>> replyAdapters + ) { + this.targetId = targetId; + addCommandHandlers(commandHandlers); + this.addCommandAdapters(commandAdapters); + this.addReplyAdapters(replyAdapters); + } + + @Override + public boolean isActive() { + return this.active; + } + + @Override + public void enforceActiveStatus(final boolean isActive) { + this.active = isActive; + } + + @Override + public String id() { + return this.id; + } + + @Override + public String targetId() { + return this.targetId; + } + + @Override + public Completable initialize() { + return Completable.fromRunnable(() -> this.active = true); + } + + @Override + public Completable close() { + return Completable.fromRunnable(() -> this.active = false); + } + + @Override + public , R extends Reply> Single send(final C command) { + CommandHandler commandHandler = commandHandlers.get(command.getType()); + if (commandHandler != null) { + return Single + .defer(() -> { + if (!active) { + return Single.error(new ChannelInactiveException()); + } + CommandAdapter, Reply> commandAdapter = (CommandAdapter, Reply>) commandAdapters.get( + command.getType() + ); + if (commandAdapter != null) { + return commandAdapter.adapt(command); + } else { + return Single.just(command); + } + }) + .flatMap(single -> { + CommandHandler, Reply> castHandler = (CommandHandler, Reply>) commandHandler; + return castHandler.handle(command); + }) + .timeout( + command.getReplyTimeoutMs(), + TimeUnit.MILLISECONDS, + Single.fromCallable(() -> { + log.warn("No reply received in time for command [{}, {}]", command.getType(), command.getId()); + throw new ChannelTimeoutException(); + }) + ) + .onErrorResumeNext(throwable -> { + CommandAdapter, R> commandAdapter = (CommandAdapter, R>) commandAdapters.get( + command.getType() + ); + if (commandAdapter != null) { + return commandAdapter.onError(command, throwable); + } else { + return Single.error(throwable); + } + }) + .flatMap(reply -> { + ReplyAdapter, R> replyAdapter = (ReplyAdapter, R>) replyAdapters.get(reply.getType()); + if (replyAdapter != null) { + return replyAdapter.adapt(reply); + } else { + return Single.just((R) reply); + } + }); + } else { + return Single.error(() -> { + if (!active) { + throw new ChannelInactiveException(); + } + String message = "No handler found for command type %s".formatted(command.getType()); + throw new ChannelNoReplyException(message); + }); + } + } + + @Override + public void addCommandHandlers(final List, ? extends Reply>> commandHandlers) { + if (commandHandlers != null) { + commandHandlers.forEach(commandHandler -> this.commandHandlers.putIfAbsent(commandHandler.supportType(), commandHandler)); + } + } + + @Override + public void addCommandAdapters( + final List, ? extends Command, ? extends Reply>> commandAdapters + ) { + if (commandAdapters != null) { + commandAdapters.forEach(commandDecorator -> this.commandAdapters.putIfAbsent(commandDecorator.supportType(), commandDecorator)); + } + } + + @Override + public void addReplyAdapters(final List, ? extends Reply>> replyAdapters) { + if (replyAdapters != null) { + replyAdapters.forEach(replyDecorator -> this.replyAdapters.putIfAbsent(replyDecorator.supportType(), replyDecorator)); + } + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-embedded/src/test/java/io/gravitee/exchange/controller/embedded/channel/EmbeddedChannelTest.java b/gravitee-exchange-controller/gravitee-exchange-controller-embedded/src/test/java/io/gravitee/exchange/controller/embedded/channel/EmbeddedChannelTest.java new file mode 100644 index 0000000..83eea14 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-embedded/src/test/java/io/gravitee/exchange/controller/embedded/channel/EmbeddedChannelTest.java @@ -0,0 +1,208 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.embedded.channel; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.gravitee.exchange.api.channel.exception.ChannelInactiveException; +import io.gravitee.exchange.api.channel.exception.ChannelNoReplyException; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.command.primary.PrimaryCommand; +import io.gravitee.exchange.api.command.primary.PrimaryCommandPayload; +import io.gravitee.exchange.api.command.primary.PrimaryReply; +import io.gravitee.exchange.api.command.primary.PrimaryReplyPayload; +import io.reactivex.rxjava3.core.Single; +import io.vertx.junit5.Checkpoint; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@ExtendWith(VertxExtension.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class EmbeddedChannelTest { + + private EmbeddedChannel cut; + + @BeforeEach + public void beforeEach() { + cut = + EmbeddedChannel + .builder() + .commandHandlers(new ArrayList<>()) + .commandAdapters(new ArrayList<>()) + .replyAdapters(new ArrayList<>()) + .targetId("targetId") + .build(); + } + + @Test + void should_have_random_id() { + assertThat(cut.id()).isNotNull(); + } + + @Test + void should_have_target_id() { + assertThat(cut.targetId()).isEqualTo("targetId"); + } + + @Test + void should_not_be_active() { + assertThat(cut.isActive()).isFalse(); + } + + @Test + void should_be_active_after_initialization() { + assertThat(cut.isActive()).isFalse(); + cut.initialize().test().assertComplete(); + assertThat(cut.isActive()).isTrue(); + } + + @Test + void should_be_inactive_after_closing() { + cut.initialize().test().assertComplete(); + assertThat(cut.isActive()).isTrue(); + + cut.close().test().assertComplete(); + assertThat(cut.isActive()).isFalse(); + } + + @Test + void should_enforce_active_status() { + assertThat(cut.isActive()).isFalse(); + cut.enforceActiveStatus(true); + assertThat(cut.isActive()).isTrue(); + } + + @Test + void should_send_command_to_internal_handlers(VertxTestContext vertxTestContext) throws InterruptedException { + Checkpoint checkpoint = vertxTestContext.checkpoint(); + cut.addCommandHandlers( + List.of( + new CommandHandler<>() { + @Override + public String supportType() { + return PrimaryCommand.COMMAND_TYPE; + } + + @Override + public Single> handle(final Command command) { + checkpoint.flag(); + return Single.just(new PrimaryReply(command.getId(), new PrimaryReplyPayload())); + } + } + ) + ); + + cut.initialize().test().assertComplete(); + + PrimaryCommand command = new PrimaryCommand(new PrimaryCommandPayload(true)); + cut.send(command).test().assertValue(reply -> reply.getCommandId().equals(command.getId())).assertComplete(); + + assertThat(vertxTestContext.awaitCompletion(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void should_decorate_command_and_reply_to_internal_handlers(VertxTestContext vertxTestContext) throws InterruptedException { + Checkpoint checkpoint = vertxTestContext.checkpoint(3); + cut = + EmbeddedChannel + .builder() + .commandHandlers( + List.of( + new CommandHandler<>() { + @Override + public String supportType() { + return PrimaryCommand.COMMAND_TYPE; + } + + @Override + public Single> handle(final Command command) { + checkpoint.flag(); + return Single.just(new PrimaryReply(command.getId(), new PrimaryReplyPayload())); + } + } + ) + ) + .commandAdapters( + List.of( + new CommandAdapter<>() { + @Override + public String supportType() { + return PrimaryCommand.COMMAND_TYPE; + } + + @Override + public Single> adapt(final Command command) { + checkpoint.flag(); + return Single.just(command); + } + } + ) + ) + .replyAdapters( + List.of( + new ReplyAdapter<>() { + @Override + public String supportType() { + return PrimaryCommand.COMMAND_TYPE; + } + + @Override + public Single> adapt(final Reply reply) { + checkpoint.flag(); + return Single.just(reply); + } + } + ) + ) + .targetId("targetId") + .build(); + + cut.initialize().test().assertComplete(); + + PrimaryCommand command = new PrimaryCommand(new PrimaryCommandPayload(true)); + cut.send(command).test().assertValue(reply -> reply.getCommandId().equals(command.getId())).assertComplete(); + + assertThat(vertxTestContext.awaitCompletion(10, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void should_return_error_when_sending_command_without_handlers() { + cut.initialize().test().assertComplete(); + + cut.send(new PrimaryCommand(new PrimaryCommandPayload(true))).test().assertNoValues().assertError(ChannelNoReplyException.class); + } + + @Test + void should_return_error_when_sending_command_to_inactive_channel() { + cut.send(new PrimaryCommand(new PrimaryCommandPayload(true))).test().assertError(ChannelInactiveException.class); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-websocket/pom.xml b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/pom.xml new file mode 100644 index 0000000..35cf910 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/pom.xml @@ -0,0 +1,65 @@ + + + + 4.0.0 + + + io.gravitee.exchange + gravitee-exchange-controller + 1.0.0-alpha.7 + + + gravitee-exchange-controller-websocket + 1.0.0-alpha.7 + Gravitee.io - Exchange - Controller Websocket + + + io.gravitee.exchange + gravitee-exchange-api + ${project.version} + + + io.gravitee.exchange + gravitee-exchange-controller-core + ${project.version} + + + io.vertx + vertx-core + provided + + + io.gravitee.node + gravitee-node-vertx + provided + + + com.fasterxml.jackson.core + jackson-databind + provided + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + provided + + + \ No newline at end of file diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/WebSocketExchangeController.java b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/WebSocketExchangeController.java new file mode 100644 index 0000000..1a64058 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/WebSocketExchangeController.java @@ -0,0 +1,186 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.websocket; + +import io.gravitee.exchange.api.configuration.IdentifyConfiguration; +import io.gravitee.exchange.api.controller.ControllerCommandHandlersFactory; +import io.gravitee.exchange.api.controller.ExchangeController; +import io.gravitee.exchange.api.websocket.command.ExchangeSerDe; +import io.gravitee.exchange.controller.core.DefaultExchangeController; +import io.gravitee.exchange.controller.core.channel.primary.PrimaryChannelManager; +import io.gravitee.exchange.controller.core.cluster.ControllerClusterManager; +import io.gravitee.exchange.controller.websocket.auth.WebSocketControllerAuthentication; +import io.gravitee.exchange.controller.websocket.server.WebSocketControllerServerConfiguration; +import io.gravitee.exchange.controller.websocket.server.WebSocketControllerServerVerticle; +import io.gravitee.node.api.cache.CacheManager; +import io.gravitee.node.api.certificate.KeyStoreLoaderFactoryRegistry; +import io.gravitee.node.api.certificate.KeyStoreLoaderOptions; +import io.gravitee.node.api.certificate.TrustStoreLoaderOptions; +import io.gravitee.node.api.cluster.ClusterManager; +import io.gravitee.node.vertx.server.http.VertxHttpServer; +import io.gravitee.node.vertx.server.http.VertxHttpServerFactory; +import io.gravitee.node.vertx.server.http.VertxHttpServerOptions; +import io.reactivex.rxjava3.core.Completable; +import io.vertx.core.DeploymentOptions; +import io.vertx.core.VertxOptions; +import io.vertx.rxjava3.core.Vertx; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Slf4j +public class WebSocketExchangeController extends DefaultExchangeController implements ExchangeController { + + private static final String HTTP_PREFIX = "controller.ws.http"; + private static final String VERTICLE_INSTANCE = "controller.ws.instances"; + + private final Vertx vertx; + private final KeyStoreLoaderFactoryRegistry keyStoreLoaderFactoryRegistry; + private final KeyStoreLoaderFactoryRegistry trustStoreLoaderFactoryRegistry; + private final WebSocketControllerServerConfiguration serverConfiguration; + private final WebSocketControllerAuthentication controllerAuthentication; + private final ControllerCommandHandlersFactory controllerCommandHandlersFactory; + private final ExchangeSerDe commandSerDe; + private String websocketServerVerticleId; + + public WebSocketExchangeController( + final IdentifyConfiguration identifyConfiguration, + final ClusterManager clusterManager, + final CacheManager cacheManager, + final Vertx vertx, + final KeyStoreLoaderFactoryRegistry keyStoreLoaderFactoryRegistry, + final KeyStoreLoaderFactoryRegistry trustStoreLoaderFactoryRegistry, + final WebSocketControllerAuthentication controllerAuthentication, + final ControllerCommandHandlersFactory controllerCommandHandlersFactory, + final ExchangeSerDe exchangeSerDe + ) { + super(identifyConfiguration, clusterManager, cacheManager); + this.vertx = vertx; + this.serverConfiguration = new WebSocketControllerServerConfiguration(identifyConfiguration); + this.keyStoreLoaderFactoryRegistry = keyStoreLoaderFactoryRegistry; + this.trustStoreLoaderFactoryRegistry = trustStoreLoaderFactoryRegistry; + this.controllerAuthentication = controllerAuthentication; + this.controllerCommandHandlersFactory = controllerCommandHandlersFactory; + this.commandSerDe = exchangeSerDe; + } + + @Override + protected void doStart() throws Exception { + super.doStart(); + deployVerticle(); + } + + private void deployVerticle() { + int instances = identifyConfiguration.getProperty(VERTICLE_INSTANCE, Integer.class, 0); + int verticleInstances = (instances < 1) ? VertxOptions.DEFAULT_EVENT_LOOP_POOL_SIZE : instances; + log.info("Starting Exchange Controller Websocket [{} instance(s)]", verticleInstances); + + DeploymentOptions options = new DeploymentOptions().setInstances(verticleInstances); + VertxHttpServerFactory vertxHttpServerFactory = new VertxHttpServerFactory( + vertx, + keyStoreLoaderFactoryRegistry, + trustStoreLoaderFactoryRegistry + ); + VertxHttpServerOptions vertxHttpServerOptions = createVertxHttpServerOptions(); + WebSocketRequestHandler webSocketRequestHandler = new WebSocketRequestHandler( + vertx, + this, + controllerAuthentication, + controllerCommandHandlersFactory, + commandSerDe + ); + vertx + .deployVerticle( + () -> { + VertxHttpServer vertxHttpServer = vertxHttpServerFactory.create(vertxHttpServerOptions); + return new WebSocketControllerServerVerticle(vertxHttpServer.newInstance(), webSocketRequestHandler); + }, + options + ) + .flatMapCompletable(deploymentId -> { + websocketServerVerticleId = deploymentId; + return Completable.complete(); + }) + .subscribe( + () -> log.info("Exchange Controller Websocket deployed successfully"), + error -> log.error("Unable to deploy Exchange Controller Websocket", error) + ); + } + + private VertxHttpServerOptions createVertxHttpServerOptions() { + VertxHttpServerOptions.VertxHttpServerOptionsBuilder builder = VertxHttpServerOptions + .builder() + .prefix(identifyConfiguration.identifyProperty(HTTP_PREFIX)) + .environment(identifyConfiguration.environment()) + .defaultPort(serverConfiguration.port()) + .host(serverConfiguration.host()) + .alpn(serverConfiguration.alpn()); + if (serverConfiguration.secured()) { + builder = + builder + .secured(true) + .keyStoreLoaderOptions( + KeyStoreLoaderOptions + .builder() + .type(serverConfiguration.keyStoreType()) + .paths(List.of(serverConfiguration.keyStorePath())) + .password(serverConfiguration.keyStorePassword()) + .build() + ) + .trustStoreLoaderOptions( + TrustStoreLoaderOptions + .builder() + .type(serverConfiguration.trustStoreType()) + .paths(List.of(serverConfiguration.trustStorePath())) + .password(serverConfiguration.trustStorePassword()) + .build() + ) + .clientAuth(serverConfiguration.clientAuth()); + } + return builder + .compressionSupported(serverConfiguration.compressionSupported()) + .idleTimeout(serverConfiguration.idleTimeout()) + .tcpKeepAlive(serverConfiguration.tcpKeepAlive()) + .maxHeaderSize(serverConfiguration.maxHeaderSize()) + .maxChunkSize(serverConfiguration.maxChunkSize()) + .maxWebSocketFrameSize(serverConfiguration.maxWebSocketFrameSize()) + .maxWebSocketMessageSize(serverConfiguration.maxWebSocketMessageSize()) + .handle100Continue(true) + // Need to be enabled to have MaxWebSocketFrameSize and MaxWebSocketMessageSize set (otherwise `gravitee-node` is skipping them) + .websocketEnabled(true) + .build(); + } + + @Override + protected void doStop() throws Exception { + super.doStop(); + undeployVerticle(); + } + + private void undeployVerticle() { + if (websocketServerVerticleId != null) { + vertx + .undeploy(websocketServerVerticleId) + .subscribe( + () -> log.info("Exchange Controller Websocket undeployed successfully"), + throwable -> log.error("Unable to undeploy Exchange Controller Websocket", throwable) + ); + } + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/WebSocketRequestHandler.java b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/WebSocketRequestHandler.java new file mode 100644 index 0000000..d103662 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/WebSocketRequestHandler.java @@ -0,0 +1,110 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.websocket; + +import static io.gravitee.exchange.api.controller.ws.WebsocketControllerConstants.EXCHANGE_PROTOCOL_HEADER; + +import io.gravitee.common.http.HttpStatusCode; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.controller.ControllerChannel; +import io.gravitee.exchange.api.controller.ControllerCommandContext; +import io.gravitee.exchange.api.controller.ControllerCommandHandlersFactory; +import io.gravitee.exchange.api.controller.ExchangeController; +import io.gravitee.exchange.api.websocket.command.ExchangeSerDe; +import io.gravitee.exchange.api.websocket.protocol.ProtocolVersion; +import io.gravitee.exchange.controller.core.channel.primary.PrimaryChannelManager; +import io.gravitee.exchange.controller.websocket.auth.WebSocketControllerAuthentication; +import io.gravitee.exchange.controller.websocket.channel.WebSocketControllerChannel; +import io.vertx.rxjava3.core.Vertx; +import io.vertx.rxjava3.core.http.HttpServerRequest; +import io.vertx.rxjava3.ext.web.RoutingContext; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Slf4j +@RequiredArgsConstructor +public class WebSocketRequestHandler implements io.vertx.core.Handler { + + private final Vertx vertx; + private final ExchangeController exchangeController; + private final WebSocketControllerAuthentication controllerAuthentication; + private final ControllerCommandHandlersFactory controllerCommandHandlersFactory; + private final ExchangeSerDe commandSerDe; + + @Override + public void handle(final RoutingContext routingContext) { + log.debug("Incoming connection on Websocket Controller"); + HttpServerRequest request = routingContext.request(); + ControllerCommandContext controllerContext = controllerAuthentication.authenticate(request); + if (controllerContext.isValid()) { + // Resolve protocol version from header + String headerValue = request.getHeader(EXCHANGE_PROTOCOL_HEADER); + ProtocolVersion protocolVersion = ProtocolVersion.parse(headerValue); + + request + .toWebSocket() + .flatMapCompletable(webSocket -> { + List, ? extends Reply>> commandHandlers = controllerCommandHandlersFactory.buildCommandHandlers( + controllerContext + ); + List, ? extends Command, ? extends Reply>> commandAdapters = controllerCommandHandlersFactory.buildCommandAdapters( + controllerContext, + protocolVersion + ); + List, ? extends Reply>> replyAdapters = controllerCommandHandlersFactory.buildReplyAdapters( + controllerContext, + protocolVersion + ); + + ControllerChannel websocketControllerChannel = new WebSocketControllerChannel( + commandHandlers, + commandAdapters, + replyAdapters, + vertx, + webSocket, + protocolVersion.adapterFactory().apply(commandSerDe) + ); + return exchangeController + .register(websocketControllerChannel) + .doOnComplete(() -> + webSocket.closeHandler(v -> + exchangeController.unregister(websocketControllerChannel).onErrorComplete().subscribe() + ) + ) + .doOnError(throwable -> { + log.error("Unable to register websocket channel"); + webSocket.close((short) 1011, "Unexpected error while registering channel").subscribe(); + }) + .onErrorComplete(); + }) + .doOnError(throwable -> routingContext.fail(HttpStatusCode.INTERNAL_SERVER_ERROR_500)) + .subscribe(); + } else { + // Authentication failed so reject the request + log.debug("Unauthorized request on Websocket Controller"); + routingContext.fail(HttpStatusCode.UNAUTHORIZED_401); + } + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/auth/DefaultCommandContext.java b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/auth/DefaultCommandContext.java new file mode 100644 index 0000000..ca7356b --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/auth/DefaultCommandContext.java @@ -0,0 +1,29 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.websocket.auth; + +import io.gravitee.exchange.api.controller.ControllerCommandContext; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public record DefaultCommandContext() implements ControllerCommandContext { + @Override + public boolean isValid() { + return true; + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/auth/DefaultWebSocketControllerAuthentication.java b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/auth/DefaultWebSocketControllerAuthentication.java new file mode 100644 index 0000000..6196757 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/auth/DefaultWebSocketControllerAuthentication.java @@ -0,0 +1,34 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.websocket.auth; + +import io.gravitee.exchange.api.controller.ControllerCommandContext; +import io.vertx.rxjava3.core.http.HttpServerRequest; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Slf4j +public class DefaultWebSocketControllerAuthentication implements WebSocketControllerAuthentication { + + @Override + public ControllerCommandContext authenticate(final HttpServerRequest httpServerRequest) { + log.warn("WebSocket Controller authentication should be implemented to customize authentication mechanism."); + return new DefaultCommandContext(); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/auth/WebSocketControllerAuthentication.java b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/auth/WebSocketControllerAuthentication.java new file mode 100644 index 0000000..0c2372c --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/auth/WebSocketControllerAuthentication.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.websocket.auth; + +import io.gravitee.exchange.api.controller.ControllerCommandContext; +import io.vertx.rxjava3.core.http.HttpServerRequest; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface WebSocketControllerAuthentication { + T authenticate(final HttpServerRequest httpServerRequest); +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/channel/WebSocketChannelInitializationException.java b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/channel/WebSocketChannelInitializationException.java new file mode 100644 index 0000000..0dea932 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/channel/WebSocketChannelInitializationException.java @@ -0,0 +1,31 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.websocket.channel; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class WebSocketChannelInitializationException extends RuntimeException { + + public WebSocketChannelInitializationException(final String message) { + super(message); + } + + public WebSocketChannelInitializationException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/channel/WebSocketControllerChannel.java b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/channel/WebSocketControllerChannel.java new file mode 100644 index 0000000..41ca845 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/channel/WebSocketControllerChannel.java @@ -0,0 +1,110 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.websocket.channel; + +import io.gravitee.exchange.api.channel.exception.ChannelClosedException; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.gravitee.exchange.api.command.goodbye.GoodByeCommand; +import io.gravitee.exchange.api.command.goodbye.GoodByeCommandPayload; +import io.gravitee.exchange.api.controller.ControllerChannel; +import io.gravitee.exchange.api.websocket.channel.AbstractWebSocketChannel; +import io.gravitee.exchange.api.websocket.protocol.ProtocolAdapter; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.CompletableEmitter; +import io.vertx.rxjava3.core.Vertx; +import io.vertx.rxjava3.core.http.ServerWebSocket; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Slf4j +public class WebSocketControllerChannel extends AbstractWebSocketChannel implements ControllerChannel { + + public WebSocketControllerChannel( + final List, ? extends Reply>> commandHandlers, + final List, ? extends Command, ? extends Reply>> commandAdapters, + final List, ? extends Reply>> replyAdapters, + final Vertx vertx, + final ServerWebSocket webSocket, + final ProtocolAdapter protocolAdapter + ) { + super(commandHandlers, commandAdapters, replyAdapters, vertx, webSocket, protocolAdapter); + } + + @Override + public boolean isActive() { + return this.active; + } + + @Override + protected boolean expectHelloCommand() { + return true; + } + + @Override + public Completable close() { + return Completable + .defer(() -> { + if (!webSocket.isClosed()) { + return send(new GoodByeCommand(new GoodByeCommandPayload(targetId, true)), true) + .ignoreElement() + .onErrorResumeNext(throwable -> { + if (throwable instanceof ChannelClosedException) { + log.debug( + "GoodBye command successfully sent for channel '{}' for target '{}' got closed normally", + id, + targetId + ); + return Completable.complete(); + } else { + log.debug("Unable to send GoodBye command for channel '{}' for target '{}'", id, targetId); + return Completable.error(throwable); + } + }); + } + return Completable.complete(); + }) + .onErrorComplete() + .doFinally(this::cleanChannel); + } + + @Override + public void enforceActiveStatus(final boolean isActive) { + this.active = isActive; + } + + @Override + protected Completable handleHelloCommand( + final CompletableEmitter emitter, + final Command command, + final CommandHandler, Reply> commandHandler + ) { + if (commandHandler == null) { + this.webSocket.close((short) 1011, "No handler for hello command").subscribe(); + emitter.onError(new WebSocketChannelInitializationException("No handler found for hello command. Closing connection.")); + return Completable.complete(); + } else { + return super.handleHelloCommand(emitter, command, commandHandler); + } + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/server/WebSocketControllerServerConfiguration.java b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/server/WebSocketControllerServerConfiguration.java new file mode 100644 index 0000000..abded4b --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/server/WebSocketControllerServerConfiguration.java @@ -0,0 +1,133 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.websocket.server; + +import io.gravitee.exchange.api.configuration.IdentifyConfiguration; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.core.net.TCPSSLOptions; +import lombok.RequiredArgsConstructor; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@RequiredArgsConstructor +public class WebSocketControllerServerConfiguration { + + public static final String PORT_KEY = "controller.ws.port"; + public static final int PORT_DEFAULT = 8062; + public static final String HOST_KEY = "controller.ws.host"; + public static final String HOST_DEFAULT = "0.0.0.0"; + public static final String ALPN_KEY = "controller.ws.alpn"; + public static final boolean ALPN_DEFAULT = false; + public static final String SECURED_KEY = "controller.ws.secured"; + public static final boolean SECURED_DEFAULT = false; + public static final String CLIENT_AUTH_KEY = "controller.ws.ssl.clientAuth"; + public static final String CLIENT_AUTH_DEFAULT = "NONE"; + public static final String KEYSTORE_TYPE_KEY = "controller.ws.ssl.keystore.type"; + public static final String KEYSTORE_PATH_KEY = "controller.ws.ssl.keystore.path"; + public static final String KEYSTORE_PASSWORD_KEY = "controller.ws.ssl.keystore.password"; + public static final String TRUSTSTORE_TYPE_KEY = "controller.ws.ssl.truststore.type"; + public static final String TRUSTSTORE_PATH_KEY = "controller.ws.ssl.truststore.path"; + public static final String TRUSTSTORE_PASSWORD_KEY = "controller.ws.ssl.truststore.password"; + public static final String COMPRESSION_SUPPORTED_KEY = "controller.ws.compressionSupported"; + public static final boolean COMPRESSION_SUPPORTED_DEFAULT = HttpServerOptions.DEFAULT_COMPRESSION_SUPPORTED; + public static final String IDLE_TIMEOUT_KEY = "controller.ws.idleTimeout"; + public static final int IDLE_TIMEOUT_DEFAULT = TCPSSLOptions.DEFAULT_IDLE_TIMEOUT; + public static final String TCP_KEEP_ALIVE_KEY = "controller.ws.tcpKeepAlive"; + public static final boolean TCP_KEEP_ALIVE_DEFAULT = true; + public static final String MAX_HEADER_SIZE_KEY = "controller.ws.maxHeaderSize"; + public static final int MAX_HEADER_SIZE_DEFAULT = 8192; + public static final String MAX_CHUNK_SIZE_KEY = "controller.ws.maxChunkSize"; + public static final int MAX_CHUNK_SIZE_DEFAULT = 8192; + public static final String MAX_WEB_SOCKET_FRAME_SIZE_KEY = "controller.ws.maxWebSocketFrameSize"; + public static final int MAX_WEB_SOCKET_FRAME_SIZE_DEFAULT = 65536; + public static final String MAX_WEB_SOCKET_MESSAGE_SIZE_KEY = "controller.ws.maxWebSocketMessageSize"; + public static final int MAX_WEB_SOCKET_MESSAGE_SIZE_DEFAULT = 13107200; + private final IdentifyConfiguration identifyConfiguration; + + public int port() { + return identifyConfiguration.getProperty(PORT_KEY, Integer.class, PORT_DEFAULT); + } + + public String host() { + return identifyConfiguration.getProperty(HOST_KEY, String.class, HOST_DEFAULT); + } + + public boolean alpn() { + return identifyConfiguration.getProperty(ALPN_KEY, Boolean.class, ALPN_DEFAULT); + } + + public boolean secured() { + return identifyConfiguration.getProperty(SECURED_KEY, Boolean.class, SECURED_DEFAULT); + } + + public String keyStoreType() { + return identifyConfiguration.getProperty(KEYSTORE_TYPE_KEY); + } + + public String keyStorePath() { + return identifyConfiguration.getProperty(KEYSTORE_PATH_KEY); + } + + public String keyStorePassword() { + return identifyConfiguration.getProperty(KEYSTORE_PASSWORD_KEY); + } + + public String trustStoreType() { + return identifyConfiguration.getProperty(TRUSTSTORE_TYPE_KEY); + } + + public String trustStorePath() { + return identifyConfiguration.getProperty(TRUSTSTORE_PATH_KEY); + } + + public String trustStorePassword() { + return identifyConfiguration.getProperty(TRUSTSTORE_PASSWORD_KEY); + } + + public String clientAuth() { + return identifyConfiguration.getProperty(CLIENT_AUTH_KEY, String.class, CLIENT_AUTH_DEFAULT); + } + + public boolean compressionSupported() { + return identifyConfiguration.getProperty(COMPRESSION_SUPPORTED_KEY, Boolean.class, COMPRESSION_SUPPORTED_DEFAULT); + } + + public int idleTimeout() { + return identifyConfiguration.getProperty(IDLE_TIMEOUT_KEY, Integer.class, IDLE_TIMEOUT_DEFAULT); + } + + public boolean tcpKeepAlive() { + return identifyConfiguration.getProperty(TCP_KEEP_ALIVE_KEY, Boolean.class, TCP_KEEP_ALIVE_DEFAULT); + } + + public int maxHeaderSize() { + return identifyConfiguration.getProperty(MAX_HEADER_SIZE_KEY, Integer.class, MAX_HEADER_SIZE_DEFAULT); + } + + public int maxChunkSize() { + return identifyConfiguration.getProperty(MAX_CHUNK_SIZE_KEY, Integer.class, MAX_CHUNK_SIZE_DEFAULT); + } + + public int maxWebSocketFrameSize() { + return identifyConfiguration.getProperty(MAX_WEB_SOCKET_FRAME_SIZE_KEY, Integer.class, MAX_WEB_SOCKET_FRAME_SIZE_DEFAULT); + } + + public int maxWebSocketMessageSize() { + return identifyConfiguration.getProperty(MAX_WEB_SOCKET_MESSAGE_SIZE_KEY, Integer.class, MAX_WEB_SOCKET_MESSAGE_SIZE_DEFAULT); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/server/WebSocketControllerServerVerticle.java b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/server/WebSocketControllerServerVerticle.java new file mode 100644 index 0000000..d44a289 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/server/WebSocketControllerServerVerticle.java @@ -0,0 +1,68 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.websocket.server; + +import static io.gravitee.exchange.api.controller.ws.WebsocketControllerConstants.EXCHANGE_CONTROLLER_PATH; +import static io.gravitee.exchange.api.controller.ws.WebsocketControllerConstants.EXCHANGE_PROTOCOL_HEADER; +import static io.gravitee.exchange.api.controller.ws.WebsocketControllerConstants.LEGACY_CONTROLLER_PATH; + +import io.gravitee.exchange.api.websocket.protocol.ProtocolVersion; +import io.gravitee.exchange.controller.websocket.WebSocketRequestHandler; +import io.reactivex.rxjava3.core.Completable; +import io.vertx.rxjava3.core.AbstractVerticle; +import io.vertx.rxjava3.core.http.HttpServer; +import io.vertx.rxjava3.ext.web.Router; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Slf4j +@RequiredArgsConstructor +public class WebSocketControllerServerVerticle extends AbstractVerticle { + + private final HttpServer controllerWebSocketHttpServer; + private final WebSocketRequestHandler webSocketRequestHandler; + + @Override + public Completable rxStart() { + Router router = Router.router(vertx); + router.route(EXCHANGE_CONTROLLER_PATH).handler(webSocketRequestHandler); + router + .route(LEGACY_CONTROLLER_PATH) + .handler(ctx -> { + ctx.request().headers().add(EXCHANGE_PROTOCOL_HEADER, ProtocolVersion.LEGACY.version()); + webSocketRequestHandler.handle(ctx); + }); + // Default non-handled requests: + router.route().handler(ctx -> ctx.fail(404)); + + return controllerWebSocketHttpServer + .requestHandler(router) + .rxListen() + .doOnSuccess(server -> log.info("Controller websocket listener ready to accept requests on port {}", server.actualPort())) + .doOnError(throwable -> log.error("Unable to start Controller websocket listener", throwable)) + .ignoreElement(); + } + + @Override + public void stop() { + log.info("Stopping Controller websocket server ..."); + controllerWebSocketHttpServer.close().doFinally(() -> log.info("HTTP Server has been successfully stopped")).subscribe(); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/test/java/io/gravitee/exchange/controller/websocket/auth/DefaultCommandContextTest.java b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/test/java/io/gravitee/exchange/controller/websocket/auth/DefaultCommandContextTest.java new file mode 100644 index 0000000..31c2c29 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/test/java/io/gravitee/exchange/controller/websocket/auth/DefaultCommandContextTest.java @@ -0,0 +1,36 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.websocket.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class DefaultCommandContextTest { + + @Test + void should_be_valid_by_default() { + DefaultCommandContext cut = new DefaultCommandContext(); + assertThat(cut.isValid()).isTrue(); + } +} diff --git a/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/test/java/io/gravitee/exchange/controller/websocket/auth/DefaultWebSocketControllerAuthenticationTest.java b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/test/java/io/gravitee/exchange/controller/websocket/auth/DefaultWebSocketControllerAuthenticationTest.java new file mode 100644 index 0000000..126aad0 --- /dev/null +++ b/gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/test/java/io/gravitee/exchange/controller/websocket/auth/DefaultWebSocketControllerAuthenticationTest.java @@ -0,0 +1,39 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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. + */ +package io.gravitee.exchange.controller.websocket.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.gravitee.exchange.api.controller.ControllerCommandContext; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class DefaultWebSocketControllerAuthenticationTest { + + @Test + void should_return_a_valid_context__by_default() { + DefaultWebSocketControllerAuthentication cut = new DefaultWebSocketControllerAuthentication(); + ControllerCommandContext controllerContext = cut.authenticate(null); + assertThat(controllerContext).isInstanceOf(DefaultCommandContext.class); + assertThat(controllerContext.isValid()).isTrue(); + } +} diff --git a/gravitee-exchange-controller/pom.xml b/gravitee-exchange-controller/pom.xml new file mode 100644 index 0000000..f320e59 --- /dev/null +++ b/gravitee-exchange-controller/pom.xml @@ -0,0 +1,39 @@ + + + + 4.0.0 + + + io.gravitee.exchange + gravitee-exchange + 1.0.0-alpha.7 + + + gravitee-exchange-controller + Gravitee.io - Exchange - Controller + pom + + + gravitee-exchange-controller-core + gravitee-exchange-controller-embedded + gravitee-exchange-controller-websocket + + \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..1ec220d --- /dev/null +++ b/pom.xml @@ -0,0 +1,225 @@ + + + + 4.0.0 + + + io.gravitee + gravitee-parent + 22.0.16 + + + io.gravitee.exchange + gravitee-exchange + 1.0.0-alpha.7 + Gravitee.io - Exchange + pom + + + 6.0.23 + 3.3.3 + 5.8.1 + + 3.14.0 + 2.1.1 + 3.0.4 + + 17 + + + + + + + io.gravitee + gravitee-bom + ${gravitee-bom.version} + import + pom + + + io.gravitee.node + gravitee-node + ${gravitee-node.version} + pom + import + + + io.gravitee.common + gravitee-common + ${gravitee-common.version} + provided + + + org.apache.commons + commons-lang3 + ${commons-lang3.version} + provided + + + jakarta.annotation + jakarta.annotation-api + ${jakarta.annotation-api.version} + + + org.wiremock + wiremock + ${wiremock.version} + test + + + + + + + + org.slf4j + slf4j-api + + + + + org.projectlombok + lombok + provided + true + + + + + org.junit.jupiter + junit-jupiter + test + + + org.junit.vintage + junit-vintage-engine + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-junit-jupiter + test + + + io.vertx + vertx-junit5 + test + + + org.springframework + spring-test + test + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.6.0 + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.6.2 + + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + org.jacoco + jacoco-maven-plugin + 0.8.11 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${jdk.version} + ${jdk.version} + + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + -Xdoclint:none + -Xdoclint:none + true + none + ${jdk.version} + + + + com.hubspot.maven.plugins + prettier-maven-plugin + 0.19 + + 16.16.0 + 1.6.1 + ${skip.validation} + + + + validate + + check + + + + + + + + + gravitee-exchange-api + gravitee-exchange-controller + gravitee-exchange-connector + + +