, ? 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 extends Command>> 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 extends Reply>> replyEmitter = resultEmitters.remove(reply.getCommandId());
+ if (replyEmitter != null) {
+ Single extends Reply>> 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
+ .