From 189937c4e9fa71c35a44af21e429a84d1d3d4ec3 Mon Sep 17 00:00:00 2001 From: Guillaume L Date: Tue, 19 Mar 2024 09:44:24 +0100 Subject: [PATCH] feat(): first version of framework * feat: initialize command-reply exchange framework * feat: add command/reply adapters to manage command migration * feat: allow to obtain connector status * feat: add key on a batch * feat: add protocol version on adapters factory * feat: add channel metrics * feat: add observer to be notified when a batch finishes * feat: improve controller metrics * feat: allow defining fallback configuration key --------- Co-authored-by: Gravitee.io Bot --- .circleci/config.yml | 2 +- .gitignore | 50 +- CHANGELOG.md | 76 +++ README.adoc | 43 +- docs/health-check.png | Bin 0 -> 69100 bytes docs/primary-channel-election.png | Bin 0 -> 104782 bytes docs/registration.png | Bin 0 -> 67042 bytes docs/sending-command.png | Bin 0 -> 71955 bytes gravitee-exchange-api/pom.xml | 96 ++++ .../io/gravitee/exchange/api/batch/Batch.java | 160 ++++++ .../exchange/api/batch/BatchCommand.java | 63 +++ .../exchange/api/batch/BatchObserver.java | 26 + .../exchange/api/batch/BatchStatus.java | 43 ++ .../exchange/api/batch/KeyBatchObserver.java | 24 + .../exchange/api/channel/Channel.java | 97 ++++ .../exception/ChannelClosedException.java | 22 + .../channel/exception/ChannelException.java | 33 ++ .../exception/ChannelInactiveException.java | 22 + .../ChannelInitializationException.java | 27 + .../exception/ChannelNoReplyException.java | 27 + .../exception/ChannelReplyException.java | 27 + .../exception/ChannelTimeoutException.java | 22 + .../ChannelUnknownCommandException.java | 27 + .../exchange/api/command/Command.java | 62 +++ .../exchange/api/command/CommandAdapter.java | 49 ++ .../exchange/api/command/CommandHandler.java | 41 ++ .../exchange/api/command/CommandStatus.java | 42 ++ .../exchange/api/command/Exchange.java | 55 ++ .../exchange/api/command/Payload.java | 24 + .../gravitee/exchange/api/command/Reply.java | 65 +++ .../exchange/api/command/ReplyAdapter.java | 39 ++ .../api/command/goodbye/GoodByeCommand.java | 42 ++ .../goodbye/GoodByeCommandPayload.java | 42 ++ .../api/command/goodbye/GoodByeReply.java | 46 ++ .../command/goodbye/GoodByeReplyPayload.java | 41 ++ .../healtcheck/HealthCheckCommand.java | 42 ++ .../healtcheck/HealthCheckCommandPayload.java | 28 ++ .../command/healtcheck/HealthCheckReply.java | 46 ++ .../healtcheck/HealthCheckReplyPayload.java | 28 ++ .../api/command/hello/HelloCommand.java | 48 ++ .../command/hello/HelloCommandPayload.java | 41 ++ .../api/command/hello/HelloReply.java | 46 ++ .../api/command/hello/HelloReplyPayload.java | 41 ++ .../exchange/api/command/noreply/NoReply.java | 44 ++ .../api/command/noreply/NoReplyPayload.java | 26 + .../api/command/primary/PrimaryCommand.java | 42 ++ .../primary/PrimaryCommandPayload.java | 26 + .../api/command/primary/PrimaryReply.java | 46 ++ .../command/primary/PrimaryReplyPayload.java | 26 + .../api/command/unknown/UnknownCommand.java | 38 ++ .../unknown/UnknownCommandHandler.java | 36 ++ .../api/command/unknown/UnknownPayload.java | 26 + .../api/command/unknown/UnknownReply.java | 42 ++ .../configuration/IdentifyConfiguration.java | 128 +++++ .../api/connector/ConnectorChannel.java | 24 + .../connector/ConnectorCommandContext.java | 22 + .../ConnectorCommandHandlersFactory.java | 58 +++ .../api/connector/ExchangeConnector.java | 86 ++++ .../connector/ExchangeConnectorManager.java | 46 ++ .../exception/ConnectorClosedException.java | 22 + .../ConnectorInitializationException.java | 22 + .../api/controller/ControllerChannel.java | 29 ++ .../controller/ControllerCommandContext.java | 24 + .../ControllerCommandHandlersFactory.java | 58 +++ .../api/controller/ExchangeController.java | 105 ++++ .../api/controller/metrics/ChannelMetric.java | 35 ++ .../api/controller/metrics/TargetMetric.java | 36 ++ .../ws/WebsocketControllerConstants.java | 31 ++ .../channel/AbstractWebSocketChannel.java | 476 ++++++++++++++++++ .../command/DefaultExchangeSerDe.java | 182 +++++++ .../api/websocket/command/ExchangeSerDe.java | 35 ++ .../exception/DeserializationException.java | 27 + .../exception/SerializationException.java | 27 + .../websocket/protocol/ProtocolAdapter.java | 46 ++ .../websocket/protocol/ProtocolExchange.java | 56 +++ .../websocket/protocol/ProtocolVersion.java | 52 ++ .../legacy/LegacyProtocolAdapter.java | 118 +++++ .../legacy/goodbye/GoodByeCommand.java | 32 ++ .../legacy/goodbye/GoodByeCommandPayload.java | 26 + .../protocol/legacy/goodbye/GoodByeReply.java | 47 ++ .../legacy/goodbye/GoodByeReplyPayload.java | 26 + .../legacy/goodbye/GoodyeCommandAdapter.java | 38 ++ .../goodbye/LegacyGoodByeReplyAdapter.java | 49 ++ .../HealthCheckCommandAdapter.java | 54 ++ .../protocol/legacy/hello/HelloCommand.java | 36 ++ .../legacy/hello/HelloCommandAdapter.java | 39 ++ .../legacy/hello/HelloCommandPayload.java | 26 + .../protocol/legacy/hello/HelloReply.java | 54 ++ .../legacy/hello/HelloReplyAdapter.java | 44 ++ .../legacy/hello/HelloReplyPayload.java | 24 + .../legacy/hello/LegacyHelloReplyAdapter.java | 42 ++ .../protocol/legacy/ignored/IgnoredReply.java | 46 ++ .../legacy/ignored/NoReplyAdapter.java | 37 ++ .../legacy/primary/PrimaryCommandAdapter.java | 54 ++ .../protocol/v1/V1ProtocolAdapter.java | 91 ++++ .../IdentifyConfigurationTest.java | 201 ++++++++ .../channel/AbstractWebSocketChannelTest.java | 430 ++++++++++++++++ .../channel/test/AbstractWebSocketTest.java | 114 +++++ .../channel/test/AdaptedDummyCommand.java | 33 ++ .../channel/test/AdaptedDummyReply.java | 34 ++ .../websocket/channel/test/DummyCommand.java | 32 ++ .../channel/test/DummyCommandAdapter.java | 38 ++ .../channel/test/DummyCommandHandler.java | 38 ++ .../channel/test/DummyCommandSerDe.java | 31 ++ .../websocket/channel/test/DummyPayload.java | 20 + .../websocket/channel/test/DummyReply.java | 35 ++ .../channel/test/DummyReplyAdapter.java | 38 ++ .../command/DefaultExchangeSerDeTest.java | 206 ++++++++ .../gravitee-exchange-connector-core/pom.xml | 55 ++ .../core/DefaultExchangeConnectorManager.java | 80 +++ .../goodbye/GoodByeCommandHandler.java | 49 ++ .../healtcheck/HealthCheckCommandHandler.java | 49 ++ .../primary/PrimaryCommandHandler.java | 47 ++ .../spring/ConnectorCoreConfiguration.java | 30 ++ .../DefaultExchangeConnectorManagerTest.java | 123 +++++ .../goodbye/GoodByeCommandHandlerTest.java | 73 +++ .../HealthCheckCommandHandlerTest.java | 71 +++ .../primary/PrimaryCommandHandlerTest.java | 73 +++ .../pom.xml | 54 ++ .../embedded/EmbeddedExchangeConnector.java | 83 +++ .../EmbeddedExchangeConnectorTest.java | 152 ++++++ .../pom.xml | 91 ++++ .../websocket/WebSocketExchangeConnector.java | 159 ++++++ .../channel/WebSocketConnectorChannel.java | 90 ++++ .../client/WebSocketClientConfiguration.java | 164 ++++++ .../WebSocketConnectorClientFactory.java | 123 +++++ .../websocket/client/WebSocketEndpoint.java | 74 +++ .../WebSocketConnectorException.java | 38 ++ .../ConnectorWebSocketConfiguration.java | 28 ++ .../AbstractWebSocketConnectorTest.java | 87 ++++ .../WebSocketExchangeConnectorTest.java | 104 ++++ .../WebSocketConnectorChannelTest.java | 274 ++++++++++ .../WebSocketClientConfigurationTest.java | 219 ++++++++ .../WebSocketConnectorClientFactoryTest.java | 292 +++++++++++ .../client/WebSocketEndpointTest.java | 97 ++++ .../src/test/resources/keystore.jks | Bin 0 -> 2070 bytes .../src/test/resources/truststore.jks | Bin 0 -> 782 bytes gravitee-exchange-connector/pom.xml | 39 ++ .../gravitee-exchange-controller-core/pom.xml | 71 +++ .../core/DefaultExchangeController.java | 409 +++++++++++++++ .../controller/core/batch/BatchStore.java | 89 ++++ .../BatchAlreadyExistsException.java | 18 + .../exception/BatchDisabledException.java | 23 + .../exception/BatchNotExistException.java | 18 + .../core/channel/ChannelManager.java | 358 +++++++++++++ .../core/channel/LocalChannelRegistry.java | 53 ++ .../exception/NoChannelFoundException.java | 22 + .../core/channel/primary/ChannelEvent.java | 22 + .../primary/PrimaryChannelCandidateStore.java | 90 ++++ .../primary/PrimaryChannelElectedEvent.java | 22 + .../primary/PrimaryChannelManager.java | 153 ++++++ .../cluster/ControllerClusterManager.java | 240 +++++++++ .../cluster/command/ClusteredCommand.java | 25 + .../core/cluster/command/ClusteredReply.java | 49 ++ .../exception/ControllerClusterException.java | 31 ++ .../ControllerClusterShutdownException.java | 27 + .../ControllerClusterTimeoutException.java | 27 + .../controller/core/batch/BatchStoreTest.java | 100 ++++ .../PrimaryChannelCandidateStoreTest.java | 101 ++++ .../pom.xml | 54 ++ .../embedded/channel/EmbeddedChannel.java | 176 +++++++ .../embedded/channel/EmbeddedChannelTest.java | 208 ++++++++ .../pom.xml | 65 +++ .../WebSocketExchangeController.java | 186 +++++++ .../websocket/WebSocketRequestHandler.java | 110 ++++ .../websocket/auth/DefaultCommandContext.java | 29 ++ ...aultWebSocketControllerAuthentication.java | 34 ++ .../WebSocketControllerAuthentication.java | 27 + ...bSocketChannelInitializationException.java | 31 ++ .../channel/WebSocketControllerChannel.java | 110 ++++ ...ebSocketControllerServerConfiguration.java | 133 +++++ .../WebSocketControllerServerVerticle.java | 68 +++ .../auth/DefaultCommandContextTest.java | 36 ++ ...WebSocketControllerAuthenticationTest.java | 39 ++ gravitee-exchange-controller/pom.xml | 39 ++ pom.xml | 225 +++++++++ 176 files changed, 12168 insertions(+), 35 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/health-check.png create mode 100644 docs/primary-channel-election.png create mode 100644 docs/registration.png create mode 100644 docs/sending-command.png create mode 100644 gravitee-exchange-api/pom.xml create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/Batch.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchCommand.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchObserver.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchStatus.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/KeyBatchObserver.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/Channel.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelClosedException.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelException.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelInactiveException.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelInitializationException.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelNoReplyException.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelReplyException.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelTimeoutException.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelUnknownCommandException.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Command.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandAdapter.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandHandler.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandStatus.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Exchange.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Payload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Reply.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/ReplyAdapter.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeCommand.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeCommandPayload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeReply.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/goodbye/GoodByeReplyPayload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckCommand.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckCommandPayload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckReply.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/healtcheck/HealthCheckReplyPayload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloCommand.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloCommandPayload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloReply.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/hello/HelloReplyPayload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/noreply/NoReply.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/noreply/NoReplyPayload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryCommand.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryCommandPayload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryReply.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/primary/PrimaryReplyPayload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownCommand.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownCommandHandler.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownPayload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/unknown/UnknownReply.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/configuration/IdentifyConfiguration.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ConnectorChannel.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ConnectorCommandContext.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ConnectorCommandHandlersFactory.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ExchangeConnector.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/ExchangeConnectorManager.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/exception/ConnectorClosedException.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/connector/exception/ConnectorInitializationException.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ControllerChannel.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ControllerCommandContext.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ControllerCommandHandlersFactory.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ExchangeController.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/metrics/ChannelMetric.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/metrics/TargetMetric.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/controller/ws/WebsocketControllerConstants.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/channel/AbstractWebSocketChannel.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/DefaultExchangeSerDe.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/ExchangeSerDe.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/exception/DeserializationException.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/command/exception/SerializationException.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/ProtocolAdapter.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/ProtocolExchange.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/ProtocolVersion.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/LegacyProtocolAdapter.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeCommand.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeCommandPayload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeReply.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodByeReplyPayload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/GoodyeCommandAdapter.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/goodbye/LegacyGoodByeReplyAdapter.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/healthcheck/HealthCheckCommandAdapter.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloCommand.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloCommandAdapter.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloCommandPayload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloReply.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloReplyAdapter.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/HelloReplyPayload.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/hello/LegacyHelloReplyAdapter.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/ignored/IgnoredReply.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/ignored/NoReplyAdapter.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/legacy/primary/PrimaryCommandAdapter.java create mode 100644 gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/websocket/protocol/v1/V1ProtocolAdapter.java create mode 100644 gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/configuration/IdentifyConfigurationTest.java create mode 100644 gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/AbstractWebSocketChannelTest.java create mode 100644 gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/AbstractWebSocketTest.java create mode 100644 gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/AdaptedDummyCommand.java create mode 100644 gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/AdaptedDummyReply.java create mode 100644 gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommand.java create mode 100644 gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommandAdapter.java create mode 100644 gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommandHandler.java create mode 100644 gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyCommandSerDe.java create mode 100644 gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyPayload.java create mode 100644 gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyReply.java create mode 100644 gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/channel/test/DummyReplyAdapter.java create mode 100644 gravitee-exchange-api/src/test/java/io/gravitee/exchange/api/websocket/command/DefaultExchangeSerDeTest.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-core/pom.xml create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/DefaultExchangeConnectorManager.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/command/goodbye/GoodByeCommandHandler.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/command/healtcheck/HealthCheckCommandHandler.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/command/primary/PrimaryCommandHandler.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-core/src/main/java/io/gravitee/exchange/connector/core/spring/ConnectorCoreConfiguration.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/DefaultExchangeConnectorManagerTest.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/command/goodbye/GoodByeCommandHandlerTest.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/command/healtcheck/HealthCheckCommandHandlerTest.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-core/src/test/java/io/gravitee/exchange/connector/core/command/primary/PrimaryCommandHandlerTest.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-embedded/pom.xml create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-embedded/src/main/java/io/gravitee/exchange/connector/embedded/EmbeddedExchangeConnector.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-embedded/src/test/java/io/gravitee/exchange/connector/embedded/EmbeddedExchangeConnectorTest.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/pom.xml create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/WebSocketExchangeConnector.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/channel/WebSocketConnectorChannel.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/client/WebSocketClientConfiguration.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/client/WebSocketConnectorClientFactory.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/client/WebSocketEndpoint.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/exception/WebSocketConnectorException.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/main/java/io/gravitee/exchange/connector/websocket/spring/ConnectorWebSocketConfiguration.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/AbstractWebSocketConnectorTest.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/WebSocketExchangeConnectorTest.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/channel/WebSocketConnectorChannelTest.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/client/WebSocketClientConfigurationTest.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/client/WebSocketConnectorClientFactoryTest.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/java/io/gravitee/exchange/connector/websocket/client/WebSocketEndpointTest.java create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/resources/keystore.jks create mode 100644 gravitee-exchange-connector/gravitee-exchange-connector-websocket/src/test/resources/truststore.jks create mode 100644 gravitee-exchange-connector/pom.xml create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/pom.xml create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/DefaultExchangeController.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/BatchStore.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/exception/BatchAlreadyExistsException.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/exception/BatchDisabledException.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/batch/exception/BatchNotExistException.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/ChannelManager.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/LocalChannelRegistry.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/exception/NoChannelFoundException.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/ChannelEvent.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelCandidateStore.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelElectedEvent.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelManager.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/ControllerClusterManager.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/command/ClusteredCommand.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/command/ClusteredReply.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/exception/ControllerClusterException.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/exception/ControllerClusterShutdownException.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/main/java/io/gravitee/exchange/controller/core/cluster/exception/ControllerClusterTimeoutException.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/test/java/io/gravitee/exchange/controller/core/batch/BatchStoreTest.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-core/src/test/java/io/gravitee/exchange/controller/core/channel/primary/PrimaryChannelCandidateStoreTest.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-embedded/pom.xml create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-embedded/src/main/java/io/gravitee/exchange/controller/embedded/channel/EmbeddedChannel.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-embedded/src/test/java/io/gravitee/exchange/controller/embedded/channel/EmbeddedChannelTest.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-websocket/pom.xml create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/WebSocketExchangeController.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/WebSocketRequestHandler.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/auth/DefaultCommandContext.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/auth/DefaultWebSocketControllerAuthentication.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/auth/WebSocketControllerAuthentication.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/channel/WebSocketChannelInitializationException.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/channel/WebSocketControllerChannel.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/server/WebSocketControllerServerConfiguration.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/main/java/io/gravitee/exchange/controller/websocket/server/WebSocketControllerServerVerticle.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/test/java/io/gravitee/exchange/controller/websocket/auth/DefaultCommandContextTest.java create mode 100644 gravitee-exchange-controller/gravitee-exchange-controller-websocket/src/test/java/io/gravitee/exchange/controller/websocket/auth/DefaultWebSocketControllerAuthenticationTest.java create mode 100644 gravitee-exchange-controller/pom.xml create mode 100644 pom.xml diff --git a/.circleci/config.yml b/.circleci/config.yml index 6e08c49..b37b681 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -32,4 +32,4 @@ workflows: - /.*/ tags: only: - - /^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$/ \ No newline at end of file + - /^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$/ diff --git a/.gitignore b/.gitignore index 760fe44..b1fd4fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,37 +1,23 @@ -*.class +!.gitignore -# Mobile Tools for Java (J2ME) -.mtj.tmp/ +**/target/ +.idea/ +*.iml +.DS_Store +.*.settings.xml +**/.logs -# Package Files # -*.jar -*.war -*.ear +# eclipse +.settings/ +.project +.classpath +/bin/ -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* +**/.tmp/ +**/coverage/ -# Maven files -/target/ +# Deepcode (Snyk Code) cache +**/.dccache -# Idea files -/.idea/ -/*.iml -# -- Cicd : Git ignore the [.circleci/**/*] which contains -# files which do not need to be commited (password to artifactory) -.circleci/**/* -# -- Cicd : Do not git ignore the [!./.circleci/config.yml] which contains -# the pipeline definition -!.circleci/config.yml -# -- Cicd : Git ignore the [gpg.script.snippet.sh] which contains -# secrets (password to artifactory) -gpg.script.snippet.sh -# -- Cicd : The [graviteebot.gpg.priv.key] file contains secrets -# which should not be commited -graviteebot.gpg.priv.key -# -- Cicd : The [.secrethub.credential] file contains secrets -# which should not be commited -graviteebot.gpg.pub.key -# -- Cicd : The [.secrets.json] file contains secrets -# which should not be commited -.secrets.json \ No newline at end of file +# Exclude flattened version of the pom, for details see https://maven.apache.org/maven-ci-friendly.html#install-deploy +.flattened-pom.xml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1104623 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,76 @@ +# [1.0.0-alpha.7](https://github.com/gravitee-io/gravitee-exchange/compare/1.0.0-alpha.6...1.0.0-alpha.7) (2024-03-15) + + +### Bug Fixes + +* add missing provided scope ([832a90c](https://github.com/gravitee-io/gravitee-exchange/commit/832a90ca17a7233030b9e62047a80e26b5081ea6)) +* fix some edge case issue on unit test ([f21cd3c](https://github.com/gravitee-io/gravitee-exchange/commit/f21cd3cca77ff612846a2090e2941c4bc302ba41)) + +# [1.0.0-alpha.6](https://github.com/gravitee-io/gravitee-exchange/compare/1.0.0-alpha.5...1.0.0-alpha.6) (2024-03-14) + + +### Features + +* allow defining fallback configuration key ([4bed7a3](https://github.com/gravitee-io/gravitee-exchange/commit/4bed7a38d9c15a8191841a00b7d6aaaffe9cb7d3)) + +# [1.0.0-alpha.5](https://github.com/gravitee-io/gravitee-exchange/compare/1.0.0-alpha.4...1.0.0-alpha.5) (2024-03-05) + + +### Bug Fixes + +* adapte connector log during connection ([59f4d15](https://github.com/gravitee-io/gravitee-exchange/commit/59f4d153818508b84c08d9b269066c5e8784bdf8)) +* add command id on clusteredReply to manage no reply ([1bd10ab](https://github.com/gravitee-io/gravitee-exchange/commit/1bd10abb9cd0e7f8cbb64db2132658551cae1b89)) +* add legacy controller path ([2016430](https://github.com/gravitee-io/gravitee-exchange/commit/2016430d832e8c3b7e5f13804361403792c6aa53)) +* add missing client auth default value ([404bdde](https://github.com/gravitee-io/gravitee-exchange/commit/404bdde9398b0b92cf4bcb333bb069307f73ec71)) +* add missing space on legacy prefixes ([dcd8ecf](https://github.com/gravitee-io/gravitee-exchange/commit/dcd8ecf0366de4d481cab262f7c6fab523f364cd)) +* clean default GoodByeCommandHandler from reconnection mechanism ([7e84b4f](https://github.com/gravitee-io/gravitee-exchange/commit/7e84b4f773a7a2329fcbb7b4f172169a5c62a4d6)) +* correct an issue with reply adapter wrongly used ([94684f3](https://github.com/gravitee-io/gravitee-exchange/commit/94684f323c99d0ad4f1f421e32cbe5bf25ac4c56)) +* do not failed on no reply exception ([4703c83](https://github.com/gravitee-io/gravitee-exchange/commit/4703c834783fe68a9d8b5122a3ebcfedd7c5e910)) +* dont use cause to log error ([c049c8f](https://github.com/gravitee-io/gravitee-exchange/commit/c049c8f311c2a9d9f55d1231e05cfbf9366f0a21)) +* filter null channel on stop to avoid NPE ([3ba8c76](https://github.com/gravitee-io/gravitee-exchange/commit/3ba8c764c44f52e1b93af1ef649f10c8f04ed38d)) +* handle internal the websocket status based on GoodBye command ([1478bfd](https://github.com/gravitee-io/gravitee-exchange/commit/1478bfd7a183ec9e238a123caaeee1a1db376eac)) +* improve and correct legacy adapters ([f97684f](https://github.com/gravitee-io/gravitee-exchange/commit/f97684fc3d9b6c92c57e0567d1543f9bac76187d)) +* issue with batch configuration ([08cdf38](https://github.com/gravitee-io/gravitee-exchange/commit/08cdf384febef676b8555ead0167b50627c1d283)) +* properly handle goodbye reconnection ([4f92d83](https://github.com/gravitee-io/gravitee-exchange/commit/4f92d835a09cd4fc117a140c997c7cd8c8a95881)) +* properly handle serialization ([3321dcf](https://github.com/gravitee-io/gravitee-exchange/commit/3321dcfcdc33af9ad00e76cf784048f50aec3c69)) +* properly manage controller shutdown process ([dd59ba2](https://github.com/gravitee-io/gravitee-exchange/commit/dd59ba206c6db22c004b4ae52c7de3504c85233d)) +* properly set default primary value on websocket connector ([b6d7bb9](https://github.com/gravitee-io/gravitee-exchange/commit/b6d7bb9f41fd96e6360019e8f51347922ec077e4)) +* properly use adapted cmd/reply when required ([7407a59](https://github.com/gravitee-io/gravitee-exchange/commit/7407a59d39f742d271befde216f6bb482e287e33)) + + +### Features + +* add channel metrics ([bf1d814](https://github.com/gravitee-io/gravitee-exchange/commit/bf1d81407187bdadc62d275c35732b7a026465e1)) +* add key on a batch ([49ec37f](https://github.com/gravitee-io/gravitee-exchange/commit/49ec37f52e38c86c2d9b3d0e9216c8e27d869959)) +* add observer to be notified when a batch finishes ([f958cb7](https://github.com/gravitee-io/gravitee-exchange/commit/f958cb7c5a512ff73e1b60ce79f37407beddac21)) +* add protocol version on adapters factory ([8ac8d77](https://github.com/gravitee-io/gravitee-exchange/commit/8ac8d77f5a82779f35472c8cf567990949f3318d)) +* allow to obtain connector status ([49f9d36](https://github.com/gravitee-io/gravitee-exchange/commit/49f9d3672ead035c893e7dd7a8e84ce0911670fb)) +* improve controller metrics ([986079d](https://github.com/gravitee-io/gravitee-exchange/commit/986079d70a5454893266afe648d84c8f536fd37f)) + +# [1.0.0-alpha.4](https://github.com/gravitee-io/gravitee-exchange/compare/1.0.0-alpha.3...1.0.0-alpha.4) (2024-02-15) + + +### Bug Fixes + +* apply correct version ([a3bac9e](https://github.com/gravitee-io/gravitee-exchange/commit/a3bac9e794e85eeccb3ab89a39bcf716823ec7d1)) + +# [1.0.0-alpha.3](https://github.com/gravitee-io/gravitee-exchange/compare/1.0.0-alpha.2...1.0.0-alpha.3) (2024-02-15) + + +### Features + +* add command/reply adapters to manage command migration ([23b4605](https://github.com/gravitee-io/gravitee-exchange/commit/23b46050c8e63fc3453b00e079001c6a98ca1d04)) + +# [1.0.0-alpha.2](https://github.com/gravitee-io/gravitee-exchange/compare/1.0.0-alpha.1...1.0.0-alpha.2) (2024-02-07) + + +### Bug Fixes + +* refactor serialization to avoid class cast ([42531a4](https://github.com/gravitee-io/gravitee-exchange/commit/42531a499d4628ab6e3ca398fdc08fe3bff94b66)) + +# 1.0.0-alpha.1 (2024-01-29) + + +### Features + +* initialize command-reply exchange framework ([8cbac71](https://github.com/gravitee-io/gravitee-exchange/commit/8cbac71d14814c749a1c86d1a31e283da8450732)) diff --git a/README.adoc b/README.adoc index 4153cc7..4569edf 100644 --- a/README.adoc +++ b/README.adoc @@ -1,3 +1,42 @@ -= Command-Reply Exchange Framework += Gravitee.io - Command-Reply Exchange Framework -This repository contains all code related to the command-reply exchange framework. \ No newline at end of file +== Description +The command-reply exchange framework offers a generic mechanism to exchange commands and replies between two components called Controller and Connectors. + +== Overview +=== Connector +A connector is identified by its id and its target-id. In order to scale connectors, connectors can be attached to the same target-id. The connector is managed by a controller. +A Connector allows to send command to the controller. + +=== Controller +A controller is the component where connectors can connect to. It will manage connectors life-cycle based health check, and primary election. Controllers work in cluster based on ClusterManager service offered by https://github.com/gravitee-io/gravitee-node/tree/master/gravitee-node-cluster[gravitee-node]. +A controller allows to send commands and batches to a connector via its target-id. + +=== Channel +A channel is a way to exchange commands and replies between a connector and a controller. There is always a connector view and a controller view of the same channel. A channel can be embedded when both controller and connectors run on the same JVM or could represent a network when they are remote. + +=== Commands +A command is a json payload send to a channel. For any commands a reply is expected. + +=== Workflow +Bellow you could find advanced details on different connector<>controller workflows. + +==== Registration +This is the first required step to allow connectors and controllers to exchange commands. The diagram bellow describes how a connector establishes the connection and how the controller registers it. + +image::docs/registration.png[width=980] + +==== Primary channel election +The primary channel election is used in order to avoid cluster management on the connectors side. A channel get notified it is primary thanks to a specific command. Then, it can e.g. start a scheduled process to send the controller some specific commands. Note that primary and secondary channels can still be used to send and receive commands and replies, this mechanism exists so that a connector bound to the primary channel is responsible to send commands that no other connector is allowed to send. + +image::docs/primary-channel-election.png[width=1167] + +==== Sending command +A command is the way to communicate between a connector and a controller. The schema bellow explain the various steps its processing involves. + +image::docs/sending-command.png[width=1125] + +==== Health check mechanism +The purpose of health check is to verify that the connector is still properly working and able to receive commands, at network level and also at the business logic level. The connector can do business check to ensure it can react on any commands. + +image::docs/health-check.png[width=1125] \ No newline at end of file diff --git a/docs/health-check.png b/docs/health-check.png new file mode 100644 index 0000000000000000000000000000000000000000..5413886c4c557ed9abdd764a986856ac20529aaa GIT binary patch literal 69100 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∓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 M>fJ89b^qD_0@a}DQ2+n{ literal 0 HcmV?d00001 diff --git a/docs/primary-channel-election.png b/docs/primary-channel-election.png new file mode 100644 index 0000000000000000000000000000000000000000..05e3f2ee550a336735f82666b674e23ee2e3d4b2 GIT binary patch literal 104782 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 d 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 + + + 4.0.0 + + + io.gravitee.exchange + gravitee-exchange + 1.0.0-alpha.7 + + + gravitee-exchange-api + 1.0.0-alpha.7 + Gravitee.io - Exchange - API + + + + + com.fasterxml.jackson.core + jackson-databind + provided + + + + io.gravitee.common + gravitee-common + provided + + + + io.gravitee.node + gravitee-node-api + provided + + + org.springframework + spring-context + provided + + + + + io.vertx + vertx-core + provided + + + io.vertx + vertx-rx-java3 + provided + + + io.reactivex.rxjava3 + rxjava + provided + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + test-jar + + + + io/gravitee/exchange/api/websocket/channel/test/** + + + + + + + + diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/Batch.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/Batch.java new file mode 100644 index 0000000..4eae0cf --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/Batch.java @@ -0,0 +1,160 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.batch; + +import io.gravitee.common.utils.UUID; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import java.io.Serializable; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Accessors(fluent = true, chain = true) +public class Batch implements Serializable { + + public static final int DEFAULT_MAX_RETRY = 5; + public static final long DEFAULT_SCHEDULER_PERIOD_IN_SECONDS = 60; + + /** + * The ID of the batch + */ + @Builder.Default + private String id = UUID.random().toString(); + + /** + * The key to identify the batch + */ + private String key; + + /** + * The target id of the batch + */ + private String targetId; + + /** + * The list of commands for this batch + */ + private List batchCommands; + + /** + * The status of the batch + */ + @Builder.Default + private BatchStatus status = BatchStatus.CREATED; + + /** + * The message related to the status of the batch. Ex: the error message when a command has failed. + */ + private String errorDetails; + + @Builder.Default + private int maxRetry = DEFAULT_MAX_RETRY; + + private Integer retry; + + private Instant lastRetryAt; + + public Batch start() { + Instant now = Instant.now(); + boolean shouldRetry = Optional + .ofNullable(this.lastRetryAt) + .map(t -> now.compareTo(t.plusSeconds(this.retry * DEFAULT_SCHEDULER_PERIOD_IN_SECONDS))) + .map(compare -> compare >= 0) + .orElse(true); + + if (shouldRetry) { + this.status = BatchStatus.IN_PROGRESS; + this.retry = Optional.ofNullable(this.retry).map(r -> r + 1).orElse(0); + this.lastRetryAt = now; + } + return this; + } + + public Batch reset() { + if (this.status == BatchStatus.IN_PROGRESS) { + this.status = BatchStatus.PENDING; + this.retry = 0; + this.lastRetryAt = null; + return this; + } + return this; + } + + public Batch markCommandInProgress(final String commandId) { + return markCommand(commandId, CommandStatus.IN_PROGRESS, null); + } + + public Batch markCommandInError(final String commandId, final String errorDetails) { + return markCommand(commandId, CommandStatus.ERROR, errorDetails); + } + + private Batch markCommand(final String commandId, final CommandStatus commandStatus, final String errorDetails) { + this.batchCommands.forEach(c -> { + if (Objects.equals(c.command().getId(), commandId)) { + c.status(commandStatus).errorDetails(errorDetails); + } + }); + this.status = computeStatus(); + return this; + } + + public Batch setCommandReply(final String commandId, final Reply reply) { + this.batchCommands.forEach(c -> { + if (Objects.equals(c.command().getId(), commandId)) { + c.status(reply.getCommandStatus()).reply(reply).errorDetails(reply.getErrorDetails()); + } + }); + this.status = computeStatus(); + return this; + } + + private BatchStatus computeStatus() { + boolean isActionSucceeded = + this.batchCommands.stream().allMatch(batchCommand -> batchCommand.status().equals(CommandStatus.SUCCEEDED)); + if (isActionSucceeded) { + return BatchStatus.SUCCEEDED; + } + + boolean isActionOnError = this.batchCommands.stream().anyMatch(batchCommand -> batchCommand.status().equals(CommandStatus.ERROR)); + boolean thresholdReached = this.retry >= this.maxRetry; + if (isActionOnError) { + if (thresholdReached) { + return BatchStatus.ERROR; + } + return BatchStatus.PENDING; + } + + return BatchStatus.IN_PROGRESS; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchCommand.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchCommand.java new file mode 100644 index 0000000..f22a42e --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchCommand.java @@ -0,0 +1,63 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.batch; + +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandStatus; +import io.gravitee.exchange.api.command.Reply; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Getter +@Accessors(fluent = true, chain = true) +public class BatchCommand implements Serializable { + + /** + * The status of the command + */ + @Builder.Default + @Setter + private CommandStatus status = CommandStatus.PENDING; + + /** + * The reply if any received yet + */ + @Setter + private Reply reply; + + /** + * An optional message that is used to give some details about the reply error. + */ + @Setter + private String errorDetails; + + /** + * The real command to execute. + */ + private Command command; +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchObserver.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchObserver.java new file mode 100644 index 0000000..edc22d5 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchObserver.java @@ -0,0 +1,26 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.batch; + +import io.reactivex.rxjava3.core.Completable; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface BatchObserver { + Completable notify(final Batch batch); +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchStatus.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchStatus.java new file mode 100644 index 0000000..0bfda5f --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/BatchStatus.java @@ -0,0 +1,43 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.batch; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public enum BatchStatus { + /** + * The batch was just created + */ + CREATED, + /** + * The batch is waiting for one of its commands to be retried + */ + PENDING, + /** + * The batch is currently being processed + */ + IN_PROGRESS, + /** + * The batch has been processed successfully + */ + SUCCEEDED, + /** + * The batch has been processed but ends in error even after multiple retries + */ + ERROR, +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/KeyBatchObserver.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/KeyBatchObserver.java new file mode 100644 index 0000000..47e49cd --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/batch/KeyBatchObserver.java @@ -0,0 +1,24 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.batch; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface KeyBatchObserver extends BatchObserver { + String batchKey(); +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/Channel.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/Channel.java new file mode 100644 index 0000000..ab248a5 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/Channel.java @@ -0,0 +1,97 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.channel; + +import io.gravitee.exchange.api.channel.exception.ChannelClosedException; +import io.gravitee.exchange.api.channel.exception.ChannelInitializationException; +import io.gravitee.exchange.api.channel.exception.ChannelNoReplyException; +import io.gravitee.exchange.api.channel.exception.ChannelTimeoutException; +import io.gravitee.exchange.api.channel.exception.ChannelUnknownCommandException; +import io.gravitee.exchange.api.command.Command; +import io.gravitee.exchange.api.command.CommandAdapter; +import io.gravitee.exchange.api.command.CommandHandler; +import io.gravitee.exchange.api.command.Reply; +import io.gravitee.exchange.api.command.ReplyAdapter; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Single; +import java.util.List; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface Channel { + /** + * Get the unique id of this channel. + * + * @return the id of the channel. + */ + String id(); + + /** + * Get the target id the channel is opened for. + * + * @return target id. + */ + String targetId(); + + /** + * Must be called to initialize channel connectivity + * + * @return returns a {@code Completable} instance that completes in case of success or a {@link ChannelInitializationException} is emitted + */ + Completable initialize(); + + /** + * Must be called to properly close channel + * + * @return returns a {@code Completable} instance that completes in case of success or a {@link ChannelClosedException} is emitted + */ + Completable close(); + + /** + * Return true is the current channel is active and ready to receive new commands, false otherwise. + * + * @return status of the channel. + */ + boolean isActive(); + + /** + * Send the actual commands to the current channel. In case of error, different exception could be returned: + *
    + *
  • if the channel is inactive {@link ChannelInitializationException} is signaled
  • + *
  • if no reply has been received before the timeout is reached {@link ChannelTimeoutException} is signaled
  • + *
  • if command is unknown by the receiver {@link ChannelUnknownCommandException} is signaled
  • + *
  • if the receiver cannot reply {@link ChannelNoReplyException} is signaled
  • + *
+ * @return a {@code Single} with the {@code Reply} of the command + */ + , R extends Reply> Single send(final C command); + + /** + * Add customs {@link CommandHandler} to this channel + */ + void addCommandHandlers(final List, ? extends Reply>> commandHandlers); + + /** + * Add customs {@link CommandAdapter} to this channel + */ + void addCommandAdapters(final List, ? extends Command, ? extends Reply>> commandAdapters); + /** + * Add customs {@link ReplyAdapter} to this channel + */ + void addReplyAdapters(final List, ? extends Reply>> replyAdapters); +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelClosedException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelClosedException.java new file mode 100644 index 0000000..173ac10 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelClosedException.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelClosedException extends ChannelException {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelException.java new file mode 100644 index 0000000..0af3d63 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelException.java @@ -0,0 +1,33 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelException extends RuntimeException { + + public ChannelException() {} + + public ChannelException(final String message) { + super(message); + } + + public ChannelException(final Throwable cause) { + super(cause); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelInactiveException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelInactiveException.java new file mode 100644 index 0000000..905bf8b --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelInactiveException.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelInactiveException extends ChannelException {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelInitializationException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelInitializationException.java new file mode 100644 index 0000000..6286c17 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelInitializationException.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelInitializationException extends ChannelException { + + public ChannelInitializationException(final String message) { + super(message); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelNoReplyException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelNoReplyException.java new file mode 100644 index 0000000..7e7029e --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelNoReplyException.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelNoReplyException extends ChannelException { + + public ChannelNoReplyException(final String message) { + super(message); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelReplyException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelReplyException.java new file mode 100644 index 0000000..f4944ac --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelReplyException.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelReplyException extends ChannelException { + + public ChannelReplyException(final Throwable cause) { + super(cause); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelTimeoutException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelTimeoutException.java new file mode 100644 index 0000000..b4395dc --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelTimeoutException.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelTimeoutException extends ChannelException {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelUnknownCommandException.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelUnknownCommandException.java new file mode 100644 index 0000000..16d9538 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/channel/exception/ChannelUnknownCommandException.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.channel.exception; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public class ChannelUnknownCommandException extends ChannelException { + + public ChannelUnknownCommandException(final String message) { + super(message); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Command.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Command.java new file mode 100644 index 0000000..fb2ff99 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Command.java @@ -0,0 +1,62 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.command; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.gravitee.common.utils.UUID; +import io.gravitee.exchange.api.command.unknown.UnknownCommand; +import java.io.Serializable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "type", + defaultImpl = UnknownCommand.class +) +@NoArgsConstructor +@Getter +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@JsonPropertyOrder({ "id", "type", "payload" }) +public abstract class Command

extends Exchange

implements Serializable { + + public static final int COMMAND_REPLY_TIMEOUT_MS = 60_000; + + /** + * The command id. + */ + protected String id; + + @Setter + @JsonIgnore + protected int replyTimeoutMs = COMMAND_REPLY_TIMEOUT_MS; + + protected Command(String type) { + super(type); + this.id = UUID.random().toString(); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandAdapter.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandAdapter.java new file mode 100644 index 0000000..84e956b --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandAdapter.java @@ -0,0 +1,49 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.command; + +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface CommandAdapter, C2 extends Command, R extends Reply> { + /** + * Returns the type of command supported by this adapter. + * + * @return the type of command supported. + */ + String supportType(); + + /** + * Method invoked before sending the command. + + * @return the command adapted + */ + default Single adapt(C1 command) { + return (Single) Single.just(command); + } + + /** + * Method invoke when an error is raised when sending a command. + * + * @param throwable a throwable + */ + default Single onError(final Command command, final Throwable throwable) { + return Single.error(throwable); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandHandler.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandHandler.java new file mode 100644 index 0000000..59a9abc --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandHandler.java @@ -0,0 +1,41 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.command; + +import io.reactivex.rxjava3.core.Single; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface CommandHandler, R extends Reply> { + /** + * Returns the type of command supported by this command handler. + * The type is used to determine the right handler to use when a command need to be handled. + * @return the type of command supported. + */ + String supportType(); + + /** + * Method invoked when a command of the expected type is received. + * + * @param command the command to handle. + * @return the reply with a status indicating if the command has been successfully handled or not. + */ + default Single handle(C command) { + return Single.error(new RuntimeException("Handle command of type " + supportType() + " is not implemented")); + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandStatus.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandStatus.java new file mode 100644 index 0000000..5f22f67 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/CommandStatus.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.command; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public enum CommandStatus { + /** + * The command is waiting for processing. + */ + PENDING, + + /** + * The command is processing. + */ + IN_PROGRESS, + + /** + * The command have been successfully processed. + */ + SUCCEEDED, + + /** + * The command got an unexpected error. + */ + ERROR, +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Exchange.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Exchange.java new file mode 100644 index 0000000..b3136ee --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Exchange.java @@ -0,0 +1,55 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.command; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import java.io.Serializable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.FieldNameConstants; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type") +@JsonIgnoreProperties(ignoreUnknown = true) +@NoArgsConstructor +@Getter +@EqualsAndHashCode +@ToString +@FieldNameConstants +public abstract class Exchange

implements Serializable { + + /** + * The type of the exchange (mainly used for deserialization). + */ + protected String type; + + /** + * The actual payload to send. + */ + protected P payload; + + protected Exchange(String type) { + this.type = type; + } +} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Payload.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Payload.java new file mode 100644 index 0000000..b0781a7 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Payload.java @@ -0,0 +1,24 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.command; + +import java.io.Serializable; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +public interface Payload extends Serializable {} diff --git a/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Reply.java b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Reply.java new file mode 100644 index 0000000..797d772 --- /dev/null +++ b/gravitee-exchange-api/src/main/java/io/gravitee/exchange/api/command/Reply.java @@ -0,0 +1,65 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.gravitee.exchange.api.command; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.gravitee.exchange.api.command.unknown.UnknownReply; +import java.io.Serializable; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +/** + * @author Guillaume LAMIRAND (guillaume.lamirand at graviteesource.com) + * @author GraviteeSource Team + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", defaultImpl = UnknownReply.class) +@Getter +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@JsonPropertyOrder({ "commandId", "type", "commandStatus", "errorDetails", "payload" }) +public abstract class Reply

extends Exchange

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

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

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

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

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