From f5cd34f827aa87ce9da5baa55c7fa0adf15ffcf2 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Mon, 19 Oct 2026 16:10:03 +0400 Subject: [PATCH 01/30] dbeaver/pro#11108 Add external DAM transport contracts --- .../META-INF/MANIFEST.MF | 14 ++ .../build.properties | 4 + apis/com.dbeaver.datadam.external.api/pom.xml | 58 +++++++ .../external/api/DDExternalDamProtocol.java | 143 ++++++++++++++++ .../external/api/model/DDConnectionInfo.java | 31 ++++ .../datadam/external/api/model/DDHello.java | 33 ++++ .../datadam/external/api/model/DDMessage.java | 47 ++++++ .../datadam/external/api/model/DDReady.java | 25 +++ .../api/transport/DDConnectionListener.java | 26 +++ .../api/transport/DDMessageChannel.java | 36 ++++ .../api/transport/DDMessageListener.java | 26 +++ .../api/transport/DDSubscription.java | 23 +++ .../api/DDExternalDamProtocolTest.java | 159 ++++++++++++++++++ .../external/api/model/DDMessageTest.java | 62 +++++++ apis/pom.xml | 1 + 15 files changed, 688 insertions(+) create mode 100644 apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF create mode 100644 apis/com.dbeaver.datadam.external.api/build.properties create mode 100644 apis/com.dbeaver.datadam.external.api/pom.xml create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java diff --git a/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF b/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF new file mode 100644 index 0000000..fb80797 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF @@ -0,0 +1,14 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Vendor: DBeaver Corp +Bundle-Name: DataDam External DAM API +Bundle-SymbolicName: com.dbeaver.datadam.external.api +Bundle-Version: 0.0.17.qualifier +Bundle-RequiredExecutionEnvironment: JavaSE-21 +Export-Package: com.dbeaver.datadam.external.api, + com.dbeaver.datadam.external.api.model, + com.dbeaver.datadam.external.api.transport +Bundle-ActivationPolicy: lazy +Require-Bundle: org.jkiss.utils +Import-Package: com.google.gson +Automatic-Module-Name: com.dbeaver.datadam.external.api diff --git a/apis/com.dbeaver.datadam.external.api/build.properties b/apis/com.dbeaver.datadam.external.api/build.properties new file mode 100644 index 0000000..f0239d7 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/build.properties @@ -0,0 +1,4 @@ +source.. = src/main/java +output.. = target/classes/ +bin.includes = ., + META-INF/ diff --git a/apis/com.dbeaver.datadam.external.api/pom.xml b/apis/com.dbeaver.datadam.external.api/pom.xml new file mode 100644 index 0000000..d3d12d0 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + com.dbeaver.datadam + api + 0.0.17-SNAPSHOT + ../pom.xml + + + com.dbeaver.datadam.external.api + eclipse-plugin + DataDam External DAM API + + + + com.dbeaver.common + org.jkiss.utils + + + com.google.code.gson + gson + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + ${project.basedir}/src/main/java + ${project.basedir}/src/test/java + + + + org.apache.maven.plugins + maven-surefire-plugin + + + test + test + + test + + + + + + + diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java new file mode 100644 index 0000000..cf51a1d --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java @@ -0,0 +1,143 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api; + +import com.dbeaver.datadam.external.api.model.DDHello; +import com.dbeaver.datadam.external.api.model.DDMessage; +import com.dbeaver.datadam.external.api.model.DDReady; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import org.jkiss.code.NotNull; + +public final class DDExternalDamProtocol { + public static final String WEBSOCKET_PATH = "/ws"; + public static final String TYPE_HELLO = "hello"; + public static final String TYPE_READY = "ready"; + + private static final String FIELD_TOPIC = "topic"; + private static final String FIELD_TYPE = "type"; + private static final String FIELD_PAYLOAD = "payload"; + private static final String FIELD_DEPLOYMENT_ID = "deploymentId"; + private static final String FIELD_APPLICATION_RUN_ID = "applicationRunId"; + private static final String FIELD_PRODUCT = "product"; + private static final String FIELD_VERSION = "version"; + private static final String FIELD_CONNECTION_ID = "connectionId"; + + private DDExternalDamProtocol() { + } + + @NotNull + public static String encode(@NotNull DDMessage message) { + JsonObject envelope = envelope(message.type(), message.payload()); + envelope.addProperty(FIELD_TOPIC, message.topic()); + return envelope.toString(); + } + + @NotNull + public static DDMessage decode(@NotNull String message) { + JsonObject envelope = parseEnvelope(message); + return new DDMessage( + readString(envelope, FIELD_TOPIC), + readString(envelope, FIELD_TYPE), + readPayload(envelope) + ); + } + + @NotNull + public static String encodeHello(@NotNull DDHello hello) { + JsonObject payload = new JsonObject(); + payload.addProperty(FIELD_DEPLOYMENT_ID, hello.deploymentId()); + payload.addProperty(FIELD_APPLICATION_RUN_ID, hello.applicationRunId()); + payload.addProperty(FIELD_PRODUCT, hello.product()); + payload.addProperty(FIELD_VERSION, hello.version()); + return envelope(TYPE_HELLO, payload).toString(); + } + + @NotNull + public static DDHello decodeHello(@NotNull String message) { + JsonObject payload = readControlPayload(message, TYPE_HELLO); + return new DDHello( + readString(payload, FIELD_DEPLOYMENT_ID), + readString(payload, FIELD_APPLICATION_RUN_ID), + readString(payload, FIELD_PRODUCT), + readString(payload, FIELD_VERSION) + ); + } + + @NotNull + public static String encodeReady(@NotNull DDReady ready) { + JsonObject payload = new JsonObject(); + payload.addProperty(FIELD_CONNECTION_ID, ready.connectionId()); + return envelope(TYPE_READY, payload).toString(); + } + + @NotNull + public static DDReady decodeReady(@NotNull String message) { + return new DDReady(readString(readControlPayload(message, TYPE_READY), FIELD_CONNECTION_ID)); + } + + @NotNull + private static JsonObject envelope(@NotNull String type, @NotNull JsonObject payload) { + JsonObject envelope = new JsonObject(); + envelope.addProperty(FIELD_TYPE, type); + envelope.add(FIELD_PAYLOAD, payload); + return envelope; + } + + @NotNull + private static JsonObject parseEnvelope(@NotNull String message) { + try { + JsonElement parsed = JsonParser.parseString(message); + if (!parsed.isJsonObject()) { + throw new IllegalArgumentException("Expected a message object"); + } + return parsed.getAsJsonObject(); + } catch (JsonParseException e) { + throw new IllegalArgumentException("Invalid message JSON", e); + } + } + + @NotNull + private static JsonObject readControlPayload(@NotNull String message, @NotNull String expectedType) { + JsonObject envelope = parseEnvelope(message); + if (!expectedType.equals(readString(envelope, FIELD_TYPE))) { + throw new IllegalArgumentException("Expected a " + expectedType + " message"); + } + return readPayload(envelope); + } + + @NotNull + private static JsonObject readPayload(@NotNull JsonObject envelope) { + JsonElement payload = envelope.get(FIELD_PAYLOAD); + if (payload == null || !payload.isJsonObject()) { + throw new IllegalArgumentException("Expected a payload object"); + } + return payload.getAsJsonObject(); + } + + @NotNull + private static String readString(@NotNull JsonObject object, @NotNull String field) { + JsonElement value = object.get(field); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() + || value.getAsString().isBlank()) { + throw new IllegalArgumentException("Expected a non-blank string for " + field); + } + return value.getAsString(); + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java new file mode 100644 index 0000000..df9fd2a --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java @@ -0,0 +1,31 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.model; + +import org.jkiss.code.NotNull; + +public record DDConnectionInfo( + @NotNull String deploymentId, + @NotNull String applicationRunId, + @NotNull String connectionId +) { + public DDConnectionInfo { + DDMessage.requireText(deploymentId, "deploymentId"); + DDMessage.requireText(applicationRunId, "applicationRunId"); + DDMessage.requireText(connectionId, "connectionId"); + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java new file mode 100644 index 0000000..1bace8f --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java @@ -0,0 +1,33 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.model; + +import org.jkiss.code.NotNull; + +public record DDHello( + @NotNull String deploymentId, + @NotNull String applicationRunId, + @NotNull String product, + @NotNull String version +) { + public DDHello { + DDMessage.requireText(deploymentId, "deploymentId"); + DDMessage.requireText(applicationRunId, "applicationRunId"); + DDMessage.requireText(product, "product"); + DDMessage.requireText(version, "version"); + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java new file mode 100644 index 0000000..e62ae0a --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java @@ -0,0 +1,47 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.model; + +import com.google.gson.JsonObject; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.Objects; + +public record DDMessage( + @NotNull String topic, + @NotNull String type, + @NotNull JsonObject payload +) { + public DDMessage { + requireText(topic, "topic"); + requireText(type, "type"); + payload = Objects.requireNonNull(payload, "payload").deepCopy(); + } + + @Override + @NotNull + public JsonObject payload() { + return payload.deepCopy(); + } + + static void requireText(@Nullable String value, @NotNull String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing " + name); + } + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java new file mode 100644 index 0000000..cc75713 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java @@ -0,0 +1,25 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.model; + +import org.jkiss.code.NotNull; + +public record DDReady(@NotNull String connectionId) { + public DDReady { + DDMessage.requireText(connectionId, "connectionId"); + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java new file mode 100644 index 0000000..d9c71a1 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java @@ -0,0 +1,26 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.transport; + +import com.dbeaver.datadam.external.api.model.DDConnectionInfo; +import org.jkiss.code.NotNull; + +public interface DDConnectionListener { + void onConnected(@NotNull DDConnectionInfo connection); + + void onDisconnected(@NotNull DDConnectionInfo connection); +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java new file mode 100644 index 0000000..a247d8c --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java @@ -0,0 +1,36 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.transport; + +import com.dbeaver.datadam.external.api.model.DDMessage; +import org.jkiss.code.NotNull; + +import java.util.concurrent.CompletionStage; + +public interface DDMessageChannel extends AutoCloseable { + @NotNull + CompletionStage send(@NotNull DDMessage message); + + @NotNull + DDSubscription subscribe(@NotNull String topic, @NotNull DDMessageListener listener); + + @NotNull + DDSubscription addConnectionListener(@NotNull DDConnectionListener listener); + + @Override + void close(); +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java new file mode 100644 index 0000000..c458c40 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java @@ -0,0 +1,26 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.transport; + +import com.dbeaver.datadam.external.api.model.DDConnectionInfo; +import com.dbeaver.datadam.external.api.model.DDMessage; +import org.jkiss.code.NotNull; + +@FunctionalInterface +public interface DDMessageListener { + void onMessage(@NotNull DDConnectionInfo connection, @NotNull DDMessage message); +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java new file mode 100644 index 0000000..880435c --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java @@ -0,0 +1,23 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.transport; + +@FunctionalInterface +public interface DDSubscription extends AutoCloseable { + @Override + void close(); +} diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java new file mode 100644 index 0000000..c960fd0 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java @@ -0,0 +1,159 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api; + +import com.dbeaver.datadam.external.api.model.DDHello; +import com.dbeaver.datadam.external.api.model.DDMessage; +import com.dbeaver.datadam.external.api.model.DDReady; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DDExternalDamProtocolTest { + @Test + void roundTripsApplicationMessagesWithTopicTypeAndPayload() { + JsonObject payload = new JsonObject(); + payload.addProperty("projectId", "project-1"); + DDMessage message = new DDMessage("projects", "project.updated", payload); + String encoded = DDExternalDamProtocol.encode(message); + + assertEquals(JsonParser.parseString(""" + {"topic":"projects","type":"project.updated","payload":{"projectId":"project-1"}} + """), JsonParser.parseString(encoded)); + assertEquals(message, DDExternalDamProtocol.decode(encoded)); + } + + @Test + void roundTripsHelloWithoutAccountCredentialsOrProtocolVersion() { + DDHello hello = new DDHello("DI_desktop", "run-1", "DBeaver", "26.2.4"); + String encoded = DDExternalDamProtocol.encodeHello(hello); + JsonObject envelope = JsonParser.parseString(encoded).getAsJsonObject(); + + assertEquals(hello, DDExternalDamProtocol.decodeHello(encoded)); + assertEquals("hello", envelope.get("type").getAsString()); + assertEquals(2, envelope.size()); + assertEquals(4, envelope.getAsJsonObject("payload").size()); + assertFalse(encoded.contains("accountId")); + assertFalse(encoded.contains("accessToken")); + assertFalse(encoded.contains("protocolVersion")); + } + + @Test + void roundTripsReadyWithOnlyTheAssignedConnectionId() { + DDReady ready = new DDReady("connection-1"); + String encoded = DDExternalDamProtocol.encodeReady(ready); + JsonObject envelope = JsonParser.parseString(encoded).getAsJsonObject(); + + assertEquals(ready, DDExternalDamProtocol.decodeReady(encoded)); + assertEquals("ready", envelope.get("type").getAsString()); + assertEquals(1, envelope.getAsJsonObject("payload").size()); + } + + @Test + void keepsControlMessagesSeparateFromTopicMessages() { + String hello = DDExternalDamProtocol.encodeHello(new DDHello("DI_desktop", "run-1", "DBeaver", "26.2.4")); + String ready = DDExternalDamProtocol.encodeReady(new DDReady("connection-1")); + String event = """ + {"topic":"projects","type":"project.updated","payload":{}} + """; + + assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decode(hello)); + assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decode(ready)); + assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeHello(ready)); + assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeReady(hello)); + assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeHello(event)); + assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeReady(event)); + } + + @Test + void preservesUnknownApplicationTypesAndAdditionalEnvelopeFields() { + DDMessage message = DDExternalDamProtocol.decode(""" + {"topic":"future","type":"future.event","payload":{"value":42},"additional":true} + """); + + assertEquals("future", message.topic()); + assertEquals("future.event", message.type()); + assertEquals(42, message.payload().get("value").getAsInt()); + } + + @Test + void rejectsMalformedEnvelopesAndMissingFields() { + for (String message : List.of( + "", "null", "[]", "true", "42", "{", "{}", + "{\"topic\":\"projects\",\"type\":\"updated\"}", + "{\"topic\":\"projects\",\"payload\":{}}", + "{\"type\":\"updated\",\"payload\":{}}" + )) { + assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decode(message), message); + } + } + + @Test + void rejectsNonStringAndBlankTopicAndTypeFields() { + for (String field : List.of("topic", "type")) { + for (String value : List.of("null", "true", "42", "[]", "{}", "\"\"", "\" \"")) { + JsonObject envelope = JsonParser.parseString(""" + {"topic":"projects","type":"updated","payload":{}} + """).getAsJsonObject(); + envelope.add(field, JsonParser.parseString(value)); + + assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decode(envelope.toString())); + } + } + } + + @Test + void rejectsNonObjectPayloads() { + for (String payload : List.of("null", "true", "42", "[]", "\"value\"")) { + String message = "{\"topic\":\"projects\",\"type\":\"updated\",\"payload\":" + payload + "}"; + + assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decode(message)); + } + } + + @Test + void rejectsMissingAndIncorrectlyTypedHelloFields() { + String valid = DDExternalDamProtocol.encodeHello(new DDHello("DI_desktop", "run-1", "DBeaver", "26.2.4")); + for (String field : List.of("deploymentId", "applicationRunId", "product", "version")) { + JsonObject missing = JsonParser.parseString(valid).getAsJsonObject(); + missing.getAsJsonObject("payload").remove(field); + assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeHello(missing.toString())); + + for (String value : List.of("null", "true", "42", "[]", "{}", "\" \"")) { + JsonObject invalid = JsonParser.parseString(valid).getAsJsonObject(); + invalid.getAsJsonObject("payload").add(field, JsonParser.parseString(value)); + + assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeHello(invalid.toString())); + } + } + } + + @Test + void rejectsReadyWithoutAStringConnectionId() { + for (String payload : List.of("{}", "{\"connectionId\":null}", "{\"connectionId\":42}", "{\"connectionId\":\" \"}")) { + String message = "{\"type\":\"ready\",\"payload\":" + payload + "}"; + + assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeReady(message)); + } + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java new file mode 100644 index 0000000..d25d5a6 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java @@ -0,0 +1,62 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.model; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DDMessageTest { + @Test + void isolatesThePayloadFromChangesToTheOriginalObject() { + JsonObject project = new JsonObject(); + project.addProperty("name", "original"); + JsonObject payload = new JsonObject(); + payload.add("project", project); + DDMessage message = new DDMessage("projects", "updated", payload); + + project.addProperty("name", "changed"); + + assertEquals("original", message.payload().getAsJsonObject("project").get("name").getAsString()); + } + + @Test + void isolatesListenersFromEachOthersPayloadChanges() { + JsonObject payload = new JsonObject(); + payload.addProperty("projectId", "project-1"); + DDMessage message = new DDMessage("projects", "updated", payload); + + message.payload().addProperty("projectId", "changed"); + + assertEquals("project-1", message.payload().get("projectId").getAsString()); + } + + @Test + void rejectsBlankRoutingFields() { + assertThrows(IllegalArgumentException.class, () -> new DDMessage(" ", "updated", new JsonObject())); + assertThrows(IllegalArgumentException.class, () -> new DDMessage("projects", " ", new JsonObject())); + } + + @Test + void rejectsMissingConnectionIdentifiers() { + assertThrows(IllegalArgumentException.class, () -> new DDConnectionInfo(" ", "run-1", "connection-1")); + assertThrows(IllegalArgumentException.class, () -> new DDConnectionInfo("DI_desktop", " ", "connection-1")); + assertThrows(IllegalArgumentException.class, () -> new DDConnectionInfo("DI_desktop", "run-1", " ")); + } +} diff --git a/apis/pom.xml b/apis/pom.xml index d559603..8ddcc48 100644 --- a/apis/pom.xml +++ b/apis/pom.xml @@ -20,6 +20,7 @@ com.dbeaver.datadam.dm.api + com.dbeaver.datadam.external.api com.dbeaver.datadam.share.api com.dbeaver.datadam.lm.api com.dbeaver.datadam.sso.api From 1a15585526e61e90476a671b684730e1d9c122d7 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Mon, 19 Oct 2026 17:54:07 +0400 Subject: [PATCH 02/30] dbeaver/pro#11108 Add service-authenticated External DAM GraphQL client --- .../META-INF/MANIFEST.MF | 5 +- .../README.md | 73 +++ apis/com.dbeaver.datadam.external.api/pom.xml | 5 + .../external/api/DDExternalDamApi.java | 34 ++ .../api/client/DDExternalDamClient.java | 189 ++++++ .../api/exception/DDExternalDamException.java | 30 + .../api/model/DDExternalDamConnection.java | 35 ++ .../external/api/DDExternalDamApiTest.java | 48 ++ .../api/client/DDExternalDamClientTest.java | 536 ++++++++++++++++++ .../model/DDExternalDamConnectionTest.java | 47 ++ 10 files changed, 1001 insertions(+), 1 deletion(-) create mode 100644 apis/com.dbeaver.datadam.external.api/README.md create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java diff --git a/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF b/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF index fb80797..f6737f6 100644 --- a/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF +++ b/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF @@ -6,9 +6,12 @@ Bundle-SymbolicName: com.dbeaver.datadam.external.api Bundle-Version: 0.0.17.qualifier Bundle-RequiredExecutionEnvironment: JavaSE-21 Export-Package: com.dbeaver.datadam.external.api, + com.dbeaver.datadam.external.api.client, + com.dbeaver.datadam.external.api.exception, com.dbeaver.datadam.external.api.model, com.dbeaver.datadam.external.api.transport Bundle-ActivationPolicy: lazy -Require-Bundle: org.jkiss.utils +Require-Bundle: org.jkiss.utils, + com.dbeaver.rest.client Import-Package: com.google.gson Automatic-Module-Name: com.dbeaver.datadam.external.api diff --git a/apis/com.dbeaver.datadam.external.api/README.md b/apis/com.dbeaver.datadam.external.api/README.md new file mode 100644 index 0000000..05a5342 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/README.md @@ -0,0 +1,73 @@ +# External DAM API + +This module contains the Desktop WebSocket contracts and a backend-only client for the External DAM internal GraphQL API. +Neither the models nor the WebSocket protocol depend on the HTTP client, Spring, or Eclipse runtime services. + +## Internal GraphQL contract + +The backend client posts JSON requests to `/internal/graphql` on the configured External DAM service root URL. +The service implementation must expose the following contract; adding the client alone does not deploy the endpoint. + +```graphql +scalar JSON + +type Query { + externalDamConnections(accountId: ID!): [ExternalDamConnection!]! +} + +type Mutation { + sendExternalDamMessage(accountId: ID!, connectionId: ID!, message: ExternalDamMessageInput!): Boolean! +} + +type ExternalDamConnection { + deploymentId: String! + applicationRunId: String! + connectionId: ID! + product: String! + version: String! +} + +input ExternalDamMessageInput { + topic: String! + type: String! + payload: JSON! +} +``` + +- `accountId` is a UUID and is mandatory for both operations. Connection lookup and sending must use the account and connection ID together. +- Connection queries return an immutable client-side snapshot of currently ready sockets, not persisted DAM installations or history. +- Concurrent sockets can share a Deployment ID and application run ID. Consumers must not deduplicate sockets using either ID. +- Product and version are Desktop-supplied presentation metadata, not authenticated identity or authorization claims. +- Message payloads must be JSON objects. They are passed through GraphQL variables, not interpolated into query text or encoded as JSON strings. +- A send result of `true` means the server completed its WebSocket write. It does not confirm that Desktop received or processed the event. +- A result of `false` means that no ready connection exists for that account and connection ID. This also covers a connection belonging to another account. +- A connection closing after lookup, queue rejection, an invalid message, or a write failure produces a GraphQL error rather than a successful send. +- HTTP errors, GraphQL errors (including errors with partial data), and malformed responses become `DDExternalDamException`. +- The client does not retry mutations or queue messages offline. After a timeout or network failure, delivery may be unknown; callers must not blindly replay non-idempotent events. +- Connections live in one External DAM process. Until owner-aware routing exists, calls must reach the process owning the socket; a missing result from another replica does not prove global offline status. + +## Authentication and configuration + +`DDExternalDamClient` takes a service root URL and the External DAM service secret. It never logs in or acquires a Desktop token. +The URL must use HTTP or HTTPS, have no credentials, query, fragment, or non-root path, and use a valid port. +HTTP is supported for trusted private service networks; use HTTPS across untrusted networks. + +The secret must contain 32–512 printable, non-space ASCII characters. It is sent only in the `X-DD-Service-Token` header, +never in the URL or GraphQL variables. Redirects are disabled to avoid forwarding credentials to another endpoint. +Connection establishment uses the shared REST client's 5-second default connect timeout; responses have a 10-second deadline. +HTTP error bodies, GraphQL error details, and malformed response contents are not copied into client exceptions. + +The service secret authenticates a trusted backend, not an end user. A backend exposing these operations to users must +derive `accountId` from its authorized account context rather than trusting an arbitrary account ID supplied by the caller. +The server must validate the service secret before executing either GraphQL operation and fail closed when its secret +is missing or invalid. A browser session or Desktop JWT must not grant access to this endpoint. +The secret belongs to backend runtime configuration only and must never be distributed to Desktop or browser clients. +It is separate from the Account backchannel secret used to verify Desktop JWTs. + +```java +var client = new DDExternalDamClient(serviceUrl, serviceSecret); +var connections = client.getConnections(accountId); +boolean written = client.sendMessage(accountId, connectionId, message); +``` + +Use one configured client per service endpoint instead of constructing one per request. No deployment-specific URL or secret is packaged here. diff --git a/apis/com.dbeaver.datadam.external.api/pom.xml b/apis/com.dbeaver.datadam.external.api/pom.xml index d3d12d0..5bef173 100644 --- a/apis/com.dbeaver.datadam.external.api/pom.xml +++ b/apis/com.dbeaver.datadam.external.api/pom.xml @@ -23,6 +23,11 @@ com.google.code.gson gson + + com.dbeaver.common + com.dbeaver.rest.client + ${dbeaver.common.version} + org.junit.jupiter junit-jupiter-api diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java new file mode 100644 index 0000000..d6eaa08 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java @@ -0,0 +1,34 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api; + +import org.jkiss.code.Nullable; + +public final class DDExternalDamApi { + public static final String GRAPHQL_PATH = "/internal/graphql"; + public static final String HEADER_SERVICE_TOKEN = "X-DD-Service-Token"; + public static final int MIN_SERVICE_SECRET_LENGTH = 32; + public static final int MAX_SERVICE_SECRET_LENGTH = 512; + + private DDExternalDamApi() { + } + + public static boolean isValidServiceSecret(@Nullable String secret) { + return secret != null && secret.length() >= MIN_SERVICE_SECRET_LENGTH && secret.length() <= MAX_SERVICE_SECRET_LENGTH + && secret.chars().allMatch(value -> value >= 33 && value <= 126); + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java new file mode 100644 index 0000000..a9cef4a --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java @@ -0,0 +1,189 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.client; + +import com.dbeaver.datadam.external.api.DDExternalDamApi; +import com.dbeaver.datadam.external.api.exception.DDExternalDamException; +import com.dbeaver.datadam.external.api.model.DDExternalDamConnection; +import com.dbeaver.datadam.external.api.model.DDMessage; +import com.dbeaver.rest.client.AbstractRestClient; +import com.dbeaver.rest.client.MediaType; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.DBException; +import org.jkiss.utils.CommonUtils; +import org.jkiss.utils.HttpConstants; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +public final class DDExternalDamClient extends AbstractRestClient { + private static final int READ_TIMEOUT_MILLIS = 10_000; + private static final String CONNECTIONS_FIELD = "externalDamConnections"; + private static final String SEND_FIELD = "sendExternalDamMessage"; + private static final String CONNECTIONS_OPERATION = "ExternalDamConnections"; + private static final String SEND_OPERATION = "SendExternalDamMessage"; + private static final String CONNECTIONS_QUERY = """ + query ExternalDamConnections($accountId: ID!) { + externalDamConnections(accountId: $accountId) { + deploymentId applicationRunId connectionId product version + } + } + """; + private static final String SEND_MUTATION = """ + mutation SendExternalDamMessage($accountId: ID!, $connectionId: ID!, $message: ExternalDamMessageInput!) { + sendExternalDamMessage(accountId: $accountId, connectionId: $connectionId, message: $message) + } + """; + + private final String serviceSecret; + + public DDExternalDamClient(@NotNull String serviceUrl, @NotNull String serviceSecret) { + super(validateServiceUrl(serviceUrl), DEFAULT_CONNECT_TIMEOUT, READ_TIMEOUT_MILLIS, List.of(), HttpClient.Redirect.NEVER); + if (!DDExternalDamApi.isValidServiceSecret(serviceSecret)) { + throw new IllegalArgumentException("A valid External DAM service secret is required"); + } + this.serviceSecret = serviceSecret; + } + + @NotNull + public List getConnections(@NotNull UUID accountId) throws DDExternalDamException { + JsonElement result = request(CONNECTIONS_OPERATION, CONNECTIONS_QUERY, + Map.of("accountId", Objects.requireNonNull(accountId).toString()), CONNECTIONS_FIELD); + if (!result.isJsonArray()) { + throw invalidResponse(); + } + List connections = new ArrayList<>(); + for (JsonElement value : result.getAsJsonArray()) { + JsonObject connection = readObject(value); + connections.add(new DDExternalDamConnection( + readString(connection, "deploymentId"), readString(connection, "applicationRunId"), + readString(connection, "connectionId"), readString(connection, "product"), readString(connection, "version") + )); + } + return List.copyOf(connections); + } + + public boolean sendMessage(@NotNull UUID accountId, @NotNull String connectionId, @NotNull DDMessage message) + throws DDExternalDamException { + if (connectionId.isBlank()) { + throw new IllegalArgumentException("A connection ID is required"); + } + JsonElement result = request(SEND_OPERATION, SEND_MUTATION, Map.of( + "accountId", Objects.requireNonNull(accountId).toString(), "connectionId", connectionId, + "message", Objects.requireNonNull(message) + ), SEND_FIELD); + if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isBoolean()) { + throw invalidResponse(); + } + return result.getAsBoolean(); + } + + @NotNull + private JsonElement request( + @NotNull String operationName, + @NotNull String query, + @NotNull Map variables, + @NotNull String field + ) throws DDExternalDamException { + String response; + try { + HttpRequest.Builder request = HttpRequest.newBuilder( + buildUri(CommonUtils.removeLeadingSlash(DDExternalDamApi.GRAPHQL_PATH), Map.of())) + .header(DDExternalDamApi.HEADER_SERVICE_TOKEN, serviceSecret) + .header(HttpConstants.HEADER_CONTENT_TYPE, MediaType.JSON.toString()) + .header(HttpConstants.HEADER_ACCEPT, MediaType.JSON.toString()) + .header(HttpConstants.HEADER_CACHE_CONTROL, HttpConstants.CACHE_CONTROL_NO_STORE) + .POST(createBodyPublisher(Map.of("operationName", operationName, "query", query, "variables", variables), MediaType.JSON)); + response = execute(request, String.class); + } catch (DBException e) { + if (e instanceof DDExternalDamException failure) { + throw failure; + } + throw new DDExternalDamException("External DAM service request failed", e); + } + JsonObject envelope; + try { + envelope = readObject(JsonParser.parseString(response)); + } catch (JsonParseException e) { + throw invalidResponse(); + } + JsonElement errors = envelope.get("errors"); + if (errors != null && (!errors.isJsonArray() || !errors.getAsJsonArray().isEmpty())) { + throw new DDExternalDamException("External DAM GraphQL request failed"); + } + JsonElement result = readObject(envelope.get("data")).get(field); + if (result == null || result.isJsonNull()) { + throw invalidResponse(); + } + return result; + } + + @Override + @NotNull + protected DBException mapErrorResponse(int code, @NotNull String message, @NotNull URI uri) { + return new DDExternalDamException("External DAM request failed: HTTP " + code); + } + + @NotNull + private static JsonObject readObject(@Nullable JsonElement value) throws DDExternalDamException { + if (value == null || !value.isJsonObject()) { + throw invalidResponse(); + } + return value.getAsJsonObject(); + } + + @NotNull + private static String readString(@NotNull JsonObject object, @NotNull String field) throws DDExternalDamException { + JsonElement value = object.get(field); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank()) { + throw invalidResponse(); + } + return value.getAsString(); + } + + @NotNull + private static DDExternalDamException invalidResponse() { + return new DDExternalDamException("Invalid External DAM response"); + } + + @NotNull + private static String validateServiceUrl(@NotNull String serviceUrl) { + try { + URI uri = URI.create(serviceUrl); + if (uri.getHost() != null && uri.getUserInfo() == null && uri.getRawQuery() == null && uri.getRawFragment() == null + && ("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme())) + && (uri.getRawPath().isEmpty() || "/".equals(uri.getRawPath())) + && (uri.getPort() == -1 || uri.getPort() >= 1 && uri.getPort() <= 65535)) { + return uri.toString(); + } + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid External DAM service URL"); + } + throw new IllegalArgumentException("Invalid External DAM service URL"); + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java new file mode 100644 index 0000000..7642145 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java @@ -0,0 +1,30 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.exception; + +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.DBException; + +public class DDExternalDamException extends DBException { + public DDExternalDamException(@NotNull String message) { + super(message); + } + + public DDExternalDamException(@NotNull String message, @NotNull Throwable cause) { + super(message, cause); + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java new file mode 100644 index 0000000..d9f1ada --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java @@ -0,0 +1,35 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.model; + +import org.jkiss.code.NotNull; + +public record DDExternalDamConnection( + @NotNull String deploymentId, + @NotNull String applicationRunId, + @NotNull String connectionId, + @NotNull String product, + @NotNull String version +) { + public DDExternalDamConnection { + DDMessage.requireText(deploymentId, "deploymentId"); + DDMessage.requireText(applicationRunId, "applicationRunId"); + DDMessage.requireText(connectionId, "connectionId"); + DDMessage.requireText(product, "product"); + DDMessage.requireText(version, "version"); + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java new file mode 100644 index 0000000..6fe72b9 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java @@ -0,0 +1,48 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class DDExternalDamApiTest { + @Test + void acceptsPrintableAsciiSecretsAtBothLengthBoundaries() { + Assertions.assertTrue(DDExternalDamApi.isValidServiceSecret("!".repeat(32))); + Assertions.assertTrue(DDExternalDamApi.isValidServiceSecret("~".repeat(512))); + StringBuilder printable = new StringBuilder(); + for (char value = 33; value <= 126; value++) { + printable.append(value); + } + Assertions.assertTrue(DDExternalDamApi.isValidServiceSecret(printable.toString())); + } + + @Test + void rejectsMissingShortAndOversizedSecrets() { + Assertions.assertFalse(DDExternalDamApi.isValidServiceSecret(null)); + Assertions.assertFalse(DDExternalDamApi.isValidServiceSecret("")); + Assertions.assertFalse(DDExternalDamApi.isValidServiceSecret("x".repeat(31))); + Assertions.assertFalse(DDExternalDamApi.isValidServiceSecret("x".repeat(513))); + } + + @Test + void rejectsSpacesControlCharactersAndNonAsciiSecrets() { + for (String invalid : new String[] {" ", "\t", "\r", "\n", "\0", "\u007f", "\u0080", "é"}) { + Assertions.assertFalse(DDExternalDamApi.isValidServiceSecret("x".repeat(31) + invalid)); + } + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java new file mode 100644 index 0000000..8b2c624 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java @@ -0,0 +1,536 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.client; + +import com.dbeaver.datadam.external.api.DDExternalDamApi; +import com.dbeaver.datadam.external.api.exception.DDExternalDamException; +import com.dbeaver.datadam.external.api.model.DDExternalDamConnection; +import com.dbeaver.datadam.external.api.model.DDMessage; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.function.Executable; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(30) +class DDExternalDamClientTest { + private static final UUID ACCOUNT_ID = UUID.fromString("58c487c0-de34-4b45-83bb-705cb733cbcd"); + private static final UUID OTHER_ACCOUNT_ID = UUID.fromString("678d5732-708f-441e-bc59-719b92af1d64"); + private static final String SERVICE_SECRET = "external-dam-service-secret-2026-test"; + private static final String UPSTREAM_MARKER = "private-upstream-response-marker"; + private static final String CONNECTIONS_FIELD = "externalDamConnections"; + private static final String SEND_FIELD = "sendExternalDamMessage"; + private final AtomicReference responseJson = new AtomicReference<>("{}"); + private final AtomicInteger responseStatus = new AtomicInteger(200); + private final AtomicReference redirect = new AtomicReference<>(); + private final AtomicBoolean truncateResponse = new AtomicBoolean(); + private final AtomicReference latestRequest = new AtomicReference<>(); + private final AtomicInteger requestCount = new AtomicInteger(); + private final AtomicReference handlerFailure = new AtomicReference<>(); + private HttpServer server; + private DDExternalDamClient client; + + @BeforeEach + void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handleRequest); + server.start(); + client = new DDExternalDamClient(serviceUrl(), SERVICE_SECRET); + } + + @AfterEach + void stop() { + if (server != null) { + server.stop(0); + } + assertNull(handlerFailure.get()); + } + + @Test + void postsConnectionQueriesToTheInternalEndpointWithEitherRootUrlForm() throws DDExternalDamException { + respond(CONNECTIONS_FIELD, new JsonArray()); + for (String suffix : List.of("", "/")) { + var configured = new DDExternalDamClient(serviceUrl() + suffix, SERVICE_SECRET); + + assertEquals(List.of(), configured.getConnections(ACCOUNT_ID)); + + JsonObject body = posted(); + assertEquals(Set.of("operationName", "query", "variables"), body.keySet()); + assertEquals("ExternalDamConnections", body.get("operationName").getAsString()); + String query = body.get("query").getAsString().replaceAll("\\s+", " "); + assertTrue(query.contains("query ExternalDamConnections($accountId: ID!)")); + assertTrue(query.contains("externalDamConnections(accountId: $accountId)")); + for (String field : List.of("deploymentId", "applicationRunId", "connectionId", "product", "version")) { + assertTrue(query.contains(field), field); + } + assertEquals(Set.of("accountId"), body.getAsJsonObject("variables").keySet()); + assertEquals(ACCOUNT_ID.toString(), body.getAsJsonObject("variables").get("accountId").getAsString()); + assertFalse(query.contains(ACCOUNT_ID.toString())); + } + assertEquals(2, requestCount.get()); + } + + @Test + void preservesDistinctConcurrentConnectionsAndReturnsAnImmutableSnapshot() throws DDExternalDamException { + JsonArray connections = new JsonArray(); + connections.add(connection("connection-1")); + connections.add(connection("connection-2")); + respond(CONNECTIONS_FIELD, connections); + + List result = client.getConnections(ACCOUNT_ID); + + assertEquals(List.of( + new DDExternalDamConnection("DI_desktop", "run-1", "connection-1", "DBeaver", "26.2.4"), + new DDExternalDamConnection("DI_desktop", "run-1", "connection-2", "DBeaver", "26.2.4") + ), result); + assertThrows(UnsupportedOperationException.class, () -> result.add(result.get(0))); + assertThrows(UnsupportedOperationException.class, () -> result.set(0, result.get(1))); + assertThrows(UnsupportedOperationException.class, () -> result.remove(0)); + + respond(CONNECTIONS_FIELD, new JsonArray()); + List empty = client.getConnections(ACCOUNT_ID); + + assertEquals(List.of(), empty); + assertThrows(UnsupportedOperationException.class, () -> empty.add(result.get(0))); + assertEquals(2, result.size()); + assertEquals("connection-1", result.get(0).connectionId()); + assertEquals(2, requestCount.get()); + } + + @Test + void scopesBothOperationsToTheAccountOnEveryRequest() throws DDExternalDamException { + String connectionsQuery = null; + String sendQuery = null; + for (UUID accountId : List.of(ACCOUNT_ID, OTHER_ACCOUNT_ID)) { + respond(CONNECTIONS_FIELD, new JsonArray()); + assertEquals(List.of(), client.getConnections(accountId)); + JsonObject connectionsBody = posted(); + assertEquals(accountId.toString(), connectionsBody.getAsJsonObject("variables").get("accountId").getAsString()); + String currentConnectionsQuery = connectionsBody.get("query").getAsString(); + if (connectionsQuery != null) { + assertEquals(connectionsQuery, currentConnectionsQuery); + } + connectionsQuery = currentConnectionsQuery; + assertFalse(connectionsQuery.contains(accountId.toString())); + + respond(SEND_FIELD, JsonParser.parseString("true")); + assertTrue(client.sendMessage(accountId, "connection-1", message())); + JsonObject sendBody = posted(); + assertEquals(accountId.toString(), sendBody.getAsJsonObject("variables").get("accountId").getAsString()); + assertEquals("connection-1", sendBody.getAsJsonObject("variables").get("connectionId").getAsString()); + String currentSendQuery = sendBody.get("query").getAsString(); + if (sendQuery != null) { + assertEquals(sendQuery, currentSendQuery); + } + sendQuery = currentSendQuery; + assertFalse(sendQuery.contains(accountId.toString())); + } + assertEquals(4, requestCount.get()); + } + + @Test + void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDExternalDamException { + String connectionId = "connection-\"\\\n) { injectedConnection }"; + String topic = "topic-\"\\\n{ injectedTopic }"; + String type = "type-\"\\\n{ injectedType }"; + JsonObject nested = new JsonObject(); + nested.addProperty("name", "quoted \"value\""); + nested.addProperty("path", "C:\\temp\\file"); + JsonArray items = new JsonArray(); + items.add(nested); + items.add(true); + items.add(42); + items.add(JsonNull.INSTANCE); + JsonObject payload = new JsonObject(); + payload.add("items", items); + payload.addProperty("text", "Привет"); + DDMessage message = new DDMessage(topic, type, payload); + respond(SEND_FIELD, JsonParser.parseString("true")); + + assertTrue(client.sendMessage(ACCOUNT_ID, connectionId, message)); + + JsonObject body = posted(); + assertEquals(Set.of("operationName", "query", "variables"), body.keySet()); + assertEquals("SendExternalDamMessage", body.get("operationName").getAsString()); + JsonObject variables = body.getAsJsonObject("variables"); + assertEquals(Set.of("accountId", "connectionId", "message"), variables.keySet()); + assertEquals(ACCOUNT_ID.toString(), variables.get("accountId").getAsString()); + assertEquals(connectionId, variables.get("connectionId").getAsString()); + JsonObject input = variables.getAsJsonObject("message"); + assertEquals(Set.of("topic", "type", "payload"), input.keySet()); + assertEquals(topic, input.get("topic").getAsString()); + assertEquals(type, input.get("type").getAsString()); + assertEquals(payload, input.get("payload")); + assertTrue(input.get("payload").isJsonObject()); + String query = body.get("query").getAsString().replaceAll("\\s+", " "); + assertTrue(query.contains("mutation SendExternalDamMessage(")); + assertTrue(query.contains("$accountId: ID!")); + assertTrue(query.contains("$connectionId: ID!")); + assertTrue(query.contains("$message: ExternalDamMessageInput!")); + assertTrue(query.contains("sendExternalDamMessage(accountId: $accountId, connectionId: $connectionId, message: $message)")); + for (String value : List.of(ACCOUNT_ID.toString(), connectionId, topic, type, "injectedConnection", "injectedTopic", + "injectedType", "Привет", "C:\\temp\\file")) { + assertFalse(query.contains(value), value); + } + assertEquals(1, requestCount.get()); + } + + @Test + void returnsTheSendBooleanWithoutAdditionalRequests() throws DDExternalDamException { + for (boolean written : List.of(true, false)) { + respond(SEND_FIELD, JsonParser.parseString(Boolean.toString(written))); + int before = requestCount.get(); + + assertEquals(written, client.sendMessage(ACCOUNT_ID, "connection-1", message())); + + assertEquals(before + 1, requestCount.get()); + assertEquals("SendExternalDamMessage", posted().get("operationName").getAsString()); + } + } + + @Test + void rejectsTheWholeListWhenAnyRequiredConnectionFieldIsInvalid() { + for (String field : List.of("deploymentId", "applicationRunId", "connectionId", "product", "version")) { + JsonObject missing = connection("connection-2"); + missing.remove(field); + assertInvalidConnection(missing); + for (String value : List.of("null", "true", "42", "[]", "{}", "\"\"", "\" \\t\\n\"")) { + JsonObject invalid = connection("connection-2"); + invalid.add(field, JsonParser.parseString(value)); + assertInvalidConnection(invalid); + } + } + } + + @Test + void rejectsNonArrayConnectionResultsAndNonObjectEntries() { + for (String value : List.of("null", "true", "42", "\"connections\"", "{}", "[null]", "[true]", "[42]", "[\"connection\"]", "[[]]")) { + respond(CONNECTIONS_FIELD, JsonParser.parseString(value)); + + assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); + } + } + + @Test + void rejectsMalformedJsonRootsAndMissingDataForBothOperations() { + for (String body : List.of( + "", "{", "not-json", "null", "[]", "true", "42", "\"root\"", "{}", + "{\"data\":null}", "{\"data\":[]}", "{\"data\":true}", "{\"data\":{}}", + "{\"data\":{\"externalDamConnections\":null,\"sendExternalDamMessage\":null}}", + "{\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\":[" + )) { + responseJson.set(body); + + assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + } + } + + @Test + void rejectsMalformedErrorsAndGraphqlErrorsEvenAlongsideUsablePartialData() { + JsonObject upstreamError = new JsonObject(); + upstreamError.addProperty("message", UPSTREAM_MARKER + SERVICE_SECRET); + JsonArray errors = new JsonArray(); + errors.add(upstreamError); + for (JsonElement value : List.of( + JsonNull.INSTANCE, new JsonObject(), JsonParser.parseString("true"), JsonParser.parseString("42"), + JsonParser.parseString("\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\""), JsonParser.parseString("[null]"), errors + )) { + JsonObject envelope = successfulEnvelope(); + envelope.add("errors", value); + responseJson.set(envelope.toString()); + + assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + } + } + + @Test + void acceptsAnEmptyGraphqlErrorsArray() throws DDExternalDamException { + JsonObject envelope = successfulEnvelope(); + envelope.add("errors", new JsonArray()); + responseJson.set(envelope.toString()); + + assertEquals(List.of(), client.getConnections(ACCOUNT_ID)); + assertTrue(client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertEquals(2, requestCount.get()); + } + + @Test + void rejectsSendResultsThatAreNotJsonBooleans() { + for (String value : List.of("null", "\"true\"", "\"false\"", "0", "1", "{}", "[]")) { + respond(SEND_FIELD, JsonParser.parseString(value)); + + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + } + } + + @Test + void rejectsHttpFailuresWithoutExposingTheResponseOrRetrying() { + JsonObject envelope = successfulEnvelope(); + envelope.addProperty("message", UPSTREAM_MARKER + SERVICE_SECRET); + responseJson.set(envelope.toString()); + for (int status : List.of(204, 400, 401, 403, 404, 429, 500, 503)) { + responseStatus.set(status); + + assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + } + } + + @Test + void neverFollowsRedirectsOrForwardsTheServiceSecret() throws IOException { + var forwarded = new AtomicInteger(); + var forwardedSecret = new AtomicReference(); + HttpServer destination = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + destination.createContext("/", exchange -> { + forwarded.incrementAndGet(); + forwardedSecret.set(exchange.getRequestHeaders().getFirst(DDExternalDamApi.HEADER_SERVICE_TOKEN)); + try { + exchange.sendResponseHeaders(200, -1); + } catch (IOException e) { + handlerFailure.compareAndSet(null, e); + } finally { + exchange.close(); + } + }); + destination.start(); + try { + redirect.set("http://127.0.0.1:" + destination.getAddress().getPort() + "/destination"); + responseJson.set(successfulEnvelope().toString()); + for (int status : List.of(301, 302, 303, 307, 308)) { + responseStatus.set(status); + + assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertEquals(0, forwarded.get()); + assertNull(forwardedSecret.get()); + } + } finally { + destination.stop(0); + } + } + + @Test + void neverRetriesOrReplaysFailedMutationsAfterRecovery() throws DDExternalDamException { + responseJson.set("{\"errors\":[{\"message\":\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\"}]}"); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + + responseJson.set(successfulEnvelope().toString()); + responseStatus.set(503); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + + responseStatus.set(200); + truncateResponse.set(true); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + + truncateResponse.set(false); + assertTrue(client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertEquals(4, requestCount.get()); + } + + @Test + void rejectsInvalidServiceOriginsBeforeSendingAnyRequest() { + for (String url : List.of( + "", "/relative", "127.0.0.1", "//127.0.0.1", "http:///", "http://", + "ftp://127.0.0.1", "ws://127.0.0.1", "wss://127.0.0.1", + serviceUrl() + "/internal/graphql", serviceUrl() + "/ws", serviceUrl() + "/prefix/", serviceUrl() + "//", + serviceUrl() + "/%2f", serviceUrl() + "?secret=" + SERVICE_SECRET, serviceUrl() + "#fragment", + "http://user:" + SERVICE_SECRET + "@127.0.0.1", "http://127.0.0.1:0", "http://127.0.0.1:65536" + )) { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> new DDExternalDamClient(url, SERVICE_SECRET), url); + assertFalse(failure.toString().contains(SERVICE_SECRET)); + } + assertEquals(0, requestCount.get()); + } + + @Test + void acceptsHttpAndHttpsRootOriginsAndPrintableSecretBoundaries() { + for (String url : List.of(serviceUrl(), serviceUrl() + "/", "https://127.0.0.1", "https://127.0.0.1:8443/")) { + for (String secret : List.of("!".repeat(32), "~".repeat(512))) { + assertDoesNotThrow(() -> new DDExternalDamClient(url, secret)); + } + } + assertEquals(0, requestCount.get()); + } + + @Test + void rejectsInvalidSecretsAndBlankConnectionIdsBeforeSendingAnyRequest() { + for (String secret : List.of("", "s".repeat(31), "s".repeat(513), " ".repeat(32), + "s".repeat(31) + "\n", "s".repeat(31) + "\t", "s".repeat(31) + "\u007f", "s".repeat(31) + "é")) { + assertThrows(IllegalArgumentException.class, () -> new DDExternalDamClient(serviceUrl(), secret)); + } + for (String connectionId : List.of("", " ", "\t\n")) { + assertThrows(IllegalArgumentException.class, () -> client.sendMessage(ACCOUNT_ID, connectionId, message())); + } + assertEquals(0, requestCount.get()); + } + + private void handleRequest(@NotNull HttpExchange exchange) { + try { + Map> headers = new HashMap<>(); + exchange.getRequestHeaders().forEach((name, values) -> headers.put(name, List.copyOf(values))); + latestRequest.set(new CapturedRequest(exchange.getRequestMethod(), exchange.getRequestURI(), Map.copyOf(headers), + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8))); + requestCount.incrementAndGet(); + String location = redirect.get(); + if (location != null) { + exchange.getResponseHeaders().set("Location", location); + } + exchange.getResponseHeaders().set("Content-Type", "application/json"); + int status = responseStatus.get(); + if (status == 204) { + exchange.sendResponseHeaders(status, -1); + } else { + byte[] bytes = responseJson.get().getBytes(StandardCharsets.UTF_8); + boolean truncated = truncateResponse.get(); + if (truncated) { + exchange.getResponseHeaders().set("Connection", "close"); + } + exchange.sendResponseHeaders(status, bytes.length + (truncated ? 16 : 0)); + exchange.getResponseBody().write(bytes); + } + } catch (IOException e) { + handlerFailure.compareAndSet(null, e); + } finally { + exchange.close(); + } + } + + @NotNull + private String serviceUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + @NotNull + private JsonObject posted() { + CapturedRequest request = latestRequest.get(); + assertNotNull(request); + assertEquals("POST", request.method()); + assertEquals(DDExternalDamApi.GRAPHQL_PATH, request.uri().toString()); + assertEquals("application/json", request.header("Content-Type")); + assertEquals("application/json", request.header("Accept")); + assertEquals("no-store", request.header("Cache-Control")); + assertEquals(SERVICE_SECRET, request.header(DDExternalDamApi.HEADER_SERVICE_TOKEN)); + assertNull(request.header("Authorization")); + assertNull(request.header("Cookie")); + assertFalse(request.body().contains(SERVICE_SECRET)); + assertFalse(request.uri().toString().contains(SERVICE_SECRET)); + return JsonParser.parseString(request.body()).getAsJsonObject(); + } + + private void respond(@NotNull String field, @NotNull JsonElement value) { + JsonObject data = new JsonObject(); + data.add(field, value); + JsonObject envelope = new JsonObject(); + envelope.add("data", data); + responseJson.set(envelope.toString()); + } + + private void assertInvalidConnection(@NotNull JsonObject invalid) { + JsonArray connections = new JsonArray(); + connections.add(connection("connection-1")); + connections.add(invalid); + respond(CONNECTIONS_FIELD, connections); + assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); + } + + private void assertRequestFails(@NotNull Executable action) { + int before = requestCount.get(); + DDExternalDamException failure = assertThrows(DDExternalDamException.class, action); + assertEquals(before + 1, requestCount.get()); + StringWriter trace = new StringWriter(); + failure.printStackTrace(new PrintWriter(trace)); + assertFalse(trace.toString().contains(SERVICE_SECRET)); + assertFalse(trace.toString().contains(UPSTREAM_MARKER)); + } + + @NotNull + private static JsonObject connection(@NotNull String connectionId) { + JsonObject connection = new JsonObject(); + connection.addProperty("deploymentId", "DI_desktop"); + connection.addProperty("applicationRunId", "run-1"); + connection.addProperty("connectionId", connectionId); + connection.addProperty("product", "DBeaver"); + connection.addProperty("version", "26.2.4"); + return connection; + } + + @NotNull + private static JsonObject successfulEnvelope() { + JsonObject data = new JsonObject(); + data.add(CONNECTIONS_FIELD, new JsonArray()); + data.addProperty(SEND_FIELD, true); + JsonObject envelope = new JsonObject(); + envelope.add("data", data); + return envelope; + } + + @NotNull + private static DDMessage message() { + return new DDMessage("projects", "project.updated", new JsonObject()); + } + + private record CapturedRequest( + @NotNull String method, + @NotNull URI uri, + @NotNull Map> headers, + @NotNull String body + ) { + @Nullable + private String header(@NotNull String name) { + return headers.entrySet().stream() + .filter(entry -> name.equalsIgnoreCase(entry.getKey())) + .map(entry -> entry.getValue().get(0)) + .findFirst() + .orElse(null); + } + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java new file mode 100644 index 0000000..8c33a60 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java @@ -0,0 +1,47 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.model; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class DDExternalDamConnectionTest { + @Test + void preservesSeparateDeploymentRunAndSocketIdentities() { + DDExternalDamConnection connection = new DDExternalDamConnection("deployment", "run", "socket", "DBeaver", "26.2"); + + Assertions.assertEquals("deployment", connection.deploymentId()); + Assertions.assertEquals("run", connection.applicationRunId()); + Assertions.assertEquals("socket", connection.connectionId()); + Assertions.assertEquals("DBeaver", connection.product()); + Assertions.assertEquals("26.2", connection.version()); + Assertions.assertNotEquals(connection, new DDExternalDamConnection("deployment", "run", "another-socket", "DBeaver", "26.2")); + } + + @Test + void rejectsMissingOrBlankMetadataInEveryField() { + for (int field = 0; field < 5; field++) { + for (String invalid : new String[] {null, "", " ", "\t\n"}) { + String[] values = {"deployment", "run", "socket", "DBeaver", "26.2"}; + values[field] = invalid; + + Assertions.assertThrows(IllegalArgumentException.class, () -> + new DDExternalDamConnection(values[0], values[1], values[2], values[3], values[4])); + } + } + } +} From 2b04e9beca42abf6288743028d73dd03ae5b8ae3 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Tue, 20 Oct 2026 12:48:32 +0400 Subject: [PATCH 03/30] dbeaver/pro#11108 Fix External DAM API Checkstyle warnings --- .../external/api/client/DDExternalDamClient.java | 2 +- .../external/api/DDExternalDamProtocolTest.java | 6 +++--- .../external/api/client/DDExternalDamClientTest.java | 10 ++++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java index a9cef4a..8168268 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java @@ -89,7 +89,7 @@ public List getConnections(@NotNull UUID accountId) thr } public boolean sendMessage(@NotNull UUID accountId, @NotNull String connectionId, @NotNull DDMessage message) - throws DDExternalDamException { + throws DDExternalDamException { if (connectionId.isBlank()) { throw new IllegalArgumentException("A connection ID is required"); } diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java index c960fd0..21b78d8 100644 --- a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java @@ -73,14 +73,14 @@ void roundTripsReadyWithOnlyTheAssignedConnectionId() { void keepsControlMessagesSeparateFromTopicMessages() { String hello = DDExternalDamProtocol.encodeHello(new DDHello("DI_desktop", "run-1", "DBeaver", "26.2.4")); String ready = DDExternalDamProtocol.encodeReady(new DDReady("connection-1")); - String event = """ - {"topic":"projects","type":"project.updated","payload":{}} - """; assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decode(hello)); assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decode(ready)); assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeHello(ready)); assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeReady(hello)); + String event = """ + {"topic":"projects","type":"project.updated","payload":{}} + """; assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeHello(event)); assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeReady(event)); } diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java index 8b2c624..a58eb45 100644 --- a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java @@ -176,9 +176,6 @@ void scopesBothOperationsToTheAccountOnEveryRequest() throws DDExternalDamExcept @Test void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDExternalDamException { - String connectionId = "connection-\"\\\n) { injectedConnection }"; - String topic = "topic-\"\\\n{ injectedTopic }"; - String type = "type-\"\\\n{ injectedType }"; JsonObject nested = new JsonObject(); nested.addProperty("name", "quoted \"value\""); nested.addProperty("path", "C:\\temp\\file"); @@ -190,9 +187,12 @@ void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDE JsonObject payload = new JsonObject(); payload.add("items", items); payload.addProperty("text", "Привет"); + String topic = "topic-\"\\\n{ injectedTopic }"; + String type = "type-\"\\\n{ injectedType }"; DDMessage message = new DDMessage(topic, type, payload); respond(SEND_FIELD, JsonParser.parseString("true")); + String connectionId = "connection-\"\\\n) { injectedConnection }"; assertTrue(client.sendMessage(ACCOUNT_ID, connectionId, message)); JsonObject body = posted(); @@ -250,7 +250,9 @@ void rejectsTheWholeListWhenAnyRequiredConnectionFieldIsInvalid() { @Test void rejectsNonArrayConnectionResultsAndNonObjectEntries() { - for (String value : List.of("null", "true", "42", "\"connections\"", "{}", "[null]", "[true]", "[42]", "[\"connection\"]", "[[]]")) { + for (String value : List.of( + "null", "true", "42", "\"connections\"", "{}", "[null]", "[true]", "[42]", "[\"connection\"]", "[[]]" + )) { respond(CONNECTIONS_FIELD, JsonParser.parseString(value)); assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); From c5ec67df5ae226bed43bddb3dfc1bbaff1c96b23 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Tue, 20 Oct 2026 14:38:35 +0400 Subject: [PATCH 04/30] dbeaver/pro#11108 Fix External DAM protocol separation and OSGi visibility --- .../META-INF/MANIFEST.MF | 2 +- .../external/api/DDExternalDamProtocol.java | 2 +- .../api/DDExternalDamProtocolTest.java | 26 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF b/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF index f6737f6..9ed42f7 100644 --- a/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF +++ b/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF @@ -12,6 +12,6 @@ Export-Package: com.dbeaver.datadam.external.api, com.dbeaver.datadam.external.api.transport Bundle-ActivationPolicy: lazy Require-Bundle: org.jkiss.utils, - com.dbeaver.rest.client + com.dbeaver.rest.client;visibility:=reexport Import-Package: com.google.gson Automatic-Module-Name: com.dbeaver.datadam.external.api diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java index cf51a1d..c7b39c2 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java @@ -116,7 +116,7 @@ private static JsonObject parseEnvelope(@NotNull String message) { @NotNull private static JsonObject readControlPayload(@NotNull String message, @NotNull String expectedType) { JsonObject envelope = parseEnvelope(message); - if (!expectedType.equals(readString(envelope, FIELD_TYPE))) { + if (envelope.has(FIELD_TOPIC) || !expectedType.equals(readString(envelope, FIELD_TYPE))) { throw new IllegalArgumentException("Expected a " + expectedType + " message"); } return readPayload(envelope); diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java index 21b78d8..d588181 100644 --- a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java @@ -85,6 +85,32 @@ void keepsControlMessagesSeparateFromTopicMessages() { assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeReady(event)); } + @Test + void rejectsControlMessagesWithAnyTopicField() { + for (String control : List.of( + DDExternalDamProtocol.encodeHello(new DDHello("DI_desktop", "run-1", "DBeaver", "26.2.4")), + DDExternalDamProtocol.encodeReady(new DDReady("connection-1")) + )) { + for (String topic : List.of("\"projects\"", "null", "\"\"", "\" \"", "42", "true", "[]", "{}")) { + JsonObject envelope = JsonParser.parseString(control).getAsJsonObject(); + envelope.add("topic", JsonParser.parseString(topic)); + String message = envelope.toString(); + + assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeHello(message)); + assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeReady(message)); + } + } + } + + @Test + void allowsControlTypeNamesInApplicationMessages() { + for (String type : List.of(DDExternalDamProtocol.TYPE_HELLO, DDExternalDamProtocol.TYPE_READY)) { + DDMessage message = new DDMessage("projects", type, new JsonObject()); + + assertEquals(message, DDExternalDamProtocol.decode(DDExternalDamProtocol.encode(message))); + } + } + @Test void preservesUnknownApplicationTypesAndAdditionalEnvelopeFields() { DDMessage message = DDExternalDamProtocol.decode(""" From 34b34ea055684f894093b507e2d2d0481e48861f Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Wed, 21 Oct 2026 11:39:05 +0400 Subject: [PATCH 05/30] dbeaver/pro#11108 Add backend subscription clients and channel status API --- .../README.md | 46 +++- .../external/api/DDExternalDamApi.java | 1 + .../client/DDExternalDamCallbackClient.java | 73 +++++++ .../api/client/DDExternalDamClient.java | 127 +++++------ .../client/DDExternalDamGraphQlClient.java | 126 +++++++++++ .../api/transport/DDChannelState.java | 24 +++ .../api/transport/DDChannelStatus.java | 36 ++++ .../api/transport/DDMessageChannel.java | 7 + .../DDExternalDamCallbackClientTest.java | 199 ++++++++++++++++++ .../api/client/DDExternalDamClientTest.java | 65 ++++++ .../api/transport/DDChannelStatusTest.java | 43 ++++ 11 files changed, 667 insertions(+), 80 deletions(-) create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java diff --git a/apis/com.dbeaver.datadam.external.api/README.md b/apis/com.dbeaver.datadam.external.api/README.md index 05a5342..1436430 100644 --- a/apis/com.dbeaver.datadam.external.api/README.md +++ b/apis/com.dbeaver.datadam.external.api/README.md @@ -17,6 +17,8 @@ type Query { type Mutation { sendExternalDamMessage(accountId: ID!, connectionId: ID!, message: ExternalDamMessageInput!): Boolean! + registerExternalDamSubscription(input: ExternalDamSubscriptionInput!): Int! + unregisterExternalDamSubscription(subscriberId: String!, subscriptionId: ID!): Boolean! } type ExternalDamConnection { @@ -32,9 +34,18 @@ input ExternalDamMessageInput { type: String! payload: JSON! } + +input ExternalDamSubscriptionInput { + subscriberId: String! + subscriptionId: ID! + accountId: ID! + connectionId: ID + topic: String! +} ``` -- `accountId` is a UUID and is mandatory for both operations. Connection lookup and sending must use the account and connection ID together. +- `accountId` is a UUID and is mandatory for connection queries, sends, and subscription registration. + Connection lookup and sending must use the account and connection ID together. - Connection queries return an immutable client-side snapshot of currently ready sockets, not persisted DAM installations or history. - Concurrent sockets can share a Deployment ID and application run ID. Consumers must not deduplicate sockets using either ID. - Product and version are Desktop-supplied presentation metadata, not authenticated identity or authorization claims. @@ -59,7 +70,7 @@ HTTP error bodies, GraphQL error details, and malformed response contents are no The service secret authenticates a trusted backend, not an end user. A backend exposing these operations to users must derive `accountId` from its authorized account context rather than trusting an arbitrary account ID supplied by the caller. -The server must validate the service secret before executing either GraphQL operation and fail closed when its secret +The server must validate the service secret before executing internal GraphQL operations and fail closed when its secret is missing or invalid. A browser session or Desktop JWT must not grant access to this endpoint. The secret belongs to backend runtime configuration only and must never be distributed to Desktop or browser clients. It is separate from the Account backchannel secret used to verify Desktop JWTs. @@ -71,3 +82,34 @@ boolean written = client.sendMessage(accountId, connectionId, message); ``` Use one configured client per service endpoint instead of constructing one per request. No deployment-specific URL or secret is packaged here. + +## Backend callback subscriptions + +`registerSubscription(subscriberId, subscriptionId, accountId, connectionId, topic)` returns the lease in seconds. +The receiving process generates the UUID `subscriptionId`; repeated registration with the same scope renews it. +A null `connectionId` subscribes to the account's matching messages, including future Desktop connections. +A non-null connection ID filters one physical connection and does not follow a replacement socket. +The subscriber ID selects a trusted destination configured on External DAM; registration never supplies a callback URL. + +`DDExternalDamCallbackClient` delivers messages to the receiver's separately authenticated +`POST /internal/external-dam/graphql` using its independent callback secret. Successful acknowledgement extends the +lease. The acknowledgement does not describe local subscriber presence or business processing. Local cancellation +uses `unregisterSubscription`, including cleanup when the receiver no longer recognizes an incoming registration. +External DAM discards queued work on cancellation or expiry; an already claimed callback may finish. +Messages are not retried or replayed after callback failure. Callback queues and workers have configured bounds. + +Spring services use the shared `com.dbeaver.datadam.external.base` module from the DataDam server repository. +It supplies the callback endpoint, local dispatch, `DDSubscription.close()` cancellation, and automatic renewal +during idle periods. Select a scoped channel once with `subscriptions.forConnection(accountId, connectionId)` or +`subscriptions.forAccount(accountId)`, then call `channel.subscribe(topic, listener)` without repeating identifiers. +The low-level registration protocol retains account and connection scope to enforce account ownership. +Application handlers do not renew registrations themselves. Every callback destination must reach the exact +receiving process that owns the local registration. + +## Channel state + +`DDMessageChannel.getStatus()` returns an immutable `DDChannelStatus`: `DISCONNECTED`, `CONNECTING`, `CONNECTED`, +or `CLOSED`. Only `CONNECTED` contains connection information, and `status.isReady()` checks that state. +`observeState(observer)` delivers the current state and subsequent changes in order without a snapshot/subscription +race. Closing the returned subscription cancels only that observer. Initial notification can run synchronously; +subsequent callbacks run on the notifying thread. Existing `addConnectionListener` remains transitions-only. diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java index d6eaa08..f31f040 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java @@ -20,6 +20,7 @@ public final class DDExternalDamApi { public static final String GRAPHQL_PATH = "/internal/graphql"; + public static final String CALLBACK_GRAPHQL_PATH = "/internal/external-dam/graphql"; public static final String HEADER_SERVICE_TOKEN = "X-DD-Service-Token"; public static final int MIN_SERVICE_SECRET_LENGTH = 32; public static final int MAX_SERVICE_SECRET_LENGTH = 512; diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java new file mode 100644 index 0000000..a79b6de --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java @@ -0,0 +1,73 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.client; + +import com.dbeaver.datadam.external.api.DDExternalDamApi; +import com.dbeaver.datadam.external.api.exception.DDExternalDamException; +import com.dbeaver.datadam.external.api.model.DDConnectionInfo; +import com.dbeaver.datadam.external.api.model.DDMessage; +import com.google.gson.JsonElement; +import org.jkiss.code.NotNull; + +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +public final class DDExternalDamCallbackClient extends DDExternalDamGraphQlClient { + private static final String DELIVER_MUTATION = """ + mutation DeliverExternalDamMessage($subscriptionId: ID!, $accountId: ID!, + $connection: ExternalDamConnectionInfoInput!, $message: ExternalDamMessageInput!) { + deliverExternalDamMessage(subscriptionId: $subscriptionId, accountId: $accountId, connection: $connection, message: $message) + } + """; + + public DDExternalDamCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret) { + this(serviceUrl, serviceSecret, Duration.ofSeconds(5)); + } + + public DDExternalDamCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull Duration deliveryTimeout) { + super(serviceUrl, serviceSecret, DDExternalDamApi.CALLBACK_GRAPHQL_PATH, timeoutMillis(deliveryTimeout)); + } + + public void deliver( + @NotNull UUID subscriptionId, + @NotNull UUID accountId, + @NotNull DDConnectionInfo connection, + @NotNull DDMessage message + ) throws DDExternalDamException { + JsonElement result = request("DeliverExternalDamMessage", DELIVER_MUTATION, Map.of( + "subscriptionId", Objects.requireNonNull(subscriptionId).toString(), + "accountId", Objects.requireNonNull(accountId).toString(), + "connection", Objects.requireNonNull(connection), "message", Objects.requireNonNull(message) + ), "deliverExternalDamMessage"); + if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isBoolean()) { + throw invalidResponse(); + } + if (!result.getAsBoolean()) { + throw new DDExternalDamException("External DAM callback was not accepted"); + } + } + + private static int timeoutMillis(@NotNull Duration timeout) { + if (timeout.isNegative() || timeout.compareTo(Duration.ofMillis(1)) < 0 + || timeout.compareTo(Duration.ofMillis(Integer.MAX_VALUE)) > 0) { + throw new IllegalArgumentException("Invalid callback delivery timeout"); + } + return (int) timeout.toMillis(); + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java index 8168268..bd56152 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java @@ -20,28 +20,19 @@ import com.dbeaver.datadam.external.api.exception.DDExternalDamException; import com.dbeaver.datadam.external.api.model.DDExternalDamConnection; import com.dbeaver.datadam.external.api.model.DDMessage; -import com.dbeaver.rest.client.AbstractRestClient; -import com.dbeaver.rest.client.MediaType; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import com.google.gson.JsonParseException; -import com.google.gson.JsonParser; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; -import org.jkiss.dbeaver.DBException; -import org.jkiss.utils.CommonUtils; -import org.jkiss.utils.HttpConstants; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.UUID; -public final class DDExternalDamClient extends AbstractRestClient { +public final class DDExternalDamClient extends DDExternalDamGraphQlClient { private static final int READ_TIMEOUT_MILLIS = 10_000; private static final String CONNECTIONS_FIELD = "externalDamConnections"; private static final String SEND_FIELD = "sendExternalDamMessage"; @@ -59,15 +50,19 @@ mutation SendExternalDamMessage($accountId: ID!, $connectionId: ID!, $message: E sendExternalDamMessage(accountId: $accountId, connectionId: $connectionId, message: $message) } """; - - private final String serviceSecret; + private static final String REGISTER_MUTATION = """ + mutation RegisterExternalDamSubscription($input: ExternalDamSubscriptionInput!) { + registerExternalDamSubscription(input: $input) + } + """; + private static final String UNREGISTER_MUTATION = """ + mutation UnregisterExternalDamSubscription($subscriberId: String!, $subscriptionId: ID!) { + unregisterExternalDamSubscription(subscriberId: $subscriberId, subscriptionId: $subscriptionId) + } + """; public DDExternalDamClient(@NotNull String serviceUrl, @NotNull String serviceSecret) { - super(validateServiceUrl(serviceUrl), DEFAULT_CONNECT_TIMEOUT, READ_TIMEOUT_MILLIS, List.of(), HttpClient.Redirect.NEVER); - if (!DDExternalDamApi.isValidServiceSecret(serviceSecret)) { - throw new IllegalArgumentException("A valid External DAM service secret is required"); - } - this.serviceSecret = serviceSecret; + super(serviceUrl, serviceSecret, DDExternalDamApi.GRAPHQL_PATH, READ_TIMEOUT_MILLIS); } @NotNull @@ -103,58 +98,49 @@ public boolean sendMessage(@NotNull UUID accountId, @NotNull String connectionId return result.getAsBoolean(); } - @NotNull - private JsonElement request( - @NotNull String operationName, - @NotNull String query, - @NotNull Map variables, - @NotNull String field + public int registerSubscription( + @NotNull String subscriberId, + @NotNull UUID subscriptionId, + @NotNull UUID accountId, + @Nullable String connectionId, + @NotNull String topic ) throws DDExternalDamException { - String response; - try { - HttpRequest.Builder request = HttpRequest.newBuilder( - buildUri(CommonUtils.removeLeadingSlash(DDExternalDamApi.GRAPHQL_PATH), Map.of())) - .header(DDExternalDamApi.HEADER_SERVICE_TOKEN, serviceSecret) - .header(HttpConstants.HEADER_CONTENT_TYPE, MediaType.JSON.toString()) - .header(HttpConstants.HEADER_ACCEPT, MediaType.JSON.toString()) - .header(HttpConstants.HEADER_CACHE_CONTROL, HttpConstants.CACHE_CONTROL_NO_STORE) - .POST(createBodyPublisher(Map.of("operationName", operationName, "query", query, "variables", variables), MediaType.JSON)); - response = execute(request, String.class); - } catch (DBException e) { - if (e instanceof DDExternalDamException failure) { - throw failure; - } - throw new DDExternalDamException("External DAM service request failed", e); + requireText(subscriberId); + requireText(topic); + if (connectionId != null) { + requireText(connectionId); } - JsonObject envelope; - try { - envelope = readObject(JsonParser.parseString(response)); - } catch (JsonParseException e) { + Map input = new HashMap<>(); + input.put("subscriberId", subscriberId); + input.put("subscriptionId", Objects.requireNonNull(subscriptionId).toString()); + input.put("accountId", Objects.requireNonNull(accountId).toString()); + input.put("connectionId", connectionId); + input.put("topic", topic); + JsonElement result = request("RegisterExternalDamSubscription", REGISTER_MUTATION, + Map.of("input", input), "registerExternalDamSubscription"); + if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isNumber()) { throw invalidResponse(); } - JsonElement errors = envelope.get("errors"); - if (errors != null && (!errors.isJsonArray() || !errors.getAsJsonArray().isEmpty())) { - throw new DDExternalDamException("External DAM GraphQL request failed"); - } - JsonElement result = readObject(envelope.get("data")).get(field); - if (result == null || result.isJsonNull()) { + try { + int leaseSeconds = result.getAsBigDecimal().intValueExact(); + if (leaseSeconds >= 15 && leaseSeconds <= 300) { + return leaseSeconds; + } + } catch (ArithmeticException | NumberFormatException e) { throw invalidResponse(); } - return result; + throw invalidResponse(); } - @Override - @NotNull - protected DBException mapErrorResponse(int code, @NotNull String message, @NotNull URI uri) { - return new DDExternalDamException("External DAM request failed: HTTP " + code); - } - - @NotNull - private static JsonObject readObject(@Nullable JsonElement value) throws DDExternalDamException { - if (value == null || !value.isJsonObject()) { + public boolean unregisterSubscription(@NotNull String subscriberId, @NotNull UUID subscriptionId) throws DDExternalDamException { + requireText(subscriberId); + JsonElement result = request("UnregisterExternalDamSubscription", UNREGISTER_MUTATION, + Map.of("subscriberId", subscriberId, "subscriptionId", Objects.requireNonNull(subscriptionId).toString()), + "unregisterExternalDamSubscription"); + if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isBoolean()) { throw invalidResponse(); } - return value.getAsJsonObject(); + return result.getAsBoolean(); } @NotNull @@ -166,24 +152,9 @@ private static String readString(@NotNull JsonObject object, @NotNull String fie return value.getAsString(); } - @NotNull - private static DDExternalDamException invalidResponse() { - return new DDExternalDamException("Invalid External DAM response"); - } - - @NotNull - private static String validateServiceUrl(@NotNull String serviceUrl) { - try { - URI uri = URI.create(serviceUrl); - if (uri.getHost() != null && uri.getUserInfo() == null && uri.getRawQuery() == null && uri.getRawFragment() == null - && ("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme())) - && (uri.getRawPath().isEmpty() || "/".equals(uri.getRawPath())) - && (uri.getPort() == -1 || uri.getPort() >= 1 && uri.getPort() <= 65535)) { - return uri.toString(); - } - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Invalid External DAM service URL"); + private static void requireText(@NotNull String value) { + if (value.isBlank() || value.length() > 256) { + throw new IllegalArgumentException("Invalid subscription metadata"); } - throw new IllegalArgumentException("Invalid External DAM service URL"); } } diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java new file mode 100644 index 0000000..21633ac --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java @@ -0,0 +1,126 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.client; + +import com.dbeaver.datadam.external.api.DDExternalDamApi; +import com.dbeaver.datadam.external.api.exception.DDExternalDamException; +import com.dbeaver.rest.client.AbstractRestClient; +import com.dbeaver.rest.client.MediaType; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.DBException; +import org.jkiss.utils.CommonUtils; +import org.jkiss.utils.HttpConstants; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.util.List; +import java.util.Map; + +abstract class DDExternalDamGraphQlClient extends AbstractRestClient { + private final String serviceSecret; + private final String endpoint; + + DDExternalDamGraphQlClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull String endpoint, int timeoutMillis) { + super(validateServiceUrl(serviceUrl), Math.min(DEFAULT_CONNECT_TIMEOUT, timeoutMillis), timeoutMillis, + List.of(), HttpClient.Redirect.NEVER); + if (!DDExternalDamApi.isValidServiceSecret(serviceSecret)) { + throw new IllegalArgumentException("A valid External DAM service secret is required"); + } + this.serviceSecret = serviceSecret; + this.endpoint = endpoint; + } + + @NotNull + final JsonElement request( + @NotNull String operationName, + @NotNull String query, + @NotNull Map variables, + @NotNull String field + ) throws DDExternalDamException { + String response; + try { + HttpRequest.Builder request = HttpRequest.newBuilder(buildUri(CommonUtils.removeLeadingSlash(endpoint), Map.of())) + .header(DDExternalDamApi.HEADER_SERVICE_TOKEN, serviceSecret) + .header(HttpConstants.HEADER_CONTENT_TYPE, MediaType.JSON.toString()) + .header(HttpConstants.HEADER_ACCEPT, MediaType.JSON.toString()) + .header(HttpConstants.HEADER_CACHE_CONTROL, HttpConstants.CACHE_CONTROL_NO_STORE) + .POST(createBodyPublisher(Map.of("operationName", operationName, "query", query, "variables", variables), MediaType.JSON)); + response = execute(request, String.class); + } catch (DBException e) { + if (e instanceof DDExternalDamException failure) { + throw failure; + } + throw new DDExternalDamException("External DAM service request failed", e); + } + JsonObject envelope; + try { + envelope = readObject(JsonParser.parseString(response)); + } catch (JsonParseException e) { + throw invalidResponse(); + } + JsonElement errors = envelope.get("errors"); + if (errors != null && (!errors.isJsonArray() || !errors.getAsJsonArray().isEmpty())) { + throw new DDExternalDamException("External DAM GraphQL request failed"); + } + JsonElement result = readObject(envelope.get("data")).get(field); + if (result == null || result.isJsonNull()) { + throw invalidResponse(); + } + return result; + } + + @Override + @NotNull + protected DBException mapErrorResponse(int code, @NotNull String message, @NotNull URI uri) { + return new DDExternalDamException("External DAM request failed: HTTP " + code); + } + + @NotNull + static JsonObject readObject(@Nullable JsonElement value) throws DDExternalDamException { + if (value == null || !value.isJsonObject()) { + throw invalidResponse(); + } + return value.getAsJsonObject(); + } + + @NotNull + static DDExternalDamException invalidResponse() { + return new DDExternalDamException("Invalid External DAM response"); + } + + @NotNull + private static String validateServiceUrl(@NotNull String serviceUrl) { + try { + URI uri = URI.create(serviceUrl); + if (uri.getHost() != null && uri.getUserInfo() == null && uri.getRawQuery() == null && uri.getRawFragment() == null + && ("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme())) + && (uri.getRawPath().isEmpty() || "/".equals(uri.getRawPath())) + && (uri.getPort() == -1 || uri.getPort() >= 1 && uri.getPort() <= 65535)) { + return uri.toString(); + } + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid External DAM service URL"); + } + throw new IllegalArgumentException("Invalid External DAM service URL"); + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java new file mode 100644 index 0000000..cc5415b --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java @@ -0,0 +1,24 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.transport; + +public enum DDChannelState { + DISCONNECTED, + CONNECTING, + CONNECTED, + CLOSED +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java new file mode 100644 index 0000000..af3e089 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java @@ -0,0 +1,36 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.transport; + +import com.dbeaver.datadam.external.api.model.DDConnectionInfo; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.Objects; + +public record DDChannelStatus(@NotNull DDChannelState state, @Nullable DDConnectionInfo connection) { + public DDChannelStatus { + Objects.requireNonNull(state); + if ((state == DDChannelState.CONNECTED) != (connection != null)) { + throw new IllegalArgumentException("Only a connected channel has connection information"); + } + } + + public boolean isReady() { + return state == DDChannelState.CONNECTED; + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java index a247d8c..297cedc 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java @@ -20,8 +20,15 @@ import org.jkiss.code.NotNull; import java.util.concurrent.CompletionStage; +import java.util.function.Consumer; public interface DDMessageChannel extends AutoCloseable { + @NotNull + DDChannelStatus getStatus(); + + @NotNull + DDSubscription observeState(@NotNull Consumer observer); + @NotNull CompletionStage send(@NotNull DDMessage message); diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java new file mode 100644 index 0000000..85644c5 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java @@ -0,0 +1,199 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.client; + +import com.dbeaver.datadam.external.api.DDExternalDamApi; +import com.dbeaver.datadam.external.api.exception.DDExternalDamException; +import com.dbeaver.datadam.external.api.model.DDConnectionInfo; +import com.dbeaver.datadam.external.api.model.DDMessage; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(10) +class DDExternalDamCallbackClientTest { + private static final String SECRET = "external-dam-callback-secret-2026-test"; + private static final UUID SUBSCRIPTION_ID = UUID.randomUUID(); + private static final UUID ACCOUNT_ID = UUID.randomUUID(); + private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("deployment", "run", "socket"); + private static final DDMessage MESSAGE = new DDMessage("projects", "updated", + JsonParser.parseString("{\"nested\":{\"value\":42}}").getAsJsonObject()); + private final AtomicReference response = new AtomicReference<>("{\"data\":{\"deliverExternalDamMessage\":true}}"); + private final AtomicInteger status = new AtomicInteger(200); + private final AtomicInteger requestCount = new AtomicInteger(); + private final AtomicInteger responseDelay = new AtomicInteger(); + private final AtomicReference request = new AtomicReference<>(); + private HttpServer server; + private DDExternalDamCallbackClient client; + + @BeforeEach + void start() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::respond); + server.start(); + client = new DDExternalDamCallbackClient(url(), SECRET); + } + + @AfterEach + void stop() { + server.stop(0); + } + + @Test + void deliversTheExactCallbackWireWithIsolatedCredentialsAndAnOpaquePayload() throws DDExternalDamException { + for (String suffix : List.of("", "/")) { + client = new DDExternalDamCallbackClient(url() + suffix, SECRET); + deliver(); + CapturedRequest captured = request.get(); + assertEquals("POST", captured.method()); + assertEquals("/internal/external-dam/graphql", captured.path()); + assertEquals(SECRET, captured.secret()); + assertEquals("application/json", captured.contentType()); + assertEquals("no-store", captured.cacheControl()); + JsonObject body = captured.body(); + assertEquals("DeliverExternalDamMessage", body.get("operationName").getAsString()); + String query = body.get("query").getAsString().replaceAll("\\s+", " "); + assertTrue(query.contains("$connection: ExternalDamConnectionInfoInput!")); + assertTrue(query.contains("$message: ExternalDamMessageInput!")); + assertTrue(query.contains("deliverExternalDamMessage(subscriptionId: $subscriptionId, accountId: $accountId, " + + "connection: $connection, message: $message)")); + JsonObject variables = body.getAsJsonObject("variables"); + assertEquals(Set.of("subscriptionId", "accountId", "connection", "message"), variables.keySet()); + assertEquals(SUBSCRIPTION_ID.toString(), variables.get("subscriptionId").getAsString()); + assertEquals(ACCOUNT_ID.toString(), variables.get("accountId").getAsString()); + assertEquals(Set.of("deploymentId", "applicationRunId", "connectionId"), variables.getAsJsonObject("connection").keySet()); + assertEquals("socket", variables.getAsJsonObject("connection").get("connectionId").getAsString()); + assertEquals(MESSAGE.payload(), variables.getAsJsonObject("message").get("payload")); + assertFalse(body.toString().contains(SECRET)); + } + } + + @Test + void rejectsFalseAcknowledgementsAsProtocolFailures() { + response.set("{\"data\":{\"deliverExternalDamMessage\":false}}"); + assertFailureOnce(); + } + + @Test + void rejectsMalformedEnvelopesPartialErrorsAndNonBooleanResultsWithoutRetries() { + for (String body : List.of("", "{", "null", "[]", "{}", "{\"data\":null}", "{\"data\":{}}", + "{\"data\":{\"deliverExternalDamMessage\":null}}", "{\"data\":{\"deliverExternalDamMessage\":\"true\"}}", + "{\"data\":{\"deliverExternalDamMessage\":1}}", "{\"errors\":null}", + "{\"data\":{\"deliverExternalDamMessage\":true},\"errors\":[{\"message\":\"" + SECRET + "\"}]}")) { + response.set(body); + assertFailureOnce(); + } + } + + @Test + void rejectsHttpFailuresAndNeverFollowsRedirects() { + for (int code : List.of(301, 302, 303, 307, 308, 400, 401, 403, 429, 500, 503)) { + status.set(code); + assertFailureOnce(); + } + } + + @Test + void boundsDeliveryTimeAndDoesNotRetryTimedOutMessages() { + responseDelay.set(1000); + client = new DDExternalDamCallbackClient(url(), SECRET, Duration.ofMillis(250)); + assertFailureOnce(); + } + + @Test + void rejectsUntrustedOriginFormsAndInvalidSecretsBeforeNetworkIo() { + for (String origin : List.of("", "/relative", url() + "/prefix", url() + "?query=1", url() + "#fragment", + "http://user:password@127.0.0.1", "ftp://127.0.0.1", "http://127.0.0.1:65536")) { + assertThrows(IllegalArgumentException.class, () -> new DDExternalDamCallbackClient(origin, SECRET)); + } + for (String secret : List.of("", "short", " ".repeat(32), "x".repeat(31) + "\n")) { + assertThrows(IllegalArgumentException.class, () -> new DDExternalDamCallbackClient(url(), secret)); + } + assertThrows(IllegalArgumentException.class, () -> new DDExternalDamCallbackClient(url(), SECRET, Duration.ZERO)); + assertEquals(0, requestCount.get()); + } + + private void deliver() throws DDExternalDamException { + client.deliver(SUBSCRIPTION_ID, ACCOUNT_ID, CONNECTION, MESSAGE); + } + + private void assertFailureOnce() { + int before = requestCount.get(); + DDExternalDamException failure = assertThrows(DDExternalDamException.class, this::deliver); + assertFalse(failure.toString().contains(SECRET)); + assertEquals(before + 1, requestCount.get()); + } + + @NotNull + private String url() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private void respond(@NotNull HttpExchange exchange) throws IOException { + try { + request.set(new CapturedRequest(exchange.getRequestMethod(), exchange.getRequestURI().toString(), + exchange.getRequestHeaders().getFirst(DDExternalDamApi.HEADER_SERVICE_TOKEN), + exchange.getRequestHeaders().getFirst("Content-Type"), exchange.getRequestHeaders().getFirst("Cache-Control"), + JsonParser.parseString(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject())); + requestCount.incrementAndGet(); + if (responseDelay.get() > 0) { + Thread.sleep(responseDelay.get()); + } + byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.getResponseHeaders().set("Location", url() + "/redirected"); + exchange.sendResponseHeaders(status.get(), bytes.length); + exchange.getResponseBody().write(bytes); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + } + } + + private record CapturedRequest( + @NotNull String method, + @NotNull String path, + @Nullable String secret, + @Nullable String contentType, + @Nullable String cacheControl, + @NotNull JsonObject body + ) { + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java index a58eb45..a7a81c8 100644 --- a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java @@ -234,6 +234,71 @@ void returnsTheSendBooleanWithoutAdditionalRequests() throws DDExternalDamExcept } } + @Test + void registersStableSubscriptionIdsAsVariablesAndPreservesOptionalConnectionScope() throws DDExternalDamException { + UUID subscriptionId = UUID.randomUUID(); + for (String connectionId : List.of("", "socket")) { + respond("registerExternalDamSubscription", JsonParser.parseString("60")); + + assertEquals(60, client.registerSubscription("account", subscriptionId, ACCOUNT_ID, + connectionId.isEmpty() ? null : connectionId, "projects")); + + JsonObject body = posted(); + assertEquals("RegisterExternalDamSubscription", body.get("operationName").getAsString()); + String query = body.get("query").getAsString().replaceAll("\\s+", " "); + assertTrue(query.contains("$input: ExternalDamSubscriptionInput!")); + assertTrue(query.contains("registerExternalDamSubscription(input: $input)")); + JsonObject input = body.getAsJsonObject("variables").getAsJsonObject("input"); + assertEquals("account", input.get("subscriberId").getAsString()); + assertEquals(subscriptionId.toString(), input.get("subscriptionId").getAsString()); + assertEquals(ACCOUNT_ID.toString(), input.get("accountId").getAsString()); + assertEquals("projects", input.get("topic").getAsString()); + if (connectionId.isEmpty()) { + assertTrue(!input.has("connectionId") || input.get("connectionId").isJsonNull()); + } else { + assertEquals(connectionId, input.get("connectionId").getAsString()); + } + assertFalse(input.has("url")); + assertFalse(input.has("serviceSecret")); + assertFalse(query.contains(subscriptionId.toString())); + } + } + + @Test + void unregistersOnlyTheStableSubscriberAndSubscriptionId() throws DDExternalDamException { + UUID subscriptionId = UUID.randomUUID(); + for (boolean removed : List.of(true, false)) { + respond("unregisterExternalDamSubscription", JsonParser.parseString(Boolean.toString(removed))); + + assertEquals(removed, client.unregisterSubscription("storage", subscriptionId)); + + JsonObject body = posted(); + assertEquals("UnregisterExternalDamSubscription", body.get("operationName").getAsString()); + JsonObject variables = body.getAsJsonObject("variables"); + assertEquals(Set.of("subscriberId", "subscriptionId"), variables.keySet()); + assertEquals("storage", variables.get("subscriberId").getAsString()); + assertEquals(subscriptionId.toString(), variables.get("subscriptionId").getAsString()); + } + } + + @Test + void rejectsInvalidLeaseValuesAndMalformedSubscriptionEnvelopesWithoutRetrying() { + UUID subscriptionId = UUID.randomUUID(); + for (String value : List.of("null", "true", "\"60\"", "14", "301", "60.5", "1e100", "{}", "[]")) { + respond("registerExternalDamSubscription", JsonParser.parseString(value)); + assertRequestFails(() -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, "projects")); + } + for (String value : List.of("null", "\"true\"", "60", "{}", "[]")) { + respond("unregisterExternalDamSubscription", JsonParser.parseString(value)); + assertRequestFails(() -> client.unregisterSubscription("account", subscriptionId)); + } + for (String body : List.of("{", "null", "{}", "{\"data\":null}", "{\"errors\":[{\"message\":\"private\"}]}")) { + responseJson.set(body); + assertRequestFails(() -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, "projects")); + assertRequestFails(() -> client.unregisterSubscription("account", subscriptionId)); + } + } + @Test void rejectsTheWholeListWhenAnyRequiredConnectionFieldIsInvalid() { for (String field : List.of("deploymentId", "applicationRunId", "connectionId", "product", "version")) { diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java new file mode 100644 index 0000000..8df8ff4 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java @@ -0,0 +1,43 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.transport; + +import com.dbeaver.datadam.external.api.model.DDConnectionInfo; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class DDChannelStatusTest { + private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("deployment", "run", "socket"); + + @Test + void onlyConnectedStatusIsReadyAndCarriesConnectionInformation() { + for (DDChannelState state : DDChannelState.values()) { + boolean connected = state == DDChannelState.CONNECTED; + DDChannelStatus status = new DDChannelStatus(state, connected ? CONNECTION : null); + + Assertions.assertEquals(connected, status.isReady()); + Assertions.assertEquals(connected ? CONNECTION : null, status.connection()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new DDChannelStatus(state, connected ? null : CONNECTION)); + } + } + + @Test + void rejectsMissingState() { + Assertions.assertThrows(NullPointerException.class, () -> new DDChannelStatus(null, null)); + } +} From e39ab29b0704ef95b30d78f7e435bed7fe3a943a Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Wed, 21 Oct 2026 14:09:29 +0400 Subject: [PATCH 06/30] dbeaver/pro#11108 Add transport-independent request/reply messaging --- .../META-INF/MANIFEST.MF | 2 + .../README.md | 51 ++ apis/com.dbeaver.datadam.external.api/pom.xml | 5 + .../requestreply/DDRequestReplyTransport.java | 360 ++++++++ .../DDRequestReplyTransportTest.java | 508 +++++++++++ .../META-INF/MANIFEST.MF | 11 + apis/com.dbeaver.messaging/README.md | 87 ++ apis/com.dbeaver.messaging/build.properties | 4 + apis/com.dbeaver.messaging/pom.xml | 51 ++ .../requestreply/MessageSubscription.java | 23 + .../requestreply/RequestHandler.java | 26 + .../requestreply/RequestReplyEndpoint.java | 369 ++++++++ .../requestreply/RequestReplyException.java | 70 ++ .../requestreply/RequestReplyMessage.java | 72 ++ .../requestreply/RequestReplyOptions.java | 46 + .../requestreply/RequestReplyTransport.java | 36 + .../RequestReplyDeadlineTest.java | 139 +++ .../RequestReplyEndpointTest.java | 838 ++++++++++++++++++ .../requestreply/RequestReplyMessageTest.java | 124 +++ .../requestreply/RequestReplyOptionsTest.java | 65 ++ apis/pom.xml | 1 + 21 files changed, 2888 insertions(+) create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java create mode 100644 apis/com.dbeaver.messaging/META-INF/MANIFEST.MF create mode 100644 apis/com.dbeaver.messaging/README.md create mode 100644 apis/com.dbeaver.messaging/build.properties create mode 100644 apis/com.dbeaver.messaging/pom.xml create mode 100644 apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java create mode 100644 apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java create mode 100644 apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java create mode 100644 apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java create mode 100644 apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java create mode 100644 apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java create mode 100644 apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java create mode 100644 apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java create mode 100644 apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java create mode 100644 apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java create mode 100644 apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java diff --git a/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF b/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF index 9ed42f7..5ab6738 100644 --- a/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF +++ b/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF @@ -9,9 +9,11 @@ Export-Package: com.dbeaver.datadam.external.api, com.dbeaver.datadam.external.api.client, com.dbeaver.datadam.external.api.exception, com.dbeaver.datadam.external.api.model, + com.dbeaver.datadam.external.api.requestreply, com.dbeaver.datadam.external.api.transport Bundle-ActivationPolicy: lazy Require-Bundle: org.jkiss.utils, + com.dbeaver.messaging;visibility:=reexport, com.dbeaver.rest.client;visibility:=reexport Import-Package: com.google.gson Automatic-Module-Name: com.dbeaver.datadam.external.api diff --git a/apis/com.dbeaver.datadam.external.api/README.md b/apis/com.dbeaver.datadam.external.api/README.md index 1436430..81b92e5 100644 --- a/apis/com.dbeaver.datadam.external.api/README.md +++ b/apis/com.dbeaver.datadam.external.api/README.md @@ -113,3 +113,54 @@ or `CLOSED`. Only `CONNECTED` contains connection information, and `status.isRea `observeState(observer)` delivers the current state and subsequent changes in order without a snapshot/subscription race. Closing the returned subscription cancels only that observer. Initial notification can run synchronously; subsequent callbacks run on the notifying thread. Existing `addConnectionListener` remains transitions-only. + +## Request/reply + +The independent `com.dbeaver.messaging` module supplies `RequestReplyEndpoint

`. It knows nothing about DataDam +accounts, deployment IDs, GraphQL, or JSON. `DDRequestReplyTransport` adapts an existing scoped DataDam channel to +`RequestReplyEndpoint` without changing the channel's asynchronous message API. + +On Desktop, or inside External DAM over an existing physical channel: + +```java +var transport = DDRequestReplyTransport.forChannel(channel, "documents"); +try (var requests = RequestReplyEndpoint.open(transport)) { + var response = requests.request(payload); +} +``` + +Open a physical-channel endpoint after the channel becomes ready. Opening over a disconnected or closed physical +channel fails without retaining subscriptions. An already opened endpoint can stay installed across reconnects; +requests outstanding at disconnection fail and are not replayed. + +An Account, Storage, or other backend selects a physical connection once: + +```java +var transport = subscriptions.forConnection(accountId, connectionId).requestReply("documents"); +try (var requests = RequestReplyEndpoint.open(transport)) { + var response = requests.request(payload); +} +``` + +The peer registers `requests.handle(payload -> process(payload))` on its own endpoint. The request topic identifies +one logical receiving endpoint for the selected peer; use distinct topics for independently handled services. Each +endpoint establishes its private reply subscription before accepting calls, so replies return to the originating +backend process. Protocol envelopes and payloads remain JSON objects. A null response represents successful completion +without a result. Handler failures are returned as safe protocol errors, without application exception details. + +The bridge uses message type `datadam.requestreply.v1`. Its envelope contains `requestId`, `replyTo`, `type`, and either +`data` or `error`. `replyTo` is an endpoint UUID, not a backend address. Private reply topics use +`datadam.reply.v1..`, which keeps them within registration metadata limits even +for a maximum-length request topic. Closing the endpoint cancels both topic subscriptions. + +`request` blocks a worker or virtual thread. The request/reply result acknowledges remote handler completion; +ordinary channel sends and callback GraphQL acknowledgements retain their existing transport-only meaning. The backend +adapter uses bounded asynchronous sending around its blocking HTTP client. It keeps the existing in-memory callback +subscriptions and lease renewal, and exposes request/reply only for `forConnection`, with no implicit connection +selection from account-wide subscriptions. + +Closing the request/reply endpoint cancels its receiving subscriptions and owned work, without closing the shared +channel or subscription facade. Closing the subscription facade rejects new backend sends and prevents queued sends +from starting; an already claimed HTTP send can finish. Physical channels report disconnects immediately; backend +callbacks have no physical connection-state feed, so pending calls use their deadlines when the peer disappears. +Timeout or interruption can leave the remote outcome unknown. No request or response is automatically retried. diff --git a/apis/com.dbeaver.datadam.external.api/pom.xml b/apis/com.dbeaver.datadam.external.api/pom.xml index 5bef173..e08fd3f 100644 --- a/apis/com.dbeaver.datadam.external.api/pom.xml +++ b/apis/com.dbeaver.datadam.external.api/pom.xml @@ -15,6 +15,11 @@ DataDam External DAM API + + com.dbeaver.datadam + com.dbeaver.messaging + ${project.version} + com.dbeaver.common org.jkiss.utils diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java new file mode 100644 index 0000000..1be6b5e --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java @@ -0,0 +1,360 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.requestreply; + +import com.dbeaver.datadam.external.api.exception.DDExternalDamException; +import com.dbeaver.datadam.external.api.model.DDConnectionInfo; +import com.dbeaver.datadam.external.api.model.DDMessage; +import com.dbeaver.datadam.external.api.transport.DDChannelStatus; +import com.dbeaver.datadam.external.api.transport.DDMessageChannel; +import com.dbeaver.datadam.external.api.transport.DDMessageListener; +import com.dbeaver.datadam.external.api.transport.DDSubscription; +import com.dbeaver.messaging.requestreply.MessageSubscription; +import com.dbeaver.messaging.requestreply.RequestReplyException; +import com.dbeaver.messaging.requestreply.RequestReplyMessage; +import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Semaphore; + +public final class DDRequestReplyTransport implements RequestReplyTransport, AutoCloseable { + private static final String MESSAGE_TYPE = "datadam.requestreply.v1"; + private static final int MAX_PENDING_SENDS = 128; + private final Object lock = new Object(); + private final Semaphore sendBudget = new Semaphore(MAX_PENDING_SENDS); + private final String topic; + private final String replyPrefix; + private final Sender sender; + private final TopicSubscriber subscriber; + @Nullable + private final DDMessageChannel channel; + @Nullable + private Registration registration; + private boolean claimed; + + @FunctionalInterface + public interface Sender { + @NotNull + CompletionStage send(@NotNull DDMessage message); + } + + @FunctionalInterface + public interface TopicSubscriber { + @NotNull + DDSubscription subscribe(@NotNull String topic, @NotNull DDMessageListener listener) throws DDExternalDamException; + } + + public DDRequestReplyTransport(@NotNull String topic, @NotNull Sender sender, @NotNull TopicSubscriber subscriber) { + this(topic, sender, subscriber, null); + } + + private DDRequestReplyTransport( + @NotNull String topic, + @NotNull Sender sender, + @NotNull TopicSubscriber subscriber, + @Nullable DDMessageChannel channel + ) { + if (topic.isBlank() || topic.length() > 256) { + throw new IllegalArgumentException("Expected a nonblank request/reply topic of at most 256 characters"); + } + this.topic = topic; + this.replyPrefix = replyPrefix(topic); + this.sender = Objects.requireNonNull(sender); + this.subscriber = Objects.requireNonNull(subscriber); + this.channel = channel; + } + + @NotNull + public static DDRequestReplyTransport forChannel(@NotNull DDMessageChannel channel, @NotNull String topic) { + Objects.requireNonNull(channel); + return new DDRequestReplyTransport(topic, channel::send, channel::subscribe, channel); + } + + @Override + @NotNull + public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener listener) + throws RequestReplyException { + Registration opened; + synchronized (lock) { + if (claimed) { + throw new IllegalStateException("A request/reply adapter supports only one endpoint and cannot be reopened"); + } + claimed = true; + opened = new Registration(Objects.requireNonNull(endpointId), Objects.requireNonNull(listener)); + registration = opened; + } + try { + if (channel != null) { + opened.state(channel.getStatus()); + opened.requireReady(); + opened.add(channel.observeState(opened::state)); + } + opened.add(subscriber.subscribe(opened.replyTopic, (connection, message) -> opened.receive(connection, message, false))); + opened.add(subscriber.subscribe(topic, (connection, message) -> opened.receive(connection, message, true))); + if (channel != null) { + opened.state(channel.getStatus()); + } + synchronized (lock) { + opened.requireReady(); + opened.opening = false; + } + return opened; + } catch (DDExternalDamException | RequestReplyException | RuntimeException e) { + try { + opened.close(); + } catch (RuntimeException closeFailure) { + e.addSuppressed(closeFailure); + } + if (e instanceof RequestReplyException failure) { + throw failure; + } + throw new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, e); + } + } + + @Override + @NotNull + public CompletionStage send(@NotNull RequestReplyMessage message) { + synchronized (lock) { + if (registration == null || registration.closed) { + return CompletableFuture.failedFuture(new RequestReplyException(RequestReplyException.Reason.CLOSED)); + } + if (channel != null && !channel.getStatus().isReady()) { + return CompletableFuture.failedFuture(new RequestReplyException(RequestReplyException.Reason.DISCONNECTED)); + } + if (message.type() == RequestReplyMessage.Type.REQUEST && !registration.endpointId.equals(message.replyTo())) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Request origin does not match its endpoint")); + } + } + if (!sendBudget.tryAcquire()) { + return CompletableFuture.failedFuture(new RequestReplyException(RequestReplyException.Reason.OVERLOADED)); + } + try { + JsonObject envelope = new JsonObject(); + envelope.addProperty("requestId", message.requestId().toString()); + envelope.addProperty("replyTo", message.replyTo().toString()); + envelope.addProperty("type", message.type().name()); + if (message.type() == RequestReplyMessage.Type.ERROR) { + envelope.addProperty("error", Objects.requireNonNull(message.error()).name()); + } else { + envelope.add("data", message.payload() == null ? JsonNull.INSTANCE : message.payload()); + } + String target = message.type() == RequestReplyMessage.Type.REQUEST ? topic : replyPrefix + message.replyTo(); + CompletableFuture result = new CompletableFuture<>(); + Objects.requireNonNull(sender.send(new DDMessage(target, MESSAGE_TYPE, envelope))).whenComplete((ignored, error) -> { + sendBudget.release(); + if (error == null) { + result.complete(null); + } else { + result.completeExceptionally(error); + } + }); + return result; + } catch (RuntimeException | Error e) { + sendBudget.release(); + return CompletableFuture.failedFuture(e); + } + } + + @Override + public void close() { + Registration stopped; + synchronized (lock) { + claimed = true; + stopped = registration; + } + if (stopped != null) { + stopped.close(); + } + } + + @NotNull + private static String replyPrefix(@NotNull String topic) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(topic.getBytes(StandardCharsets.UTF_8)); + return "datadam.reply.v1." + HexFormat.of().formatHex(digest) + "."; + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + @Nullable + private static RequestReplyMessage decode(@NotNull DDMessage message) { + if (!MESSAGE_TYPE.equals(message.type())) { + return null; + } + try { + JsonObject envelope = message.payload(); + UUID requestId = uuid(envelope, "requestId"); + UUID replyTo = uuid(envelope, "replyTo"); + RequestReplyMessage.Type type = RequestReplyMessage.Type.valueOf(string(envelope, "type")); + JsonElement data = envelope.get("data"); + RequestReplyMessage.Error error = null; + JsonObject payload = null; + if (type == RequestReplyMessage.Type.ERROR) { + if (data != null && !data.isJsonNull()) { + return null; + } + error = RequestReplyMessage.Error.valueOf(string(envelope, "error")); + } else { + if (envelope.has("error") || data == null || !data.isJsonObject() && !data.isJsonNull() + || type == RequestReplyMessage.Type.REQUEST && data.isJsonNull()) { + return null; + } + payload = data.isJsonNull() ? null : data.getAsJsonObject(); + } + return new RequestReplyMessage<>(requestId, replyTo, type, payload, error); + } catch (IllegalArgumentException e) { + return null; + } + } + + @NotNull + private static String string(@NotNull JsonObject envelope, @NotNull String field) { + JsonElement value = envelope.get(field); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException("Invalid request/reply field"); + } + return value.getAsString(); + } + + @NotNull + private static UUID uuid(@NotNull JsonObject envelope, @NotNull String field) { + String value = string(envelope, field); + UUID id = UUID.fromString(value); + if (value.length() != 36 || !id.toString().equalsIgnoreCase(value)) { + throw new IllegalArgumentException("Invalid request/reply UUID"); + } + return id; + } + + private final class Registration implements MessageSubscription { + private final UUID endpointId; + private final String replyTopic; + private final Listener listener; + private final List subscriptions = new ArrayList<>(); + @Nullable + private DDChannelStatus status; + private boolean opening = true; + private boolean openingDisconnected; + private boolean closed; + + private Registration(@NotNull UUID endpointId, @NotNull Listener listener) { + this.endpointId = endpointId; + this.replyTopic = replyPrefix + endpointId; + this.listener = listener; + } + + private void add(@NotNull DDSubscription subscription) { + synchronized (lock) { + if (!closed) { + subscriptions.add(Objects.requireNonNull(subscription)); + return; + } + } + subscription.close(); + } + + private void requireReady() throws RequestReplyException { + synchronized (lock) { + if (closed) { + throw new RequestReplyException(RequestReplyException.Reason.CLOSED); + } + if (openingDisconnected || channel != null && (status == null || !status.isReady())) { + throw new RequestReplyException(RequestReplyException.Reason.DISCONNECTED); + } + } + } + + private void state(@NotNull DDChannelStatus next) { + boolean disconnected; + synchronized (lock) { + if (closed || next.equals(status)) { + return; + } + disconnected = !next.isReady() || status != null && status.isReady() + && !Objects.equals(status.connection(), next.connection()); + status = next; + openingDisconnected |= opening && disconnected; + } + if (disconnected) { + listener.onDisconnect(); + } + } + + private void receive(@NotNull DDConnectionInfo connection, @NotNull DDMessage message, boolean requestTopic) { + synchronized (lock) { + if (closed || !(requestTopic ? topic : replyTopic).equals(message.topic())) { + return; + } + if (channel != null) { + DDChannelStatus current = channel.getStatus(); + if (!current.isReady() || !connection.equals(current.connection())) { + return; + } + } + } + RequestReplyMessage frame = decode(message); + if (frame != null && (requestTopic ? frame.type() == RequestReplyMessage.Type.REQUEST + : frame.type() != RequestReplyMessage.Type.REQUEST && endpointId.equals(frame.replyTo()))) { + listener.onMessage(frame); + } + } + + @Override + public void close() { + List stopped; + synchronized (lock) { + if (closed) { + return; + } + closed = true; + stopped = List.copyOf(subscriptions); + subscriptions.clear(); + } + RuntimeException failure = null; + for (DDSubscription subscription : stopped.reversed()) { + try { + subscription.close(); + } catch (RuntimeException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + } + if (failure != null) { + throw failure; + } + } + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java new file mode 100644 index 0000000..82b10b4 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java @@ -0,0 +1,508 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.requestreply; + +import com.dbeaver.datadam.external.api.exception.DDExternalDamException; +import com.dbeaver.datadam.external.api.model.DDConnectionInfo; +import com.dbeaver.datadam.external.api.model.DDMessage; +import com.dbeaver.datadam.external.api.transport.DDChannelState; +import com.dbeaver.datadam.external.api.transport.DDChannelStatus; +import com.dbeaver.datadam.external.api.transport.DDConnectionListener; +import com.dbeaver.datadam.external.api.transport.DDMessageChannel; +import com.dbeaver.datadam.external.api.transport.DDMessageListener; +import com.dbeaver.datadam.external.api.transport.DDSubscription; +import com.dbeaver.messaging.requestreply.MessageSubscription; +import com.dbeaver.messaging.requestreply.RequestReplyEndpoint; +import com.dbeaver.messaging.requestreply.RequestReplyException; +import com.dbeaver.messaging.requestreply.RequestReplyMessage; +import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +@Timeout(10) +class DDRequestReplyTransportTest { + private static final String TOPIC = "projects"; + private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("desktop", "run", "socket"); + + @Test + void exchangesRequestsInBothDirectionsThroughExistingMessageChannels() throws Exception { + FakeChannel callerChannel = new FakeChannel(); + FakeChannel handlerChannel = new FakeChannel(); + callerChannel.forward = message -> handlerChannel.deliver(CONNECTION, message); + handlerChannel.forward = message -> callerChannel.deliver(CONNECTION, message); + JsonObject payload = new JsonObject(); + payload.addProperty("value", "request"); + try (var handler = RequestReplyEndpoint.open(DDRequestReplyTransport.forChannel(handlerChannel, TOPIC)); + var handlerRegistration = handler.handle(request -> { + request.addProperty("handled", true); + return request; + }); + var caller = RequestReplyEndpoint.open(DDRequestReplyTransport.forChannel(callerChannel, TOPIC)); + var callerRegistration = caller.handle(request -> null)) { + JsonObject response = caller.request(payload, Duration.ofSeconds(2)); + + Assertions.assertNotNull(response); + Assertions.assertEquals("request", response.get("value").getAsString()); + Assertions.assertTrue(response.get("handled").getAsBoolean()); + Assertions.assertFalse(payload.has("handled")); + Assertions.assertNull(handler.request(new JsonObject(), Duration.ofSeconds(2))); + } + Assertions.assertFalse(callerChannel.closed); + Assertions.assertFalse(handlerChannel.closed); + Assertions.assertTrue(callerChannel.observers.isEmpty()); + Assertions.assertTrue(handlerChannel.observers.isEmpty()); + Assertions.assertTrue(callerChannel.subscriptions.stream().noneMatch(subscription -> subscription.active)); + Assertions.assertTrue(handlerChannel.subscriptions.stream().noneMatch(subscription -> subscription.active)); + } + + @Test + void routesRequestsAndRepliesToUniqueEndpointTopics() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRequestReplyTransport first = DDRequestReplyTransport.forChannel(channel, TOPIC); + DDRequestReplyTransport second = DDRequestReplyTransport.forChannel(channel, TOPIC); + UUID firstId = UUID.randomUUID(); + UUID secondId = UUID.randomUUID(); + Received firstReceived = new Received(); + Received secondReceived = new Received(); + try (var firstSubscription = first.subscribe(firstId, firstReceived); + var secondSubscription = second.subscribe(secondId, secondReceived)) { + Assertions.assertEquals(4, channel.subscriptions.size()); + String firstReplyTopic = channel.subscriptions.get(0).topic; + String secondReplyTopic = channel.subscriptions.get(2).topic; + Assertions.assertNotEquals(firstReplyTopic, secondReplyTopic); + Assertions.assertTrue(firstReplyTopic.endsWith(firstId.toString())); + Assertions.assertTrue(secondReplyTopic.endsWith(secondId.toString())); + Assertions.assertEquals(TOPIC, channel.subscriptions.get(1).topic); + Assertions.assertEquals(TOPIC, channel.subscriptions.get(3).topic); + RequestReplyMessage request = request(firstId); + first.send(request).toCompletableFuture().join(); + DDMessage sent = channel.sent.getLast(); + Assertions.assertEquals(TOPIC, sent.topic()); + channel.deliver(CONNECTION, sent); + Assertions.assertEquals(List.of(request), firstReceived.messages); + Assertions.assertEquals(List.of(request), secondReceived.messages); + second.send(request.response(null)).toCompletableFuture().join(); + Assertions.assertEquals(firstReplyTopic, channel.sent.getLast().topic()); + channel.deliver(CONNECTION, channel.sent.getLast()); + Assertions.assertEquals(List.of(request, request.response(null)), firstReceived.messages); + Assertions.assertEquals(List.of(request), secondReceived.messages); + RequestReplyMessage otherRequest = request(secondId); + first.send(otherRequest.failure(RequestReplyMessage.Error.NO_HANDLER)).toCompletableFuture().join(); + Assertions.assertEquals(secondReplyTopic, channel.sent.getLast().topic()); + channel.deliver(CONNECTION, channel.sent.getLast()); + Assertions.assertEquals(otherRequest.failure(RequestReplyMessage.Error.NO_HANDLER), secondReceived.messages.getLast()); + } + Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active)); + Assertions.assertTrue(channel.observers.isEmpty()); + Assertions.assertFalse(channel.closed); + } + + @Test + void keepsDerivedTopicsWithinRegistrationMetadataLimit() throws Exception { + FakeChannel channel = new FakeChannel(); + String topic = "x".repeat(256); + DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, topic); + try (var subscription = adapter.subscribe(UUID.randomUUID(), new Received())) { + Assertions.assertEquals(topic, channel.subscriptions.get(1).topic); + Assertions.assertTrue(channel.subscriptions.getFirst().topic.length() <= 256); + } + Assertions.assertThrows(IllegalArgumentException.class, () -> DDRequestReplyTransport.forChannel(channel, " ")); + Assertions.assertThrows(IllegalArgumentException.class, () -> DDRequestReplyTransport.forChannel(channel, topic + "x")); + } + + @Test + void rollsBackReplySubscriptionAndStateObservationWhenRequestSubscriptionFails() { + FakeChannel channel = new FakeChannel(); + channel.failTopic = TOPIC; + DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + RequestReplyException failure = Assertions.assertThrows(RequestReplyException.class, + () -> adapter.subscribe(UUID.randomUUID(), new Received())); + Assertions.assertSame(channel.subscriptionFailure, failure.getCause()); + Assertions.assertFalse(channel.subscriptions.getFirst().active); + Assertions.assertTrue(channel.observers.isEmpty()); + Assertions.assertFalse(channel.closed); + Assertions.assertThrows(IllegalStateException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); + assertFailure(RequestReplyException.Reason.CLOSED, adapter.send(request(UUID.randomUUID()))); + } + + @Test + void scopedSubscriberPreservesCheckedOpeningFailureAndRollsBack() { + DDExternalDamException unavailable = new DDExternalDamException("Unavailable"); + AtomicInteger closed = new AtomicInteger(); + DDRequestReplyTransport adapter = new DDRequestReplyTransport(TOPIC, + message -> CompletableFuture.completedFuture(null), (topic, listener) -> { + if (topic.equals(TOPIC)) { + throw unavailable; + } + return closed::incrementAndGet; + }); + RequestReplyException failure = Assertions.assertThrows(RequestReplyException.class, + () -> adapter.subscribe(UUID.randomUUID(), new Received())); + Assertions.assertSame(unavailable, failure.getCause()); + Assertions.assertEquals(1, closed.get()); + } + + @Test + void rejectsDisconnectedAndClosedChannelsAtOpening() { + for (DDChannelState state : List.of(DDChannelState.DISCONNECTED, DDChannelState.CONNECTING, DDChannelState.CLOSED)) { + FakeChannel channel = new FakeChannel(); + channel.setStatus(new DDChannelStatus(state, null)); + DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + RequestReplyException failure = Assertions.assertThrows(RequestReplyException.class, + () -> adapter.subscribe(UUID.randomUUID(), new Received())); + Assertions.assertEquals(RequestReplyException.Reason.DISCONNECTED, failure.reason()); + Assertions.assertTrue(channel.subscriptions.isEmpty()); + Assertions.assertTrue(channel.observers.isEmpty()); + } + } + + @Test + void rollsBackAllResourcesAfterDisconnectDuringOpeningEvenIfTheChannelReconnects() { + FakeChannel channel = new FakeChannel(); + channel.onSubscribe = topic -> { + if (TOPIC.equals(topic)) { + channel.setStatus(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); + channel.setStatus(new DDChannelStatus(DDChannelState.CONNECTED, CONNECTION)); + } + }; + DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + RequestReplyException failure = Assertions.assertThrows(RequestReplyException.class, + () -> adapter.subscribe(UUID.randomUUID(), new Received())); + Assertions.assertEquals(RequestReplyException.Reason.DISCONNECTED, failure.reason()); + Assertions.assertEquals(2, channel.subscriptions.size()); + Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active)); + Assertions.assertTrue(channel.observers.isEmpty()); + } + + @Test + void propagatesDisconnectAndAcceptsOnlyTheCurrentPhysicalSource() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + try (var subscription = adapter.subscribe(endpoint, received)) { + adapter.send(request(endpoint)).toCompletableFuture().join(); + DDMessage wire = channel.sent.getLast(); + channel.deliver(new DDConnectionInfo("other", "run", "socket"), wire); + channel.deliver(new DDConnectionInfo("desktop", "other", "socket"), wire); + channel.deliver(new DDConnectionInfo("desktop", "run", "other"), wire); + Assertions.assertTrue(received.messages.isEmpty()); + channel.setStatus(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); + Assertions.assertEquals(1, received.disconnects); + assertFailure(RequestReplyException.Reason.DISCONNECTED, adapter.send(request(endpoint))); + channel.deliver(CONNECTION, wire); + Assertions.assertTrue(received.messages.isEmpty()); + DDConnectionInfo replacement = new DDConnectionInfo("desktop", "new-run", "new-socket"); + channel.setStatus(new DDChannelStatus(DDChannelState.CONNECTED, replacement)); + channel.deliver(CONNECTION, wire); + channel.deliver(replacement, wire); + Assertions.assertEquals(1, received.messages.size()); + channel.setStatus(new DDChannelStatus(DDChannelState.CONNECTED, CONNECTION)); + Assertions.assertEquals(2, received.disconnects); + channel.setStatus(new DDChannelStatus(DDChannelState.CLOSED, null)); + Assertions.assertEquals(3, received.disconnects); + } + } + + @Test + void rejectsMalformedRecognizedFramesAndWrongTypeTopicAndOrigin() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + try (var subscription = adapter.subscribe(endpoint, received)) { + RequestReplyMessage request = request(endpoint); + adapter.send(request).toCompletableFuture().join(); + DDMessage requestWire = channel.sent.getLast(); + adapter.send(request.response(new JsonObject())).toCompletableFuture().join(); + final DDMessage responseWire = channel.sent.getLast(); + List malformed = new ArrayList<>(); + JsonObject abbreviatedId = requestWire.payload(); + abbreviatedId.addProperty("requestId", "1-1-1-1-1"); + malformed.add(abbreviatedId); + JsonObject invalidOrigin = requestWire.payload(); + invalidOrigin.addProperty("replyTo", "not-a-uuid"); + malformed.add(invalidOrigin); + JsonObject numericId = requestWire.payload(); + numericId.addProperty("requestId", 1); + malformed.add(numericId); + JsonObject unknownType = requestWire.payload(); + unknownType.addProperty("type", "OTHER"); + malformed.add(unknownType); + JsonObject missingData = requestWire.payload(); + missingData.remove("data"); + malformed.add(missingData); + JsonObject nullRequest = requestWire.payload(); + nullRequest.add("data", JsonNull.INSTANCE); + malformed.add(nullRequest); + JsonObject primitiveData = requestWire.payload(); + primitiveData.addProperty("data", "text"); + malformed.add(primitiveData); + JsonObject requestError = requestWire.payload(); + requestError.addProperty("error", "NO_HANDLER"); + malformed.add(requestError); + for (JsonObject envelope : malformed) { + channel.deliver(CONNECTION, new DDMessage(TOPIC, requestWire.type(), envelope)); + } + JsonObject responseError = responseWire.payload(); + responseError.addProperty("error", "NO_HANDLER"); + channel.deliver(CONNECTION, new DDMessage(responseWire.topic(), responseWire.type(), responseError)); + JsonObject responsePrimitive = responseWire.payload(); + responsePrimitive.addProperty("data", 42); + channel.deliver(CONNECTION, new DDMessage(responseWire.topic(), responseWire.type(), responsePrimitive)); + JsonObject responseMissingData = responseWire.payload(); + responseMissingData.remove("data"); + channel.deliver(CONNECTION, new DDMessage(responseWire.topic(), responseWire.type(), responseMissingData)); + JsonObject wrongOrigin = responseWire.payload(); + wrongOrigin.addProperty("replyTo", UUID.randomUUID().toString()); + channel.deliver(CONNECTION, new DDMessage(responseWire.topic(), responseWire.type(), wrongOrigin)); + adapter.send(request.failure(RequestReplyMessage.Error.HANDLER_FAILED)).toCompletableFuture().join(); + DDMessage errorWire = channel.sent.getLast(); + JsonObject errorData = errorWire.payload(); + errorData.add("data", new JsonObject()); + channel.deliver(CONNECTION, new DDMessage(errorWire.topic(), errorWire.type(), errorData)); + JsonObject unknownError = errorWire.payload(); + unknownError.addProperty("error", "OTHER"); + channel.deliver(CONNECTION, new DDMessage(errorWire.topic(), errorWire.type(), unknownError)); + JsonObject missingError = errorWire.payload(); + missingError.remove("error"); + channel.deliver(CONNECTION, new DDMessage(errorWire.topic(), errorWire.type(), missingError)); + channel.deliver(CONNECTION, new DDMessage(TOPIC, "unrelated", requestWire.payload())); + channel.deliver(CONNECTION, new DDMessage(TOPIC, responseWire.type(), responseWire.payload())); + channel.deliver(CONNECTION, new DDMessage(responseWire.topic(), requestWire.type(), requestWire.payload())); + channel.subscriptions.getFirst().listener.onMessage(CONNECTION, requestWire); + channel.subscriptions.get(1).listener.onMessage(CONNECTION, responseWire); + Assertions.assertTrue(received.messages.isEmpty()); + channel.deliver(CONNECTION, requestWire); + channel.deliver(CONNECTION, responseWire); + channel.deliver(CONNECTION, errorWire); + Assertions.assertEquals(List.of(request, request.response(new JsonObject()), + request.failure(RequestReplyMessage.Error.HANDLER_FAILED)), received.messages); + } + } + + @Test + void closeIsIdempotentAndLeavesBorrowedSubscriptionsIntact() throws Exception { + FakeChannel channel = new FakeChannel(); + final DDSubscription borrowed = channel.subscribe("other", (connection, message) -> { }); + DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + final MessageSubscription subscription = adapter.subscribe(endpoint, new Received()); + Assertions.assertThrows(IllegalStateException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); + Assertions.assertThrows(CompletionException.class, + () -> adapter.send(request(UUID.randomUUID())).toCompletableFuture().join()); + adapter.close(); + subscription.close(); + assertFailure(RequestReplyException.Reason.CLOSED, adapter.send(request(endpoint))); + Assertions.assertTrue(channel.subscriptions.getFirst().active); + Assertions.assertFalse(channel.closed); + borrowed.close(); + } + + @Test + void boundsOutstandingSendsAndReleasesBudgetOnCompletionAndFailure() throws Exception { + List> pending = new ArrayList<>(); + DDRequestReplyTransport adapter = new DDRequestReplyTransport(TOPIC, message -> { + CompletableFuture result = new CompletableFuture<>(); + pending.add(result); + return result; + }, (topic, listener) -> () -> { }); + UUID endpoint = UUID.randomUUID(); + try (var subscription = adapter.subscribe(endpoint, new Received())) { + List> sends = new ArrayList<>(); + for (int i = 0; i < 128; i++) { + sends.add(adapter.send(request(endpoint))); + } + assertFailure(RequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + Assertions.assertEquals(128, pending.size()); + pending.getFirst().complete(null); + sends.getFirst().toCompletableFuture().get(2, TimeUnit.SECONDS); + adapter.send(request(endpoint)); + Assertions.assertEquals(129, pending.size()); + assertFailure(RequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + IllegalStateException unavailable = new IllegalStateException("Unavailable"); + pending.get(1).completeExceptionally(unavailable); + ExecutionException failure = Assertions.assertThrows(ExecutionException.class, + () -> sends.get(1).toCompletableFuture().get(2, TimeUnit.SECONDS)); + Assertions.assertSame(unavailable, failure.getCause()); + adapter.send(request(endpoint)); + Assertions.assertEquals(130, pending.size()); + assertFailure(RequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + Assertions.assertTrue(sends.get(2).toCompletableFuture().cancel(false)); + assertFailure(RequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + pending.get(2).complete(null); + adapter.send(request(endpoint)); + Assertions.assertEquals(131, pending.size()); + assertFailure(RequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + } finally { + pending.forEach(result -> result.complete(null)); + } + } + + @Test + void releasesSendBudgetAfterSynchronousSenderFailure() throws Exception { + IllegalStateException unavailable = new IllegalStateException("Unavailable"); + AtomicInteger attempts = new AtomicInteger(); + DDRequestReplyTransport adapter = new DDRequestReplyTransport(TOPIC, message -> { + if (attempts.incrementAndGet() <= 129) { + throw unavailable; + } + return CompletableFuture.completedFuture(null); + }, (topic, listener) -> () -> { }); + UUID endpoint = UUID.randomUUID(); + try (var subscription = adapter.subscribe(endpoint, new Received())) { + for (int i = 0; i < 129; i++) { + ExecutionException failure = Assertions.assertThrows(ExecutionException.class, + () -> adapter.send(request(endpoint)).toCompletableFuture().get(2, TimeUnit.SECONDS)); + Assertions.assertSame(unavailable, failure.getCause()); + } + adapter.send(request(endpoint)).toCompletableFuture().get(2, TimeUnit.SECONDS); + Assertions.assertEquals(130, attempts.get()); + } + } + + @NotNull + private static RequestReplyMessage request(@NotNull UUID endpoint) { + return RequestReplyMessage.request(UUID.randomUUID(), endpoint, new JsonObject()); + } + + private static void assertFailure(@NotNull RequestReplyException.Reason reason, @NotNull CompletionStage result) { + ExecutionException failure = Assertions.assertThrows(ExecutionException.class, + () -> result.toCompletableFuture().get(2, TimeUnit.SECONDS)); + Assertions.assertEquals(reason, Assertions.assertInstanceOf(RequestReplyException.class, failure.getCause()).reason()); + } + + private static final class Received implements RequestReplyTransport.Listener { + private final List> messages = new ArrayList<>(); + private int disconnects; + + @Override + public void onMessage(@NotNull RequestReplyMessage message) { + messages.add(message); + } + + @Override + public void onDisconnect() { + disconnects++; + } + } + + private static final class FakeChannel implements DDMessageChannel { + private final List subscriptions = new ArrayList<>(); + private final List> observers = new ArrayList<>(); + private final List sent = new ArrayList<>(); + private final RuntimeException subscriptionFailure = new IllegalStateException("Subscription unavailable"); + private DDChannelStatus status = new DDChannelStatus(DDChannelState.CONNECTED, CONNECTION); + @Nullable + private String failTopic; + @Nullable + private Consumer onSubscribe; + @Nullable + private Consumer forward; + private boolean closed; + + @Override + @NotNull + public DDChannelStatus getStatus() { + return status; + } + + @Override + @NotNull + public DDSubscription observeState(@NotNull Consumer observer) { + observers.add(observer); + observer.accept(status); + return () -> observers.remove(observer); + } + + @Override + @NotNull + public CompletionStage send(@NotNull DDMessage message) { + sent.add(message); + if (forward != null) { + forward.accept(message); + } + return CompletableFuture.completedFuture(null); + } + + @Override + @NotNull + public DDSubscription subscribe(@NotNull String topic, @NotNull DDMessageListener listener) { + if (topic.equals(failTopic)) { + throw subscriptionFailure; + } + TopicSubscription subscription = new TopicSubscription(topic, listener); + subscriptions.add(subscription); + if (onSubscribe != null) { + onSubscribe.accept(topic); + } + return () -> subscription.active = false; + } + + @Override + @NotNull + public DDSubscription addConnectionListener(@NotNull DDConnectionListener listener) { + throw new UnsupportedOperationException(); + } + + private void setStatus(@NotNull DDChannelStatus next) { + status = next; + List.copyOf(observers).forEach(observer -> observer.accept(next)); + } + + private void deliver(@NotNull DDConnectionInfo source, @NotNull DDMessage message) { + for (TopicSubscription subscription : List.copyOf(subscriptions)) { + if (subscription.active && subscription.topic.equals(message.topic())) { + subscription.listener.onMessage(source, message); + } + } + } + + @Override + public void close() { + closed = true; + } + } + + private static final class TopicSubscription { + private final String topic; + private final DDMessageListener listener; + private boolean active = true; + + private TopicSubscription(@NotNull String topic, @NotNull DDMessageListener listener) { + this.topic = topic; + this.listener = listener; + } + } +} diff --git a/apis/com.dbeaver.messaging/META-INF/MANIFEST.MF b/apis/com.dbeaver.messaging/META-INF/MANIFEST.MF new file mode 100644 index 0000000..8908cf5 --- /dev/null +++ b/apis/com.dbeaver.messaging/META-INF/MANIFEST.MF @@ -0,0 +1,11 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Vendor: DBeaver Corp +Bundle-Name: DBeaver Messaging +Bundle-SymbolicName: com.dbeaver.messaging +Bundle-Version: 0.0.17.qualifier +Bundle-RequiredExecutionEnvironment: JavaSE-21 +Export-Package: com.dbeaver.messaging.requestreply +Bundle-ActivationPolicy: lazy +Require-Bundle: org.jkiss.utils +Automatic-Module-Name: com.dbeaver.messaging diff --git a/apis/com.dbeaver.messaging/README.md b/apis/com.dbeaver.messaging/README.md new file mode 100644 index 0000000..68311ac --- /dev/null +++ b/apis/com.dbeaver.messaging/README.md @@ -0,0 +1,87 @@ +# Messaging: request/reply + +This module supplies a transport-independent request/reply endpoint for Java 21. It has no dependency on DataDam, +HTTP, WebSocket, JSON, Spring, or Eclipse runtime services. The only non-JDK production dependency supplies nullability +annotations. Typed RPC is not part of this module's current API. + +## Usage + +Create an endpoint over a transport already scoped to the intended peer and logical request topic: + +```java +try (var requests = RequestReplyEndpoint.open(transport)) { + var response = requests.request(payload); +} +``` + +The receiving peer installs one handler on its own endpoint: + +```java +try (var requests = RequestReplyEndpoint.open(transport); + var registration = requests.handle(request -> process(request))) { + serveApplication(); +} +``` + +An endpoint can initiate and handle requests concurrently. `request` blocks the calling thread and returns the response +payload, or null for successful completion without a value. Use a worker or virtual thread. Incoming handlers run on +the endpoint's virtual threads, outside the transport listener. Future objects are internal. + +`RequestReplyEndpoint

` leaves the payload representation to the application and transport adapter. For example, +`P` can be a string, an immutable application value, or an encoded JSON object. The core does not copy arbitrary `P` +values: payloads must remain immutable while a request is in use, or the adapter must take defensive snapshots. + +The default `RequestReplyOptions` use a 30-second deadline, at most 256 outgoing requests, and 64 concurrent incoming +handlers. Supply options to `open`, or a deadline to an individual `request(payload, timeout)` call. Deadlines are +measured with monotonic time and include local sending. The handler limit has no waiting queue: saturation replies +with `OVERLOADED`. Outgoing saturation fails locally without sending. +The handler slot covers application-handler execution and is released before response sending. Each transport adapter +separately bounds its encoding and outstanding send work. + +## Completion and failure + +Pending correlation is installed before sending, including when the adapter delivers an immediate response before +its send stage completes. Request IDs and reply endpoint IDs are independent UUIDs. A response must match both. +Unknown, late, and duplicate responses do not complete another request. + +`RequestReplyException.reason()` distinguishes local closure, disconnection, capacity rejection, send failure, timeout, +and remote errors. `remoteError()` identifies `NO_HANDLER`, `OVERLOADED`, or `HANDLER_FAILED` for remote errors. Handler +exception classes, messages, and stack traces are not sent to the peer. Local send failures retain their causes. + +Interruption propagates as `InterruptedException` and releases the pending slot. A thread already interrupted when +calling `request` does not send. Timeout, interruption, or a send error can leave the remote outcome unknown: none +promises remote cancellation or makes a non-idempotent request safe to repeat. The library never retries requests, +replays results, or provides exactly-once execution. Duplicate requests still being handled are ignored; completed +request IDs are not retained as a replay history. + +Closing a handler registration removes only that handler; already accepted work can finish. The returned registration +is identity-scoped, so closing it again cannot remove a replacement handler. Closing the endpoint fails pending calls, +removes its transport subscription, and interrupts accepted work. It does not close the borrowed physical transport. +Closing is idempotent and does not wait for application code that ignores interruption. + +A disconnect notification fails pending calls and invalidates replies from work accepted before that notification. +Handlers are interrupted, but continue counting against capacity until their execution actually stops. The receiving +subscription can stay installed across reconnects. New calls use the adapter's normal send readiness checks. If an +adapter cannot observe peer disconnection, outstanding calls expire through their deadlines instead. + +## Transport adapter contract + +Implement `RequestReplyTransport

` to integrate a message transport: + +- `subscribe(endpointId, listener)` establishes receiving routes before returning. Requests go to one designated + handler endpoint; responses go to the original request's `replyTo` endpoint. A transport may limit an adapter + instance to one endpoint subscription. +- Failed subscription establishment must roll back every partially installed route and owned resource. +- `send(message)` is nonblocking and returns a bounded transport send stage. Completing that stage confirms sending, + not remote business completion. A response can arrive before this stage completes. +- The adapter retains peer scope, verifies the source of received frames, and uses `replyTo` only as a logical reply + destination. Correlation UUIDs do not replace authentication or account/peer authorization. +- Disconnection is reported through `listener.onDisconnect()`. Incoming callbacks can be concurrent and can occur + synchronously during subscription establishment or sending. +- Closing the returned `MessageSubscription` removes receiving routes and releases adapter-owned resources. It must + leave a caller-owned physical connection available to other consumers. +- The adapter owns wire validation, payload snapshots, message size limits, and bounded transport send work. It must + not introduce automatic retries or an offline business-message queue. + +Transport write failure or a lost reply can prevent a caller from seeing a completed remote operation. Response send +failure is not retried. An already claimed transport send can finish while closure or disconnection is taking place. diff --git a/apis/com.dbeaver.messaging/build.properties b/apis/com.dbeaver.messaging/build.properties new file mode 100644 index 0000000..e5d65d5 --- /dev/null +++ b/apis/com.dbeaver.messaging/build.properties @@ -0,0 +1,4 @@ +source.. = src/main/java +output.. = target/classes/ +bin.includes = .,\ + META-INF/ diff --git a/apis/com.dbeaver.messaging/pom.xml b/apis/com.dbeaver.messaging/pom.xml new file mode 100644 index 0000000..ad55cc5 --- /dev/null +++ b/apis/com.dbeaver.messaging/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + com.dbeaver.datadam + api + 0.0.17-SNAPSHOT + ../pom.xml + + + com.dbeaver.messaging + eclipse-plugin + DBeaver Messaging + + + + com.dbeaver.common + org.jkiss.utils + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + test + test + + test + + + + + + + diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java new file mode 100644 index 0000000..515f50c --- /dev/null +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java @@ -0,0 +1,23 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +@FunctionalInterface +public interface MessageSubscription extends AutoCloseable { + @Override + void close(); +} diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java new file mode 100644 index 0000000..a57dc97 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java @@ -0,0 +1,26 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +@FunctionalInterface +public interface RequestHandler

{ + @Nullable + P handle(@NotNull P request) throws Exception; +} diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java new file mode 100644 index 0000000..76c0247 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java @@ -0,0 +1,369 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.FutureTask; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.LongSupplier; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class RequestReplyEndpoint

implements AutoCloseable { + private static final Logger log = Logger.getLogger(RequestReplyEndpoint.class.getName()); + private final Object lock = new Object(); + private final UUID endpointId = UUID.randomUUID(); + private final RequestReplyTransport

transport; + private final RequestReplyOptions options; + private final LongSupplier nanoTime; + private final Map pending = new HashMap<>(); + private final Map incoming = new HashMap<>(); + private final ExecutorService handlers = Executors.newVirtualThreadPerTaskExecutor(); + private final MessageSubscription subscription; + @Nullable + private HandlerRegistration handler; + private boolean closed; + private long generation; + + RequestReplyEndpoint( + @NotNull RequestReplyTransport

transport, + @NotNull RequestReplyOptions options, + @NotNull LongSupplier nanoTime + ) throws RequestReplyException { + this.transport = Objects.requireNonNull(transport); + this.options = Objects.requireNonNull(options); + this.nanoTime = Objects.requireNonNull(nanoTime); + try { + this.subscription = Objects.requireNonNull(transport.subscribe(endpointId, new RequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull RequestReplyMessage

message) { + receive(message); + } + + @Override + public void onDisconnect() { + disconnect(); + } + })); + } catch (RequestReplyException | RuntimeException e) { + handlers.shutdownNow(); + throw e; + } + } + + @NotNull + public static

RequestReplyEndpoint

open(@NotNull RequestReplyTransport

transport) throws RequestReplyException { + return open(transport, RequestReplyOptions.DEFAULT); + } + + @NotNull + public static

RequestReplyEndpoint

open( + @NotNull RequestReplyTransport

transport, + @NotNull RequestReplyOptions options + ) throws RequestReplyException { + return new RequestReplyEndpoint<>(transport, options, System::nanoTime); + } + + @Nullable + public P request(@NotNull P payload) throws RequestReplyException, InterruptedException { + return request(payload, options.timeout()); + } + + @Nullable + public P request(@NotNull P payload, @NotNull Duration timeout) throws RequestReplyException, InterruptedException { + Objects.requireNonNull(payload); + long nanos = RequestReplyOptions.requireTimeout(timeout); + if (Thread.interrupted()) { + throw new InterruptedException(); + } + long started = nanoTime.getAsLong(); + UUID requestId = UUID.randomUUID(); + Pending call = new Pending(started, nanos); + synchronized (lock) { + if (closed) { + throw new RequestReplyException(RequestReplyException.Reason.CLOSED); + } + if (pending.size() >= options.maxPendingRequests()) { + throw new RequestReplyException(RequestReplyException.Reason.OVERLOADED); + } + pending.put(requestId, call); + } + try { + if (call.remaining() <= 0) { + fail(requestId, call, new RequestReplyException(RequestReplyException.Reason.TIMEOUT)); + } else { + sendRequest(requestId, payload, call); + } + try { + return call.result.get(Math.max(0, call.remaining()), TimeUnit.NANOSECONDS); + } catch (TimeoutException e) { + fail(requestId, call, new RequestReplyException(RequestReplyException.Reason.TIMEOUT)); + return call.result.get(); + } + } catch (ExecutionException e) { + if (e.getCause() instanceof RequestReplyException failure) { + throw failure; + } + throw new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, e.getCause()); + } finally { + synchronized (lock) { + pending.remove(requestId, call); + } + } + } + + @NotNull + public MessageSubscription handle(@NotNull RequestHandler

requestHandler) { + Objects.requireNonNull(requestHandler); + synchronized (lock) { + if (closed || handler != null) { + throw new IllegalStateException("Request/reply handler registration is unavailable"); + } + handler = new HandlerRegistration(requestHandler); + return handler; + } + } + + private void sendRequest(@NotNull UUID requestId, @NotNull P payload, @NotNull Pending call) { + synchronized (lock) { + if (pending.get(requestId) != call) { + return; + } + } + try { + transport.send(RequestReplyMessage.request(requestId, endpointId, payload)).whenComplete((ignored, error) -> { + if (error != null) { + fail(requestId, call, new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, error)); + } + }); + } catch (RuntimeException e) { + fail(requestId, call, new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, e)); + } + } + + private void fail( + @NotNull UUID requestId, + @NotNull Pending expected, + @NotNull RequestReplyException failure + ) { + synchronized (lock) { + if (pending.remove(requestId, expected)) { + expected.result.completeExceptionally(failure); + } + } + } + + private void receive(@NotNull RequestReplyMessage

message) { + if (message.type() == RequestReplyMessage.Type.REQUEST) { + accept(message); + return; + } + synchronized (lock) { + if (!closed && endpointId.equals(message.replyTo())) { + Pending call = pending.remove(message.requestId()); + if (call != null) { + if (call.remaining() <= 0) { + call.result.completeExceptionally(new RequestReplyException(RequestReplyException.Reason.TIMEOUT)); + } else if (message.type() == RequestReplyMessage.Type.RESPONSE) { + call.result.complete(message.payload()); + } else { + call.result.completeExceptionally(new RequestReplyException(Objects.requireNonNull(message.error()))); + } + } + } + } + } + + private void accept(@NotNull RequestReplyMessage

message) { + RequestReplyMessage.Error rejection; + synchronized (lock) { + if (closed) { + return; + } + RequestKey key = new RequestKey(message.replyTo(), message.requestId()); + if (incoming.containsKey(key)) { + return; + } + if (handler == null) { + rejection = RequestReplyMessage.Error.NO_HANDLER; + } else if (incoming.size() >= options.maxConcurrentHandlers()) { + rejection = RequestReplyMessage.Error.OVERLOADED; + } else { + Incoming accepted = new Incoming(key, message, handler.requestHandler, generation); + incoming.put(key, accepted); + try { + handlers.execute(accepted); + return; + } catch (RejectedExecutionException e) { + incoming.remove(key, accepted); + rejection = RequestReplyMessage.Error.OVERLOADED; + } + } + } + sendReply(message.failure(rejection)); + } + + private void sendReply(@NotNull RequestReplyMessage

message) { + try { + transport.send(message).whenComplete((ignored, error) -> { + if (error != null) { + log.log(Level.FINE, "Reply could not be sent"); + } + }); + } catch (RuntimeException e) { + log.log(Level.FINE, "Reply could not be sent"); + } + } + + private void disconnect() { + List stopped; + synchronized (lock) { + generation++; + failPending(RequestReplyException.Reason.DISCONNECTED); + stopped = List.copyOf(incoming.values()); + } + stopped.forEach(request -> request.task.cancel(true)); + } + + private void failPending(@NotNull RequestReplyException.Reason reason) { + pending.values().forEach(call -> call.result.completeExceptionally(new RequestReplyException(reason))); + pending.clear(); + } + + @Override + public void close() { + List stopped; + synchronized (lock) { + if (closed) { + return; + } + closed = true; + handler = null; + failPending(RequestReplyException.Reason.CLOSED); + stopped = List.copyOf(incoming.values()); + } + stopped.forEach(request -> request.task.cancel(true)); + try { + subscription.close(); + } finally { + handlers.shutdownNow(); + } + } + + private record RequestKey(@NotNull UUID replyTo, @NotNull UUID requestId) { + } + + private final class Pending { + private final long started; + private final long nanos; + private final CompletableFuture

result = new CompletableFuture<>(); + + private Pending(long started, long nanos) { + this.started = started; + this.nanos = nanos; + } + + private long remaining() { + return nanos - (nanoTime.getAsLong() - started); + } + } + + private final class HandlerRegistration implements MessageSubscription { + private final RequestHandler

requestHandler; + + private HandlerRegistration(@NotNull RequestHandler

requestHandler) { + this.requestHandler = requestHandler; + } + + @Override + public void close() { + synchronized (lock) { + if (handler == this) { + handler = null; + } + } + } + } + + private final class Incoming implements Runnable { + private final RequestKey key; + private final RequestReplyMessage

message; + private final RequestHandler

requestHandler; + private final long acceptedGeneration; + private final FutureTask task; + + private Incoming( + @NotNull RequestKey key, + @NotNull RequestReplyMessage

message, + @NotNull RequestHandler

requestHandler, + long acceptedGeneration + ) { + this.key = key; + this.message = message; + this.requestHandler = requestHandler; + this.acceptedGeneration = acceptedGeneration; + this.task = new FutureTask<>(() -> { + process(); + return null; + }); + } + + @Override + public void run() { + try { + task.run(); + } finally { + synchronized (lock) { + incoming.remove(key, this); + } + } + } + + private void process() { + RequestReplyMessage

reply; + try { + reply = message.response(requestHandler.handle(Objects.requireNonNull(message.payload()))); + } catch (Exception e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + reply = message.failure(RequestReplyMessage.Error.HANDLER_FAILED); + } + synchronized (lock) { + if (closed || acceptedGeneration != generation) { + return; + } + incoming.remove(key, this); + } + sendReply(reply); + } + } +} diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java new file mode 100644 index 0000000..94d62e7 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java @@ -0,0 +1,70 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.Objects; + +public class RequestReplyException extends Exception { + public enum Reason { + CLOSED, DISCONNECTED, OVERLOADED, SEND_FAILED, TIMEOUT, REMOTE_ERROR + } + + private final Reason reason; + @Nullable + private final RequestReplyMessage.Error remoteError; + + public RequestReplyException(@NotNull Reason reason) { + this(reason, null); + } + + public RequestReplyException(@NotNull Reason reason, @Nullable Throwable cause) { + super(message(Objects.requireNonNull(reason)), cause); + this.reason = reason; + this.remoteError = null; + } + + public RequestReplyException(@NotNull RequestReplyMessage.Error remoteError) { + super("Remote request failed: " + Objects.requireNonNull(remoteError)); + this.reason = Reason.REMOTE_ERROR; + this.remoteError = remoteError; + } + + @NotNull + public Reason reason() { + return reason; + } + + @Nullable + public RequestReplyMessage.Error remoteError() { + return remoteError; + } + + @NotNull + private static String message(@NotNull Reason reason) { + return switch (reason) { + case CLOSED -> "Request/reply endpoint is closed"; + case DISCONNECTED -> "Request/reply transport was disconnected"; + case OVERLOADED -> "Request/reply capacity is unavailable"; + case SEND_FAILED -> "Request/reply message could not be sent"; + case TIMEOUT -> "Request/reply deadline expired"; + case REMOTE_ERROR -> "Remote request failed"; + }; + } +} diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java new file mode 100644 index 0000000..c5ccf24 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java @@ -0,0 +1,72 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.Objects; +import java.util.UUID; + +public record RequestReplyMessage

( + @NotNull UUID requestId, + @NotNull UUID replyTo, + @NotNull Type type, + @Nullable P payload, + @Nullable Error error +) { + public enum Type { + REQUEST, RESPONSE, ERROR + } + + public enum Error { + NO_HANDLER, OVERLOADED, HANDLER_FAILED + } + + public RequestReplyMessage { + Objects.requireNonNull(requestId); + Objects.requireNonNull(replyTo); + Objects.requireNonNull(type); + if (type == Type.REQUEST && payload == null || type == Type.ERROR && (error == null || payload != null) + || type != Type.ERROR && error != null) { + throw new IllegalArgumentException("Invalid request/reply message"); + } + } + + @NotNull + public static

RequestReplyMessage

request(@NotNull UUID requestId, @NotNull UUID replyTo, @NotNull P payload) { + return new RequestReplyMessage<>(requestId, replyTo, Type.REQUEST, Objects.requireNonNull(payload), null); + } + + @NotNull + public RequestReplyMessage

response(@Nullable P result) { + requireRequest(); + return new RequestReplyMessage<>(requestId, replyTo, Type.RESPONSE, result, null); + } + + @NotNull + public RequestReplyMessage

failure(@NotNull Error reason) { + requireRequest(); + return new RequestReplyMessage<>(requestId, replyTo, Type.ERROR, null, Objects.requireNonNull(reason)); + } + + private void requireRequest() { + if (type != Type.REQUEST) { + throw new IllegalStateException("Only a request can be answered"); + } + } +} diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java new file mode 100644 index 0000000..f25de1a --- /dev/null +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java @@ -0,0 +1,46 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; + +import java.time.Duration; +import java.util.Objects; + +public record RequestReplyOptions(@NotNull Duration timeout, int maxPendingRequests, int maxConcurrentHandlers) { + public static final RequestReplyOptions DEFAULT = new RequestReplyOptions(Duration.ofSeconds(30), 256, 64); + + public RequestReplyOptions { + requireTimeout(timeout); + if (maxPendingRequests < 1 || maxConcurrentHandlers < 1) { + throw new IllegalArgumentException("Request/reply limits must be positive"); + } + } + + static long requireTimeout(@NotNull Duration timeout) { + Objects.requireNonNull(timeout); + try { + long nanos = timeout.toNanos(); + if (nanos > 0) { + return nanos; + } + } catch (ArithmeticException e) { + throw new IllegalArgumentException("Request/reply timeout is too large", e); + } + throw new IllegalArgumentException("Request/reply timeout must be positive"); + } +} diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java new file mode 100644 index 0000000..b598053 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java @@ -0,0 +1,36 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; + +import java.util.UUID; +import java.util.concurrent.CompletionStage; + +public interface RequestReplyTransport

{ + @NotNull + MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener

listener) throws RequestReplyException; + + @NotNull + CompletionStage send(@NotNull RequestReplyMessage

message); + + interface Listener

{ + void onMessage(@NotNull RequestReplyMessage

message); + + void onDisconnect(); + } +} diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java new file mode 100644 index 0000000..69b884f --- /dev/null +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java @@ -0,0 +1,139 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class RequestReplyDeadlineTest { + @Test + void rejectsAnExpiredResponseBeforeTheCallerStartsWaiting() throws Exception { + AtomicLong nanos = new AtomicLong(); + Transport transport = new Transport(); + transport.sending = request -> { + nanos.set(Duration.ofSeconds(2).toNanos()); + transport.listener.onMessage(request.response("late")); + }; + + try (var endpoint = new RequestReplyEndpoint<>(transport, RequestReplyOptions.DEFAULT, nanos::get)) { + RequestReplyException failure = assertThrows(RequestReplyException.class, + () -> endpoint.request("request", Duration.ofSeconds(1))); + assertEquals(RequestReplyException.Reason.TIMEOUT, failure.reason()); + } + } + + @Test + void preservesAResponseAcceptedBeforeTheDeadlineWhenTheCallerResumesLater() throws Exception { + AtomicLong nanos = new AtomicLong(); + Transport transport = new Transport(); + transport.sending = request -> { + transport.listener.onMessage(request.response("on time")); + nanos.set(Duration.ofSeconds(2).toNanos()); + }; + + try (var endpoint = new RequestReplyEndpoint<>(transport, RequestReplyOptions.DEFAULT, nanos::get)) { + assertEquals("on time", endpoint.request("request", Duration.ofSeconds(1))); + } + } + + @Test + void doesNotSendWhenTheDeadlineExpiresBeforeSending() throws Exception { + AtomicLong nanos = new AtomicLong(); + Transport transport = new Transport(); + transport.sending = request -> { + throw new AssertionError("Expired request was sent"); + }; + + try (var endpoint = new RequestReplyEndpoint<>(transport, RequestReplyOptions.DEFAULT, + () -> nanos.getAndAdd(Duration.ofSeconds(2).toNanos()))) { + RequestReplyException failure = assertThrows(RequestReplyException.class, + () -> endpoint.request("request", Duration.ofSeconds(1))); + assertEquals(RequestReplyException.Reason.TIMEOUT, failure.reason()); + } + } + + @Test + void doesNotSendAPendingRequestAlreadyFailedByDisconnect() throws Exception { + AtomicInteger reads = new AtomicInteger(); + Transport transport = new Transport(); + transport.sending = request -> { + throw new AssertionError("Disconnected request was sent"); + }; + + try (var endpoint = new RequestReplyEndpoint<>(transport, RequestReplyOptions.DEFAULT, () -> { + if (reads.incrementAndGet() == 2) { + transport.listener.onDisconnect(); + } + return 0; + })) { + RequestReplyException failure = assertThrows(RequestReplyException.class, () -> endpoint.request("request")); + assertEquals(RequestReplyException.Reason.DISCONNECTED, failure.reason()); + } + } + + @Test + void doesNotSendAPendingRequestAlreadyFailedByClosure() throws Exception { + AtomicInteger reads = new AtomicInteger(); + AtomicReference> current = new AtomicReference<>(); + Transport transport = new Transport(); + transport.sending = request -> { + throw new AssertionError("Closed request was sent"); + }; + + try (var endpoint = new RequestReplyEndpoint<>(transport, RequestReplyOptions.DEFAULT, () -> { + if (reads.incrementAndGet() == 2) { + current.get().close(); + } + return 0; + })) { + current.set(endpoint); + RequestReplyException failure = assertThrows(RequestReplyException.class, () -> endpoint.request("request")); + assertEquals(RequestReplyException.Reason.CLOSED, failure.reason()); + } + } + + private static final class Transport implements RequestReplyTransport { + private Listener listener; + private Consumer> sending; + + @Override + @NotNull + public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener receiver) { + listener = receiver; + return () -> { }; + } + + @Override + @NotNull + public CompletionStage send(@NotNull RequestReplyMessage message) { + sending.accept(message); + return CompletableFuture.completedFuture(null); + } + } +} diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java new file mode 100644 index 0000000..fd35ae7 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java @@ -0,0 +1,838 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.function.Executable; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(30) +class RequestReplyEndpointTest { + private static final Duration WAIT = Duration.ofSeconds(5); + private final List callers = new ArrayList<>(); + + @AfterEach + void stopCallers() throws InterruptedException { + callers.forEach(Thread::interrupt); + for (Thread caller : callers) { + join(caller); + } + } + + @Test + void subscribesBeforeReturningAndAcceptsAReplyBeforeSendCompletes() throws Exception { + var transport = new MemoryTransport(); + var acknowledgement = new CompletableFuture(); + try (var endpoint = RequestReplyEndpoint.open(transport)) { + assertEquals(1, transport.listeners.size()); + transport.sender = message -> { + assertTrue(transport.listeners.containsKey(message.replyTo())); + transport.deliver(message.response("immediate reply")); + return acknowledgement; + }; + + assertEquals("immediate reply", endpoint.request("request")); + assertFalse(acknowledgement.isDone()); + assertEquals(RequestReplyMessage.Type.REQUEST, transport.next().type()); + assertTrue(transport.sent.isEmpty()); + + assertTrue(acknowledgement.completeExceptionally(new IllegalStateException("late send failure"))); + assertEquals("immediate reply", endpoint.request("another request")); + assertEquals(1, transport.sent.size()); + } + assertEquals(1, transport.subscriptionCloses.get()); + } + + @Test + void failsOnAnAsynchronousSendErrorBeforeTheReplyAndReleasesCapacity() throws Exception { + var transport = new MemoryTransport(); + var acknowledgement = new CompletableFuture(); + var cause = new IllegalStateException("send failure"); + transport.sender = message -> acknowledgement; + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { + Future pending = async(() -> endpoint.request("request")); + RequestReplyMessage request = transport.next(); + + acknowledgement.completeExceptionally(cause); + + assertSame(cause, failure(RequestReplyException.Reason.SEND_FAILED, pending).getCause()); + transport.deliver(request.response("too late")); + transport.deliver(request.failure(RequestReplyMessage.Error.HANDLER_FAILED)); + assertTrue(transport.sent.isEmpty()); + + transport.sender = message -> { + transport.deliver(message.response("recovered")); + return CompletableFuture.completedFuture(null); + }; + assertEquals("recovered", endpoint.request("next request")); + assertEquals(1, transport.sent.size()); + } + } + + @Test + void convertsSynchronousSendFailuresWithoutRetryingOrLeakingCapacity() throws Exception { + var transport = new MemoryTransport(); + var cause = new IllegalStateException("synchronous send failure"); + transport.sender = message -> { + throw cause; + }; + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { + assertSame(cause, failure(RequestReplyException.Reason.SEND_FAILED, () -> endpoint.request("request")).getCause()); + assertEquals("request", transport.next().payload()); + assertTrue(transport.sent.isEmpty()); + + transport.sender = message -> { + transport.deliver(message.response("recovered")); + return CompletableFuture.completedFuture(null); + }; + assertEquals("recovered", endpoint.request("next request")); + assertEquals(1, transport.sent.size()); + } + } + + @Test + void correlatesConcurrentResponsesDeliveredInReverseOrder() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = RequestReplyEndpoint.open(transport, options(8, 1))) { + Map> results = new HashMap<>(); + for (int index = 0; index < 8; index++) { + String payload = "request-" + index; + results.put(payload, async(() -> endpoint.request(payload))); + } + List> requests = new ArrayList<>(); + Set requestIds = new HashSet<>(); + Set replyTargets = new HashSet<>(); + for (int index = 0; index < results.size(); index++) { + RequestReplyMessage request = transport.next(); + requests.add(request); + assertEquals(RequestReplyMessage.Type.REQUEST, request.type()); + assertTrue(requestIds.add(request.requestId())); + replyTargets.add(request.replyTo()); + } + assertEquals(1, replyTargets.size()); + for (int index = requests.size() - 1; index >= 0; index--) { + RequestReplyMessage request = requests.get(index); + transport.deliver(request.response("reply:" + request.payload())); + } + + for (var entry : results.entrySet()) { + assertEquals("reply:" + entry.getKey(), result(entry.getValue())); + } + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void invokesTheRemoteHandlerOnAVirtualThreadAndReturnsANullResult() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + connect(clientTransport, serverTransport); + var handlerThread = new AtomicReference(); + var handled = new AtomicReference(); + try (var client = RequestReplyEndpoint.open(clientTransport); + var server = RequestReplyEndpoint.open(serverTransport); + var registration = server.handle(payload -> { + handlerThread.set(Thread.currentThread()); + handled.set(payload); + return null; + })) { + assertNull(client.request("nullable result")); + + assertEquals("nullable result", handled.get()); + assertTrue(handlerThread.get().isVirtual()); + assertNotEquals(Thread.currentThread(), handlerThread.get()); + RequestReplyMessage request = clientTransport.next(); + assertEquals(request.response(null), serverTransport.next()); + join(handlerThread.get()); + } + } + + @Test + void reportsMissingAndFailingHandlersWithoutExposingHandlerDetails() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + connect(clientTransport, serverTransport); + try (var client = RequestReplyEndpoint.open(clientTransport); + var server = RequestReplyEndpoint.open(serverTransport)) { + RequestReplyException missing = failure(RequestReplyException.Reason.REMOTE_ERROR, () -> client.request("missing")); + assertEquals(RequestReplyMessage.Error.NO_HANDLER, missing.remoteError()); + assertNull(missing.getCause()); + assertEquals(clientTransport.next().failure(RequestReplyMessage.Error.NO_HANDLER), serverTransport.next()); + + try (var registration = server.handle(payload -> { + throw new IllegalStateException("private handler details: " + payload); + })) { + RequestReplyException failed = failure(RequestReplyException.Reason.REMOTE_ERROR, () -> client.request("secret")); + + assertEquals(RequestReplyMessage.Error.HANDLER_FAILED, failed.remoteError()); + assertNull(failed.getCause()); + assertFalse(failed.toString().contains("private")); + assertFalse(failed.toString().contains("secret")); + assertEquals(clientTransport.next().failure(RequestReplyMessage.Error.HANDLER_FAILED), serverTransport.next()); + } + } + } + + @Test + void releasesHandlerCapacityWhenReplySendingFails() throws Exception { + var transport = new MemoryTransport(); + var handlerThread = new AtomicReference(); + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1)); + var registration = endpoint.handle(payload -> { + handlerThread.set(Thread.currentThread()); + return "reply:" + payload; + })) { + for (boolean asynchronous : List.of(false, true)) { + transport.sender = message -> { + var failure = new IllegalStateException("reply send failure"); + if (asynchronous) { + return CompletableFuture.failedFuture(failure); + } + throw failure; + }; + var request = request("request"); + + assertDoesNotThrow(() -> transport.deliver(request)); + assertEquals(request.response("reply:request"), transport.next()); + join(handlerThread.get()); + } + transport.sender = message -> CompletableFuture.completedFuture(null); + var request = request("recovered"); + transport.deliver(request); + assertEquals(request.response("reply:recovered"), transport.next()); + join(handlerThread.get()); + } + } + + @Test + void isolatesReplyTargetsAndIgnoresUnknownAndDuplicateResponses() throws Exception { + var transport = new MemoryTransport(); + try (var first = RequestReplyEndpoint.open(transport); + var second = RequestReplyEndpoint.open(transport)) { + final Future firstResult = async(() -> first.request("first")); + RequestReplyMessage firstRequest = transport.next(); + final Future secondResult = async(() -> second.request("second")); + RequestReplyMessage secondRequest = transport.next(); + assertNotEquals(firstRequest.replyTo(), secondRequest.replyTo()); + + transport.deliver(new RequestReplyMessage<>(firstRequest.requestId(), secondRequest.replyTo(), + RequestReplyMessage.Type.RESPONSE, "wrong endpoint", null)); + transport.deliver(new RequestReplyMessage<>(secondRequest.requestId(), firstRequest.replyTo(), + RequestReplyMessage.Type.ERROR, null, RequestReplyMessage.Error.HANDLER_FAILED)); + var unknown = RequestReplyMessage.request(UUID.randomUUID(), firstRequest.replyTo(), "unknown"); + transport.deliver(unknown.response("unknown response")); + transport.deliver(unknown.failure(RequestReplyMessage.Error.NO_HANDLER)); + assertFalse(firstResult.isDone()); + assertFalse(secondResult.isDone()); + + transport.deliver(firstRequest.response("first reply")); + transport.deliver(firstRequest.response("duplicate")); + transport.deliver(firstRequest.failure(RequestReplyMessage.Error.HANDLER_FAILED)); + assertEquals("first reply", result(firstResult)); + assertFalse(secondResult.isDone()); + transport.deliver(secondRequest.response("second reply")); + assertEquals("second reply", result(secondResult)); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void usesTheDefaultTimeoutAndReleasesCapacityBeforeIgnoringLateReplies() throws Exception { + var transport = new MemoryTransport(); + var options = new RequestReplyOptions(Duration.ofMillis(100), 1, 1); + try (var endpoint = RequestReplyEndpoint.open(transport, options)) { + failure(RequestReplyException.Reason.TIMEOUT, () -> endpoint.request("timed out")); + RequestReplyMessage expired = transport.next(); + assertTrue(transport.sent.isEmpty()); + + Future next = async(() -> endpoint.request("next", WAIT)); + final RequestReplyMessage request = transport.next(); + transport.deliver(expired.response("late response")); + transport.deliver(expired.failure(RequestReplyMessage.Error.HANDLER_FAILED)); + assertFalse(next.isDone()); + transport.deliver(request.response("next reply")); + + assertEquals("next reply", result(next)); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void rejectsRepliesAtTheDeadlineEvenWhenTheyArriveBeforeSendReturns() throws Exception { + var transport = new MemoryTransport(); + var clock = new AtomicLong(); + try (var endpoint = new RequestReplyEndpoint<>(transport, options(1, 1), clock::get)) { + for (boolean error : List.of(false, true)) { + transport.sender = message -> { + clock.addAndGet(WAIT.toNanos()); + transport.deliver(error ? message.failure(RequestReplyMessage.Error.HANDLER_FAILED) : message.response("late")); + return CompletableFuture.completedFuture(null); + }; + + failure(RequestReplyException.Reason.TIMEOUT, () -> endpoint.request("expired")); + + assertEquals("expired", transport.next().payload()); + assertTrue(transport.sent.isEmpty()); + } + transport.sender = message -> { + transport.deliver(message.response("on time")); + return CompletableFuture.completedFuture(null); + }; + assertEquals("on time", endpoint.request("fresh")); + } + } + + @Test + void doesNotSendOrConsumeCapacityWhenTheCallerIsAlreadyInterrupted() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { + Future pending = async(() -> { + Thread.currentThread().interrupt(); + return endpoint.request("already interrupted"); + }); + + ExecutionException failure = assertThrows(ExecutionException.class, () -> result(pending)); + assertInstanceOf(InterruptedException.class, failure.getCause()); + assertTrue(transport.sent.isEmpty()); + transport.sender = message -> { + transport.deliver(message.response("next reply")); + return CompletableFuture.completedFuture(null); + }; + assertEquals("next reply", endpoint.request("next")); + } + } + + @Test + void propagatesInterruptionAndReleasesThePendingSlotWithoutSendingCancellation() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { + var pending = new FutureTask<>(() -> endpoint.request("interrupted")); + Thread caller = start(pending); + final RequestReplyMessage interrupted = transport.next(); + + caller.interrupt(); + + ExecutionException failure = assertThrows(ExecutionException.class, () -> result(pending)); + assertInstanceOf(InterruptedException.class, failure.getCause()); + join(caller); + assertTrue(transport.sent.isEmpty()); + Future next = async(() -> endpoint.request("next")); + RequestReplyMessage request = transport.next(); + transport.deliver(interrupted.response("too late")); + assertFalse(next.isDone()); + transport.deliver(request.response("next reply")); + assertEquals("next reply", result(next)); + } + } + + @Test + void rejectsExcessPendingRequestsBeforeSendingAndReusesReleasedSlots() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = RequestReplyEndpoint.open(transport, options(2, 1))) { + final Future first = async(() -> endpoint.request("first")); + final RequestReplyMessage firstRequest = transport.next(); + final Future second = async(() -> endpoint.request("second")); + RequestReplyMessage secondRequest = transport.next(); + + failure(RequestReplyException.Reason.OVERLOADED, () -> endpoint.request("excess")); + assertTrue(transport.sent.isEmpty()); + transport.deliver(secondRequest.response("second reply")); + assertEquals("second reply", result(second)); + + final Future third = async(() -> endpoint.request("third")); + RequestReplyMessage thirdRequest = transport.next(); + assertFalse(first.isDone()); + transport.deliver(thirdRequest.response("third reply")); + transport.deliver(firstRequest.response("first reply")); + assertEquals("third reply", result(third)); + assertEquals("first reply", result(first)); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void rejectsExcessHandlersWithoutQueueingAndReusesTheSlotAfterExit() throws Exception { + var transport = new MemoryTransport(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var calls = new AtomicInteger(); + var handlerThread = new AtomicReference(); + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1)); + var registration = endpoint.handle(payload -> { + calls.incrementAndGet(); + handlerThread.set(Thread.currentThread()); + entered.countDown(); + await(release); + return "reply:" + payload; + })) { + try { + var first = request("first"); + transport.deliver(first); + await(entered); + assertTrue(handlerThread.get().isVirtual()); + var excess = request("excess"); + + transport.deliver(excess); + assertEquals(excess.failure(RequestReplyMessage.Error.OVERLOADED), transport.next()); + transport.deliver(first); + assertEquals(1, calls.get()); + assertTrue(transport.sent.isEmpty()); + + release.countDown(); + assertEquals(first.response("reply:first"), transport.next()); + join(handlerThread.get()); + var next = request("next"); + transport.deliver(next); + assertEquals(next.response("reply:next"), transport.next()); + join(handlerThread.get()); + assertEquals(2, calls.get()); + assertTrue(transport.sent.isEmpty()); + } finally { + release.countDown(); + } + } + } + + @Test + void treatsIncomingRequestIdentityAsThePairOfOriginAndRequestId() throws Exception { + var transport = new MemoryTransport(); + var entered = new CountDownLatch(2); + var release = new CountDownLatch(1); + var calls = new AtomicInteger(); + var handlerThreads = new ConcurrentHashMap(); + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 2)); + var registration = endpoint.handle(payload -> { + calls.incrementAndGet(); + handlerThreads.put(payload, Thread.currentThread()); + entered.countDown(); + await(release); + return "reply:" + payload; + })) { + try { + UUID requestId = UUID.randomUUID(); + var first = RequestReplyMessage.request(requestId, UUID.randomUUID(), "first origin"); + var second = RequestReplyMessage.request(requestId, UUID.randomUUID(), "second origin"); + transport.deliver(first); + transport.deliver(second); + await(entered); + transport.deliver(first); + transport.deliver(second); + assertEquals(2, calls.get()); + assertTrue(transport.sent.isEmpty()); + + release.countDown(); + + assertEquals(Set.of(first.response("reply:first origin"), second.response("reply:second origin")), + Set.of(transport.next(), transport.next())); + for (Thread thread : handlerThreads.values()) { + join(thread); + } + assertEquals(2, calls.get()); + assertTrue(transport.sent.isEmpty()); + } finally { + release.countDown(); + } + } + } + + @Test + void closingAnOldRegistrationCannotRemoveItsReplacementOrCancelAcceptedWork() throws Exception { + var transport = new MemoryTransport(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 2))) { + try (var original = endpoint.handle(payload -> { + entered.countDown(); + await(release); + return "original reply"; + })) { + try { + assertThrows(IllegalStateException.class, () -> endpoint.handle(payload -> "duplicate handler")); + var accepted = request("accepted"); + transport.deliver(accepted); + await(entered); + original.close(); + try (var replacement = endpoint.handle(payload -> "replacement reply")) { + original.close(); + var next = request("next"); + transport.deliver(next); + assertEquals(next.response("replacement reply"), transport.next()); + release.countDown(); + assertEquals(accepted.response("original reply"), transport.next()); + assertEquals(1, transport.listeners.size()); + assertEquals(0, transport.subscriptionCloses.get()); + } + var unhandled = request("unhandled"); + transport.deliver(unhandled); + assertEquals(unhandled.failure(RequestReplyMessage.Error.NO_HANDLER), transport.next()); + } finally { + release.countDown(); + } + } + } + } + + @Test + void closingFailsPendingWorkCancelsHandlersAndOnlyClosesItsOwnSubscription() throws Exception { + try (var transport = new MemoryTransport(); + var endpoint = RequestReplyEndpoint.open(transport); + var other = RequestReplyEndpoint.open(transport)) { + var entered = new CountDownLatch(1); + var interrupted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var handlerThread = new AtomicReference(); + try (var registration = endpoint.handle(payload -> { + handlerThread.set(Thread.currentThread()); + entered.countDown(); + try { + await(release); + } catch (InterruptedException e) { + interrupted.countDown(); + } + return "stale reply"; + })) { + try { + final Future pending = async(() -> endpoint.request("pending")); + RequestReplyMessage outgoing = transport.next(); + RequestReplyTransport.Listener oldListener = transport.listeners.get(outgoing.replyTo()); + assertNotNull(oldListener); + oldListener.onMessage(request("incoming")); + await(entered); + + endpoint.close(); + + failure(RequestReplyException.Reason.CLOSED, pending); + await(interrupted); + join(handlerThread.get()); + assertEquals(1, transport.subscriptionCloses.get()); + assertEquals(1, transport.listeners.size()); + assertFalse(transport.closed); + assertFalse(transport.listeners.containsKey(outgoing.replyTo())); + failure(RequestReplyException.Reason.CLOSED, () -> endpoint.request("closed")); + assertThrows(IllegalStateException.class, () -> endpoint.handle(payload -> payload)); + oldListener.onMessage(request("after close")); + oldListener.onMessage(outgoing.response("late reply")); + oldListener.onDisconnect(); + endpoint.close(); + assertEquals(1, transport.subscriptionCloses.get()); + assertTrue(transport.sent.isEmpty()); + + Future surviving = async(() -> other.request("surviving endpoint")); + RequestReplyMessage request = transport.next(); + transport.deliver(request.response("still open")); + assertEquals("still open", result(surviving)); + } finally { + release.countDown(); + } + } + } + } + + @Test + void disconnectFailsAllPendingRequestsAndAllowsFreshWorkWithoutReplayingOldRequests() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = RequestReplyEndpoint.open(transport, options(2, 1))) { + Future first = async(() -> endpoint.request("first")); + final RequestReplyMessage firstRequest = transport.next(); + Future second = async(() -> endpoint.request("second")); + final RequestReplyMessage secondRequest = transport.next(); + + transport.disconnect(); + + failure(RequestReplyException.Reason.DISCONNECTED, first); + failure(RequestReplyException.Reason.DISCONNECTED, second); + assertTrue(transport.sent.isEmpty()); + final Future fresh = async(() -> endpoint.request("fresh")); + RequestReplyMessage freshRequest = transport.next(); + assertEquals("fresh", freshRequest.payload()); + assertEquals(firstRequest.replyTo(), freshRequest.replyTo()); + transport.deliver(firstRequest.response("old first reply")); + transport.deliver(secondRequest.failure(RequestReplyMessage.Error.HANDLER_FAILED)); + assertFalse(fresh.isDone()); + transport.deliver(freshRequest.response("fresh reply")); + + assertEquals("fresh reply", result(fresh)); + assertTrue(transport.sent.isEmpty()); + assertEquals(0, transport.subscriptionCloses.get()); + assertEquals(1, transport.listeners.size()); + } + } + + @Test + void disconnectKeepsInterruptIgnoringHandlersCountedAndInvalidatesTheirReplies() throws Exception { + var transport = new MemoryTransport(); + transport.sender = message -> { + transport.deliver(message); + return CompletableFuture.completedFuture(null); + }; + var entered = new CountDownLatch(1); + var interrupted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var calls = new AtomicInteger(); + var oldHandler = new AtomicReference(); + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1)); + var registration = endpoint.handle(payload -> { + calls.incrementAndGet(); + if ("old".equals(payload)) { + oldHandler.set(Thread.currentThread()); + entered.countDown(); + awaitIgnoringInterrupts(release, interrupted); + } + return "reply:" + payload; + })) { + try { + Future pending = async(() -> endpoint.request("old")); + final RequestReplyMessage oldRequest = transport.next(); + await(entered); + + transport.disconnect(); + + failure(RequestReplyException.Reason.DISCONNECTED, pending); + await(interrupted); + for (int index = 0; index < 4; index++) { + transport.disconnect(); + RequestReplyException failure = failure(RequestReplyException.Reason.REMOTE_ERROR, + () -> endpoint.request("excess")); + assertEquals(RequestReplyMessage.Error.OVERLOADED, failure.remoteError()); + RequestReplyMessage excess = transport.next(); + assertEquals(excess.failure(RequestReplyMessage.Error.OVERLOADED), transport.next()); + assertEquals(1, calls.get()); + assertTrue(oldHandler.get().isAlive()); + } + + release.countDown(); + join(oldHandler.get()); + assertTrue(transport.sent.isEmpty()); + transport.deliver(oldRequest.response("late reply")); + assertEquals("reply:fresh", endpoint.request("fresh")); + RequestReplyMessage fresh = transport.next(); + assertEquals(fresh.response("reply:fresh"), transport.next()); + assertEquals(2, calls.get()); + assertEquals(1, transport.listeners.size()); + } finally { + release.countDown(); + } + } + } + + @Test + void rejectsInvalidRequestArgumentsBeforeSendingOrConsumingCapacity() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { + assertThrows(NullPointerException.class, () -> endpoint.request(null)); + assertThrows(NullPointerException.class, () -> endpoint.request("request", null)); + assertThrows(NullPointerException.class, () -> endpoint.handle(null)); + for (Duration timeout : List.of(Duration.ZERO, Duration.ofNanos(-1), Duration.ofSeconds(Long.MAX_VALUE))) { + assertThrows(IllegalArgumentException.class, () -> endpoint.request("request", timeout)); + } + assertTrue(transport.sent.isEmpty()); + transport.sender = message -> { + transport.deliver(message.response("valid reply")); + return CompletableFuture.completedFuture(null); + }; + assertEquals("valid reply", endpoint.request("valid", WAIT)); + } + } + + @Test + void propagatesSubscriptionFailureBeforePublishingAnEndpoint() { + var expected = new RequestReplyException(RequestReplyException.Reason.DISCONNECTED); + var transport = new RequestReplyTransport() { + @NotNull + @Override + public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener listener) + throws RequestReplyException { + throw expected; + } + + @NotNull + @Override + public CompletionStage send(@NotNull RequestReplyMessage message) { + throw new AssertionError("Subscription failure must prevent sending"); + } + }; + + assertSame(expected, assertThrows(RequestReplyException.class, () -> RequestReplyEndpoint.open(transport))); + assertThrows(NullPointerException.class, () -> RequestReplyEndpoint.open(null)); + assertThrows(NullPointerException.class, () -> RequestReplyEndpoint.open(new MemoryTransport(), null)); + } + + @NotNull + private static RequestReplyOptions options(int pending, int handlers) { + return new RequestReplyOptions(WAIT, pending, handlers); + } + + @NotNull + private static RequestReplyMessage request(@NotNull String payload) { + return RequestReplyMessage.request(UUID.randomUUID(), UUID.randomUUID(), payload); + } + + @NotNull + private FutureTask async(@NotNull Callable action) { + var result = new FutureTask<>(action); + start(result); + return result; + } + + @NotNull + private Thread start(@NotNull Runnable action) { + Thread thread = Thread.ofVirtual().start(action); + callers.add(thread); + return thread; + } + + @Nullable + private static T result(@NotNull Future future) throws InterruptedException, ExecutionException, TimeoutException { + return future.get(WAIT.toMillis(), TimeUnit.MILLISECONDS); + } + + @NotNull + private static RequestReplyException failure(@NotNull RequestReplyException.Reason reason, @NotNull Executable action) { + RequestReplyException failure = assertThrows(RequestReplyException.class, action); + assertEquals(reason, failure.reason()); + return failure; + } + + @NotNull + private static RequestReplyException failure(@NotNull RequestReplyException.Reason reason, @NotNull Future future) { + ExecutionException wrapper = assertThrows(ExecutionException.class, () -> result(future)); + RequestReplyException failure = assertInstanceOf(RequestReplyException.class, wrapper.getCause()); + assertEquals(reason, failure.reason()); + return failure; + } + + private static void await(@NotNull CountDownLatch latch) throws InterruptedException { + assertTrue(latch.await(WAIT.toMillis(), TimeUnit.MILLISECONDS), "Latch was not released"); + } + + private static void join(@NotNull Thread thread) throws InterruptedException { + assertTrue(thread.join(WAIT), "Thread did not finish"); + } + + private static void awaitIgnoringInterrupts(@NotNull CountDownLatch release, @NotNull CountDownLatch interrupted) { + long deadline = System.nanoTime() + WAIT.toNanos(); + boolean released = false; + while (!released) { + try { + released = release.await(Math.max(0, deadline - System.nanoTime()), TimeUnit.NANOSECONDS); + assertTrue(released, "Interrupt-ignoring handler was not released"); + } catch (InterruptedException e) { + interrupted.countDown(); + } + } + } + + private static void connect(@NotNull MemoryTransport first, @NotNull MemoryTransport second) { + first.sender = message -> { + second.deliver(message); + return CompletableFuture.completedFuture(null); + }; + second.sender = message -> { + first.deliver(message); + return CompletableFuture.completedFuture(null); + }; + } + + private static final class MemoryTransport implements RequestReplyTransport, AutoCloseable { + private final Map> listeners = new ConcurrentHashMap<>(); + private final BlockingQueue> sent = new LinkedBlockingQueue<>(); + private final AtomicInteger subscriptionCloses = new AtomicInteger(); + private volatile Function, CompletionStage> sender = + message -> CompletableFuture.completedFuture(null); + private boolean closed; + + @NotNull + @Override + public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener listener) { + assertFalse(closed); + assertNull(listeners.putIfAbsent(endpointId, listener)); + return () -> { + if (listeners.remove(endpointId, listener)) { + subscriptionCloses.incrementAndGet(); + } + }; + } + + @NotNull + @Override + public CompletionStage send(@NotNull RequestReplyMessage message) { + assertFalse(closed); + sent.add(message); + return sender.apply(message); + } + + private void deliver(@NotNull RequestReplyMessage message) { + List.copyOf(listeners.values()).forEach(listener -> listener.onMessage(message)); + } + + private void disconnect() { + List.copyOf(listeners.values()).forEach(Listener::onDisconnect); + } + + @NotNull + private RequestReplyMessage next() throws InterruptedException { + RequestReplyMessage message = sent.poll(WAIT.toMillis(), TimeUnit.MILLISECONDS); + assertNotNull(message, "No message was sent"); + return message; + } + + @Override + public void close() { + closed = true; + listeners.clear(); + } + } +} diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java new file mode 100644 index 0000000..3ef5d16 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java @@ -0,0 +1,124 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@Timeout(10) +class RequestReplyMessageTest { + private static final UUID REQUEST_ID = UUID.randomUUID(); + private static final UUID ENDPOINT_ID = UUID.randomUUID(); + + @Test + void preservesTheOpaquePayloadAndCorrelationWhenCreatingReplies() { + Object payload = new Object(); + RequestReplyMessage request = RequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, payload); + + assertEquals(REQUEST_ID, request.requestId()); + assertEquals(ENDPOINT_ID, request.replyTo()); + assertEquals(RequestReplyMessage.Type.REQUEST, request.type()); + assertSame(payload, request.payload()); + assertNull(request.error()); + + Object result = new Object(); + RequestReplyMessage response = request.response(result); + + assertEquals(REQUEST_ID, response.requestId()); + assertEquals(ENDPOINT_ID, response.replyTo()); + assertEquals(RequestReplyMessage.Type.RESPONSE, response.type()); + assertSame(result, response.payload()); + assertNull(response.error()); + assertSame(payload, request.payload()); + } + + @Test + void permitsANullResponseWithoutTurningItIntoAnError() { + RequestReplyMessage response = RequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, "request").response(null); + + assertEquals(REQUEST_ID, response.requestId()); + assertEquals(ENDPOINT_ID, response.replyTo()); + assertEquals(RequestReplyMessage.Type.RESPONSE, response.type()); + assertNull(response.payload()); + assertNull(response.error()); + } + + @Test + void exposesOnlyTheErrorCodeAndOriginalCorrelationInFailures() { + var request = RequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, "private request"); + for (RequestReplyMessage.Error error : RequestReplyMessage.Error.values()) { + RequestReplyMessage failure = request.failure(error); + + assertEquals(REQUEST_ID, failure.requestId()); + assertEquals(ENDPOINT_ID, failure.replyTo()); + assertEquals(RequestReplyMessage.Type.ERROR, failure.type()); + assertEquals(error, failure.error()); + assertNull(failure.payload()); + } + } + + @Test + void rejectsMissingCorrelationAndType() { + assertThrows(NullPointerException.class, + () -> new RequestReplyMessage<>(null, ENDPOINT_ID, RequestReplyMessage.Type.RESPONSE, null, null)); + assertThrows(NullPointerException.class, + () -> new RequestReplyMessage<>(REQUEST_ID, null, RequestReplyMessage.Type.RESPONSE, null, null)); + assertThrows(NullPointerException.class, + () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, null, null, null)); + assertThrows(NullPointerException.class, () -> RequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, null)); + } + + @Test + void rejectsMalformedPayloadAndErrorCombinations() { + assertThrows(IllegalArgumentException.class, + () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, RequestReplyMessage.Type.REQUEST, null, null)); + assertThrows(IllegalArgumentException.class, + () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, RequestReplyMessage.Type.ERROR, null, null)); + for (RequestReplyMessage.Error error : RequestReplyMessage.Error.values()) { + assertThrows(IllegalArgumentException.class, + () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, RequestReplyMessage.Type.REQUEST, "request", error)); + assertThrows(IllegalArgumentException.class, + () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, RequestReplyMessage.Type.REQUEST, null, error)); + assertThrows(IllegalArgumentException.class, + () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, RequestReplyMessage.Type.RESPONSE, "response", error)); + assertThrows(IllegalArgumentException.class, + () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, RequestReplyMessage.Type.RESPONSE, null, error)); + assertThrows(IllegalArgumentException.class, + () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, RequestReplyMessage.Type.ERROR, "private payload", error)); + } + } + + @Test + void onlyAllowsRequestsToProduceReplies() { + var request = RequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, "request"); + assertThrows(NullPointerException.class, () -> request.failure(null)); + for (RequestReplyMessage reply : List.of( + request.response("response"), request.response(null), request.failure(RequestReplyMessage.Error.NO_HANDLER) + )) { + assertThrows(IllegalStateException.class, () -> reply.response("another response")); + assertThrows(IllegalStateException.class, () -> reply.failure(RequestReplyMessage.Error.HANDLER_FAILED)); + } + } +} diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java new file mode 100644 index 0000000..0403d4e --- /dev/null +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java @@ -0,0 +1,65 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.requestreply; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.time.Duration; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(10) +class RequestReplyOptionsTest { + @Test + void acceptsPositiveLimitsAndRepresentableTimeoutBoundaries() { + for (Duration timeout : List.of(Duration.ofNanos(1), Duration.ofNanos(Long.MAX_VALUE))) { + for (int limit : List.of(1, Integer.MAX_VALUE)) { + var options = new RequestReplyOptions(timeout, limit, limit); + + assertEquals(timeout, options.timeout()); + assertEquals(limit, options.maxPendingRequests()); + assertEquals(limit, options.maxConcurrentHandlers()); + } + } + assertTrue(RequestReplyOptions.DEFAULT.timeout().isPositive()); + assertTrue(RequestReplyOptions.DEFAULT.maxPendingRequests() > 0); + assertTrue(RequestReplyOptions.DEFAULT.maxConcurrentHandlers() > 0); + } + + @Test + void rejectsNonPositiveAndOverflowingTimeouts() { + for (Duration timeout : List.of( + Duration.ZERO, Duration.ofNanos(-1), Duration.ofSeconds(-1), + Duration.ofSeconds(Long.MAX_VALUE), Duration.ofSeconds(Long.MIN_VALUE) + )) { + assertThrows(IllegalArgumentException.class, () -> new RequestReplyOptions(timeout, 1, 1)); + } + assertThrows(NullPointerException.class, () -> new RequestReplyOptions(null, 1, 1)); + } + + @Test + void rejectsNonPositivePendingAndHandlerLimitsIndependently() { + for (int limit : List.of(0, -1, Integer.MIN_VALUE)) { + assertThrows(IllegalArgumentException.class, () -> new RequestReplyOptions(Duration.ofSeconds(1), limit, 1)); + assertThrows(IllegalArgumentException.class, () -> new RequestReplyOptions(Duration.ofSeconds(1), 1, limit)); + } + } +} diff --git a/apis/pom.xml b/apis/pom.xml index 8ddcc48..17217d8 100644 --- a/apis/pom.xml +++ b/apis/pom.xml @@ -19,6 +19,7 @@ DataDam API + com.dbeaver.messaging com.dbeaver.datadam.dm.api com.dbeaver.datadam.external.api com.dbeaver.datadam.share.api From 21477d0478912601d7dc28f11708b5a90d9b5407 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Wed, 21 Oct 2026 14:39:09 +0400 Subject: [PATCH 07/30] dbeaver/pro#11108 Add missing interface Javadoc --- .../external/api/requestreply/DDRequestReplyTransport.java | 2 ++ .../datadam/external/api/transport/DDConnectionListener.java | 1 + .../datadam/external/api/transport/DDMessageChannel.java | 1 + .../datadam/external/api/transport/DDMessageListener.java | 1 + .../dbeaver/datadam/external/api/transport/DDSubscription.java | 1 + .../com/dbeaver/messaging/requestreply/MessageSubscription.java | 1 + .../java/com/dbeaver/messaging/requestreply/RequestHandler.java | 1 + .../dbeaver/messaging/requestreply/RequestReplyTransport.java | 2 ++ 8 files changed, 10 insertions(+) diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java index 1be6b5e..3f4354c 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java @@ -60,12 +60,14 @@ public final class DDRequestReplyTransport implements RequestReplyTransport send(@NotNull DDMessage message); } + /** Registers topic listeners for a selected peer. */ @FunctionalInterface public interface TopicSubscriber { @NotNull diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java index d9c71a1..5e3f9cf 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java @@ -19,6 +19,7 @@ import com.dbeaver.datadam.external.api.model.DDConnectionInfo; import org.jkiss.code.NotNull; +/** Receives physical connection lifecycle notifications. */ public interface DDConnectionListener { void onConnected(@NotNull DDConnectionInfo connection); diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java index 297cedc..a358840 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java @@ -22,6 +22,7 @@ import java.util.concurrent.CompletionStage; import java.util.function.Consumer; +/** Provides asynchronous DataDam messaging and connection-state observation. */ public interface DDMessageChannel extends AutoCloseable { @NotNull DDChannelStatus getStatus(); diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java index c458c40..bbe4c5b 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java @@ -20,6 +20,7 @@ import com.dbeaver.datadam.external.api.model.DDMessage; import org.jkiss.code.NotNull; +/** Receives topic messages with their source connection. */ @FunctionalInterface public interface DDMessageListener { void onMessage(@NotNull DDConnectionInfo connection, @NotNull DDMessage message); diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java index 880435c..a10a488 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java @@ -16,6 +16,7 @@ */ package com.dbeaver.datadam.external.api.transport; +/** Controls the lifetime of a DataDam listener registration. */ @FunctionalInterface public interface DDSubscription extends AutoCloseable { @Override diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java index 515f50c..3f3daad 100644 --- a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java @@ -16,6 +16,7 @@ */ package com.dbeaver.messaging.requestreply; +/** Controls the lifetime of a listener or handler registration. */ @FunctionalInterface public interface MessageSubscription extends AutoCloseable { @Override diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java index a57dc97..eb12162 100644 --- a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java @@ -19,6 +19,7 @@ import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; +/** Handles incoming requests and returns response payloads. */ @FunctionalInterface public interface RequestHandler

{ @Nullable diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java index b598053..b162362 100644 --- a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java @@ -21,6 +21,7 @@ import java.util.UUID; import java.util.concurrent.CompletionStage; +/** Provides asynchronous request/reply message transport for a selected peer. */ public interface RequestReplyTransport

{ @NotNull MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener

listener) throws RequestReplyException; @@ -28,6 +29,7 @@ public interface RequestReplyTransport

{ @NotNull CompletionStage send(@NotNull RequestReplyMessage

message); + /** Receives endpoint messages and disconnection notifications. */ interface Listener

{ void onMessage(@NotNull RequestReplyMessage

message); From 6edb503c35528c845fe8658abdc6bb5b7119decf Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Wed, 21 Oct 2026 15:32:25 +0400 Subject: [PATCH 08/30] dbeaver/pro#11108 Add transport-independent typed RPC --- .../META-INF/MANIFEST.MF | 1 + .../README.md | 70 ++ .../external/api/rpc/DDRpcTransport.java | 170 ++++ .../external/api/rpc/DDRpcTransportTest.java | 861 ++++++++++++++++ .../META-INF/MANIFEST.MF | 3 +- apis/com.dbeaver.messaging/README.md | 95 +- .../com/dbeaver/messaging/rpc/RpcCodec.java | 31 + .../dbeaver/messaging/rpc/RpcContract.java | 156 +++ .../dbeaver/messaging/rpc/RpcEndpoint.java | 271 ++++++ .../dbeaver/messaging/rpc/RpcException.java | 70 ++ .../com/dbeaver/messaging/rpc/RpcMessage.java | 73 ++ .../messaging/rpc/RpcContractTest.java | 399 ++++++++ .../messaging/rpc/RpcEndpointTest.java | 921 ++++++++++++++++++ .../dbeaver/messaging/rpc/RpcMessageTest.java | 102 ++ .../dbeaver/messaging/rpc/RpcTestSupport.java | 197 ++++ .../rpc/fixtures/HiddenInheritedService.java | 24 + .../rpc/fixtures/PublicInheritedService.java | 21 + 17 files changed, 3462 insertions(+), 3 deletions(-) create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java create mode 100644 apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java create mode 100644 apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java create mode 100644 apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java create mode 100644 apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcException.java create mode 100644 apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java create mode 100644 apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java create mode 100644 apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java create mode 100644 apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java create mode 100644 apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java create mode 100644 apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java create mode 100644 apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java diff --git a/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF b/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF index 5ab6738..cc45454 100644 --- a/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF +++ b/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF @@ -10,6 +10,7 @@ Export-Package: com.dbeaver.datadam.external.api, com.dbeaver.datadam.external.api.exception, com.dbeaver.datadam.external.api.model, com.dbeaver.datadam.external.api.requestreply, + com.dbeaver.datadam.external.api.rpc, com.dbeaver.datadam.external.api.transport Bundle-ActivationPolicy: lazy Require-Bundle: org.jkiss.utils, diff --git a/apis/com.dbeaver.datadam.external.api/README.md b/apis/com.dbeaver.datadam.external.api/README.md index 81b92e5..1aac21d 100644 --- a/apis/com.dbeaver.datadam.external.api/README.md +++ b/apis/com.dbeaver.datadam.external.api/README.md @@ -164,3 +164,73 @@ channel or subscription facade. Closing the subscription facade rejects new back from starting; an already claimed HTTP send can finish. Physical channels report disconnects immediately; backend callbacks have no physical connection-state feed, so pending calls use their deadlines when the peer disappears. Timeout or interruption can leave the remote outcome unknown. No request or response is automatically retried. + +## Typed RPC + +`com.dbeaver.messaging.rpc` adds typed Java proxies and registered implementations without depending on DataDam or +WebSocket. `DDRpcTransport` adapts the existing request/reply transport to the neutral `RpcMessage` envelope. +It contains only protocol framing; application serialization is selected by the developer through `RpcCodec`. + +For example, an application can choose Gson once for an endpoint: + +```java +Gson gson = new Gson(); +RpcCodec codec = new RpcCodec<>() { + @Override + public JsonElement encode(Object value, Type type) { + return gson.toJsonTree(value, type); + } + + @Override + public Object decode(JsonElement payload, Type type) { + return gson.fromJson(payload, type); + } +}; +``` + +The Desktop side installs an implementation and can independently obtain proxies for backend services: + +```java +var transport = DDRpcTransport.forChannel(channel, "rpc.account"); +try (var rpc = RpcEndpoint.open(transport, codec); + var registration = rpc.register("desktop.projects", DesktopProjects.class, implementation)) { + ServerDocuments documents = rpc.proxy("server.documents", ServerDocuments.class); + serveApplication(documents); +} +``` + +The backend selects its physical account/connection scope once and uses the same endpoint API: + +```java +var scope = subscriptions.forConnection(accountId, connectionId); +var transport = new DDRpcTransport(scope.requestReply("rpc.account")); +try (var rpc = RpcEndpoint.open(transport, codec); + var registration = rpc.register("server.documents", ServerDocuments.class, implementation)) { + DesktopProjects projects = rpc.proxy("desktop.projects", DesktopProjects.class); + serveApplication(projects); +} +``` + +Use the same underlying request/reply topic (`"rpc.account"` here) at both ends of the selected channel. Logical service topics +such as `desktop.projects` and `server.documents` are multiplexed inside that lane; they do not open connections or add +per-service transport subscriptions. Registering a local service never implicitly registers a service on the other peer. +The existing private reply topic continues to route each answer back to the exact initiating process. + +Each lane has one receiving RPC endpoint at each peer. If Account, Storage, or other backend processes share one Desktop +connection, use separate lanes such as `rpc.account` and `rpc.storage`, with a corresponding Desktop endpoint for each. +Sharing a lane between multiple backend receivers is not remote service discovery: unrelated receivers could answer +`UNKNOWN_SERVICE` before the intended service. Replica routing and callback ownership retain the existing process-local +subscription constraints. + +The inner JSON envelope uses `protocol: "dbeaver.rpc.v1"`, `type`, and `data`, plus `topic` and `operation` for requests +or a fixed `error` code for failures. Application `data` can be any JSON value, including null; the outer DataDam message +remains an object and retains its existing request/reply correlation and source validation. Malformed inner envelopes +are ignored without exposing raw contents. Transport validation and RPC validation do not replace authentication. + +The adapter bounds framing, snapshot copying, and outstanding sends to 128 operations without a waiting queue. +Cancellation of a returned send future does not free its slot until the underlying send actually finishes. + +Interfaces declare ordinary one-argument methods returning values or `void`; applications do not implement transport +listeners. A `void` call waits for handler completion. Local serialization errors and transport failures become unchecked +`RpcException`; remote handler exceptions are represented only by safe error codes. See the messaging module's README +for supported contract shapes, ownership, concurrent codec/handler requirements, and timeout semantics. diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java new file mode 100644 index 0000000..d650fca --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java @@ -0,0 +1,170 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.rpc; + +import com.dbeaver.datadam.external.api.requestreply.DDRequestReplyTransport; +import com.dbeaver.datadam.external.api.transport.DDMessageChannel; +import com.dbeaver.messaging.requestreply.MessageSubscription; +import com.dbeaver.messaging.requestreply.RequestReplyException; +import com.dbeaver.messaging.requestreply.RequestReplyMessage; +import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import com.dbeaver.messaging.rpc.RpcMessage; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Semaphore; + +public final class DDRpcTransport implements RequestReplyTransport> { + private static final String PROTOCOL = "dbeaver.rpc.v1"; + private static final int MAX_PENDING_SENDS = 128; + private final Semaphore sendBudget = new Semaphore(MAX_PENDING_SENDS); + private final RequestReplyTransport delegate; + + public DDRpcTransport(@NotNull RequestReplyTransport delegate) { + this.delegate = Objects.requireNonNull(delegate); + } + + @NotNull + public static DDRpcTransport forChannel(@NotNull DDMessageChannel channel, @NotNull String topic) { + return new DDRpcTransport(DDRequestReplyTransport.forChannel(channel, topic)); + } + + @Override + @NotNull + public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) + throws RequestReplyException { + Objects.requireNonNull(endpointId); + Objects.requireNonNull(listener); + return delegate.subscribe(endpointId, new Listener<>() { + @Override + public void onMessage(@NotNull RequestReplyMessage message) { + RequestReplyMessage> decoded = decode(message); + if (decoded != null) { + listener.onMessage(decoded); + } + } + + @Override + public void onDisconnect() { + listener.onDisconnect(); + } + }); + } + + @Override + @NotNull + public CompletionStage send(@NotNull RequestReplyMessage> message) { + if (!sendBudget.tryAcquire()) { + return CompletableFuture.failedFuture(new RequestReplyException(RequestReplyException.Reason.OVERLOADED)); + } + try { + RpcMessage payload = message.payload(); + if (payload != null) { + requireType(message.type(), payload.type()); + } + RequestReplyMessage frame = new RequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), + payload == null ? null : encode(payload), message.error()); + CompletableFuture result = new CompletableFuture<>(); + Objects.requireNonNull(delegate.send(frame)).whenComplete((ignored, error) -> { + sendBudget.release(); + if (error == null) { + result.complete(null); + } else { + result.completeExceptionally(error); + } + }); + return result; + } catch (RuntimeException | Error e) { + sendBudget.release(); + return CompletableFuture.failedFuture(e); + } + } + + @NotNull + private static JsonObject encode(@NotNull RpcMessage message) { + JsonObject envelope = new JsonObject(); + envelope.addProperty("protocol", PROTOCOL); + envelope.addProperty("type", message.type().name()); + if (message.type() == RpcMessage.Type.ERROR) { + envelope.addProperty("error", Objects.requireNonNull(message.error()).name()); + } else { + if (message.type() == RpcMessage.Type.REQUEST) { + envelope.addProperty("topic", message.topic()); + envelope.addProperty("operation", message.operation()); + } + envelope.add("data", message.payload() == null ? JsonNull.INSTANCE : message.payload().deepCopy()); + } + return envelope; + } + + @Nullable + private static RequestReplyMessage> decode(@NotNull RequestReplyMessage message) { + if (message.type() == RequestReplyMessage.Type.ERROR || message.payload() == null) { + return new RequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), null, message.error()); + } + try { + JsonObject envelope = message.payload(); + if (!PROTOCOL.equals(string(envelope, "protocol"))) { + return null; + } + RpcMessage.Type type = RpcMessage.Type.valueOf(string(envelope, "type")); + requireType(message.type(), type); + RpcMessage payload; + if (type == RpcMessage.Type.ERROR) { + if (envelope.size() != 3) { + return null; + } + payload = RpcMessage.failure(RpcMessage.Error.valueOf(string(envelope, "error"))); + } else { + JsonElement data = envelope.get("data"); + if (data == null || envelope.size() != (type == RpcMessage.Type.REQUEST ? 5 : 3)) { + return null; + } + JsonElement snapshot = data.isJsonNull() ? null : data.deepCopy(); + payload = type == RpcMessage.Type.REQUEST + ? RpcMessage.request(string(envelope, "topic"), string(envelope, "operation"), snapshot) + : RpcMessage.response(snapshot); + } + return new RequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), payload, message.error()); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static void requireType(@NotNull RequestReplyMessage.Type outer, @NotNull RpcMessage.Type inner) { + if (outer == RequestReplyMessage.Type.REQUEST ? inner != RpcMessage.Type.REQUEST + : outer != RequestReplyMessage.Type.RESPONSE || inner == RpcMessage.Type.REQUEST) { + throw new IllegalArgumentException("RPC message type does not match its request/reply frame"); + } + } + + @NotNull + private static String string(@NotNull JsonObject envelope, @NotNull String field) { + JsonElement value = envelope.get(field); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException("Invalid RPC field"); + } + return value.getAsString(); + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java new file mode 100644 index 0000000..fa93d53 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java @@ -0,0 +1,861 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.rpc; + +import com.dbeaver.datadam.external.api.model.DDConnectionInfo; +import com.dbeaver.datadam.external.api.model.DDMessage; +import com.dbeaver.datadam.external.api.requestreply.DDRequestReplyTransport; +import com.dbeaver.datadam.external.api.transport.DDChannelState; +import com.dbeaver.datadam.external.api.transport.DDChannelStatus; +import com.dbeaver.datadam.external.api.transport.DDConnectionListener; +import com.dbeaver.datadam.external.api.transport.DDMessageChannel; +import com.dbeaver.datadam.external.api.transport.DDMessageListener; +import com.dbeaver.datadam.external.api.transport.DDSubscription; +import com.dbeaver.messaging.requestreply.MessageSubscription; +import com.dbeaver.messaging.requestreply.RequestReplyEndpoint; +import com.dbeaver.messaging.requestreply.RequestReplyException; +import com.dbeaver.messaging.requestreply.RequestReplyMessage; +import com.dbeaver.messaging.requestreply.RequestReplyOptions; +import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import com.dbeaver.messaging.rpc.RpcCodec; +import com.dbeaver.messaging.rpc.RpcEndpoint; +import com.dbeaver.messaging.rpc.RpcException; +import com.dbeaver.messaging.rpc.RpcMessage; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.lang.reflect.Type; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +@Timeout(10) +public class DDRpcTransportTest { + private static final String LANE = "rpc"; + private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("desktop", "run", "socket"); + private static final RequestReplyOptions OPTIONS = new RequestReplyOptions(Duration.ofSeconds(2), 16, 8); + private static final RpcCodec CODEC = new GsonCodec(); + + @Test + void multiplexesIndependentBidirectionalServicesOnOneBorrowedLane() throws Exception { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = right::deliver; + right.forward = left::deliver; + AtomicInteger nullArguments = new AtomicInteger(); + AtomicInteger notifications = new AtomicInteger(); + final DDSubscription borrowed = left.subscribe("other", (connection, message) -> { }); + RequestReplyTransport backendLane = DDRequestReplyTransport.forChannel(left, LANE); + try (var caller = RpcEndpoint.open(new DDRpcTransport(backendLane), CODEC, OPTIONS); + var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right, LANE), CODEC, OPTIONS); + var leftNames = caller.register("names", Names.class, values -> List.of("left", values.getFirst())); + var rightNames = handler.register("names", Names.class, values -> List.of("right", values.getFirst())); + var otherNames = handler.register("other-names", Names.class, values -> List.of("other")); + var records = handler.register("records", Records.class, value -> new Item(value.name(), List.of("record"))); + var nullable = handler.register("nullable", Text.class, value -> { + if (value == null) { + nullArguments.incrementAndGet(); + } + return null; + }); + var notification = handler.register("notifications", Notification.class, value -> notifications.incrementAndGet())) { + Assertions.assertEquals(List.of("right", "request"), caller.proxy("names", Names.class).apply(List.of("request"))); + Assertions.assertEquals(List.of("left", "reverse"), handler.proxy("names", Names.class).apply(List.of("reverse"))); + Assertions.assertEquals(List.of("other"), caller.proxy("other-names", Names.class).apply(List.of())); + Assertions.assertEquals(new Item("name", List.of("record")), + caller.proxy("records", Records.class).apply(new Item("name", List.of("argument")))); + Assertions.assertNull(caller.proxy("nullable", Text.class).apply(null)); + Assertions.assertEquals(1, nullArguments.get()); + caller.proxy("notifications", Notification.class).accept(null); + Assertions.assertEquals(1, notifications.get()); + Assertions.assertEquals(3, left.subscriptions.size()); + Assertions.assertEquals(2, right.subscriptions.size()); + Assertions.assertEquals(LANE, left.subscriptions.getLast().topic); + Assertions.assertEquals(LANE, right.subscriptions.getLast().topic); + otherNames.close(); + RpcException missing = Assertions.assertThrows(RpcException.class, + () -> caller.proxy("other-names", Names.class).apply(List.of())); + Assertions.assertEquals(RpcMessage.Error.UNKNOWN_SERVICE, missing.remoteError()); + Assertions.assertEquals(List.of("right", "still-open"), + caller.proxy("names", Names.class).apply(List.of("still-open"))); + } + Assertions.assertFalse(left.closed); + Assertions.assertFalse(right.closed); + Assertions.assertTrue(left.observers.isEmpty()); + Assertions.assertTrue(right.observers.isEmpty()); + Assertions.assertTrue(left.subscriptions.getFirst().active); + Assertions.assertEquals(1, left.subscriptions.stream().filter(subscription -> subscription.active).count()); + Assertions.assertTrue(right.subscriptions.stream().noneMatch(subscription -> subscription.active)); + borrowed.close(); + } + + @Test + void isolatesGsonArgumentsAndResultsAcrossRealMessageChannels() throws Exception { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = right::deliver; + right.forward = left::deliver; + AtomicReference received = new AtomicReference<>(); + Item argument = new Item("argument", new ArrayList<>(List.of("initial"))); + try (var caller = RpcEndpoint.open(DDRpcTransport.forChannel(left, LANE), CODEC, OPTIONS); + var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right, LANE), CODEC, OPTIONS); + var service = handler.register("records", Records.class, value -> { + received.set(value); + value.tags().add("handler"); + return value; + })) { + Item result = caller.proxy("records", Records.class).apply(argument); + Assertions.assertEquals(List.of("initial"), argument.tags()); + Assertions.assertEquals(List.of("initial", "handler"), result.tags()); + argument.tags().add("caller"); + Assertions.assertEquals(List.of("initial", "handler"), received.get().tags()); + received.get().tags().add("later"); + Assertions.assertEquals(List.of("initial", "handler"), result.tags()); + result.tags().add("result"); + Assertions.assertEquals(List.of("initial", "handler", "later"), received.get().tags()); + } + } + + @Test + void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRpcTransport adapter = new DDRpcTransport(delegate); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + RequestReplyMessage> request = request(endpoint, new JsonPrimitive("argument")); + try (var subscription = adapter.subscribe(endpoint, received)) { + Assertions.assertEquals(endpoint, delegate.endpoint); + adapter.send(request).toCompletableFuture().join(); + RequestReplyMessage sent = delegate.sent.getLast(); + Assertions.assertEquals(request.requestId(), sent.requestId()); + Assertions.assertEquals(request.replyTo(), sent.replyTo()); + Assertions.assertEquals(RequestReplyMessage.Type.REQUEST, sent.type()); + Assertions.assertEquals("dbeaver.rpc.v1", Objects.requireNonNull(sent.payload()).get("protocol").getAsString()); + delegate.deliver(sent); + Assertions.assertEquals(request, received.messages.getLast()); + RequestReplyMessage> response = request.response(RpcMessage.response(new JsonPrimitive(42))); + adapter.send(response).toCompletableFuture().join(); + delegate.deliver(delegate.sent.getLast()); + Assertions.assertEquals(response, received.messages.getLast()); + for (RpcMessage.Error error : RpcMessage.Error.values()) { + RequestReplyMessage> failure = request.response(RpcMessage.failure(error)); + adapter.send(failure).toCompletableFuture().join(); + RequestReplyMessage wire = delegate.sent.getLast(); + Assertions.assertEquals(request.requestId(), wire.requestId()); + Assertions.assertEquals(request.replyTo(), wire.replyTo()); + Assertions.assertEquals(RequestReplyMessage.Type.RESPONSE, wire.type()); + Assertions.assertNull(wire.error()); + Assertions.assertFalse(Objects.requireNonNull(wire.payload()).has("data")); + Assertions.assertEquals(error.name(), wire.payload().get("error").getAsString()); + delegate.deliver(wire); + Assertions.assertEquals(failure, received.messages.getLast()); + } + for (RequestReplyMessage.Error error : RequestReplyMessage.Error.values()) { + RequestReplyMessage> failure = request.failure(error); + adapter.send(failure).toCompletableFuture().join(); + RequestReplyMessage wire = delegate.sent.getLast(); + Assertions.assertNull(wire.payload()); + Assertions.assertEquals(error, wire.error()); + delegate.deliver(wire); + Assertions.assertEquals(failure, received.messages.getLast()); + } + adapter.send(request.response(null)).toCompletableFuture().join(); + delegate.deliver(delegate.sent.getLast()); + Assertions.assertEquals(request.response(null), received.messages.getLast()); + } + Assertions.assertTrue(delegate.closed); + } + + @Test + void acceptsArbitraryJsonValuesAndNormalizesEncodedNull() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRpcTransport adapter = new DDRpcTransport(delegate); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + List values = List.of(JsonParser.parseString("{\"items\":[1,true,null]}"), + JsonParser.parseString("[\"a\",2,null]"), new JsonPrimitive("text"), new JsonPrimitive(42), + new JsonPrimitive(true), JsonNull.INSTANCE); + try (var subscription = adapter.subscribe(endpoint, received)) { + for (JsonElement value : values) { + RequestReplyMessage> request = request(endpoint, value); + adapter.send(request).toCompletableFuture().join(); + RequestReplyMessage wire = delegate.sent.getLast(); + Assertions.assertEquals(value, Objects.requireNonNull(wire.payload()).get("data")); + delegate.deliver(wire); + Assertions.assertEquals(value.isJsonNull() ? null : value, + Objects.requireNonNull(received.messages.getLast().payload()).payload()); + adapter.send(request.response(RpcMessage.response(value))).toCompletableFuture().join(); + delegate.deliver(delegate.sent.getLast()); + Assertions.assertEquals(RpcMessage.Type.RESPONSE, Objects.requireNonNull(received.messages.getLast().payload()).type()); + Assertions.assertEquals(value.isJsonNull() ? null : value, received.messages.getLast().payload().payload()); + } + } + } + + @Test + void isolatesSnapshotsEvenWhenTheBorrowedDelegateDoesNotCopy() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRpcTransport adapter = new DDRpcTransport(delegate); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + try (var subscription = adapter.subscribe(endpoint, received)) { + for (RequestReplyMessage.Type type : List.of(RequestReplyMessage.Type.REQUEST, RequestReplyMessage.Type.RESPONSE)) { + JsonObject argument = JsonParser.parseString("{\"items\":[\"original\"]}").getAsJsonObject(); + RequestReplyMessage> request = request(endpoint, argument); + adapter.send(type == RequestReplyMessage.Type.REQUEST ? request : request.response(RpcMessage.response(argument))); + JsonObject wire = Objects.requireNonNull(delegate.sent.getLast().payload()); + argument.getAsJsonArray("items").add("caller"); + Assertions.assertEquals(1, wire.getAsJsonObject("data").getAsJsonArray("items").size()); + delegate.deliver(delegate.sent.getLast()); + JsonObject decoded = Objects.requireNonNull(Objects.requireNonNull(received.messages.getLast().payload()).payload()) + .getAsJsonObject(); + wire.getAsJsonObject("data").getAsJsonArray("items").add("delegate"); + Assertions.assertEquals(1, decoded.getAsJsonArray("items").size()); + decoded.getAsJsonArray("items").add("listener"); + Assertions.assertEquals("delegate", wire.getAsJsonObject("data").getAsJsonArray("items").get(1).getAsString()); + Assertions.assertEquals("caller", argument.getAsJsonArray("items").get(1).getAsString()); + } + } + } + + @Test + void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRpcTransport adapter = new DDRpcTransport(delegate); + Received received = new Received(); + UUID endpoint = UUID.randomUUID(); + RequestReplyMessage request = RequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope()); + List nonStrings = List.of(new JsonPrimitive(1), new JsonPrimitive(true), JsonNull.INSTANCE, + new JsonObject(), new JsonArray()); + try (var subscription = adapter.subscribe(endpoint, received)) { + for (String field : List.of("protocol", "type", "topic", "operation")) { + JsonObject missing = requestEnvelope(); + missing.remove(field); + delegate.deliver(RequestReplyMessage.request(request.requestId(), endpoint, missing)); + for (JsonElement value : nonStrings) { + JsonObject invalid = requestEnvelope(); + invalid.add(field, value); + delegate.deliver(RequestReplyMessage.request(request.requestId(), endpoint, invalid)); + } + } + List> malformed = List.of( + envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v2"), + envelope -> envelope.addProperty("protocol", "unrelated"), + envelope -> envelope.addProperty("type", "OTHER"), + envelope -> envelope.addProperty("type", "request"), + envelope -> envelope.addProperty("type", "RESPONSE"), + envelope -> envelope.addProperty("type", "ERROR"), + envelope -> envelope.addProperty("topic", " "), + envelope -> envelope.addProperty("topic", "x".repeat(257)), + envelope -> envelope.addProperty("operation", "\t"), + envelope -> envelope.addProperty("operation", "x".repeat(1025)), + envelope -> envelope.remove("data"), + envelope -> envelope.add("error", JsonNull.INSTANCE), + envelope -> envelope.addProperty("extra", true) + ); + for (Consumer mutation : malformed) { + JsonObject invalid = requestEnvelope(); + mutation.accept(invalid); + delegate.deliver(RequestReplyMessage.request(request.requestId(), endpoint, invalid)); + } + Assertions.assertTrue(received.messages.isEmpty()); + JsonObject boundary = requestEnvelope(); + boundary.addProperty("topic", "x".repeat(256)); + boundary.addProperty("operation", "x".repeat(1024)); + delegate.deliver(RequestReplyMessage.request(request.requestId(), endpoint, boundary)); + Assertions.assertEquals(1, received.messages.size()); + RpcMessage accepted = Objects.requireNonNull(received.messages.getLast().payload()); + Assertions.assertEquals("x".repeat(256), accepted.topic()); + Assertions.assertEquals("x".repeat(1024), accepted.operation()); + Assertions.assertNull(accepted.payload()); + } + } + + @Test + void dropsMalformedResponseAndNestedErrorShapes() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRpcTransport adapter = new DDRpcTransport(delegate); + Received received = new Received(); + UUID endpoint = UUID.randomUUID(); + RequestReplyMessage request = RequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope()); + try (var subscription = adapter.subscribe(endpoint, received)) { + List> malformedResponses = List.of( + envelope -> envelope.remove("data"), + envelope -> envelope.add("topic", JsonNull.INSTANCE), + envelope -> envelope.addProperty("operation", "apply"), + envelope -> envelope.add("error", JsonNull.INSTANCE), + envelope -> envelope.addProperty("protocol", 1), + envelope -> envelope.addProperty("type", true), + envelope -> envelope.addProperty("type", "OTHER") + ); + for (Consumer mutation : malformedResponses) { + JsonObject invalid = responseEnvelope(); + mutation.accept(invalid); + delegate.deliver(request.response(invalid)); + } + delegate.deliver(request.response(requestEnvelope())); + List> malformedErrors = List.of( + envelope -> envelope.remove("error"), + envelope -> envelope.addProperty("error", "OTHER"), + envelope -> envelope.addProperty("error", 1), + envelope -> envelope.addProperty("error", true), + envelope -> envelope.add("error", JsonNull.INSTANCE), + envelope -> envelope.add("error", new JsonObject()), + envelope -> envelope.add("data", JsonNull.INSTANCE), + envelope -> envelope.addProperty("topic", "names"), + envelope -> envelope.add("operation", JsonNull.INSTANCE) + ); + for (Consumer mutation : malformedErrors) { + JsonObject invalid = errorEnvelope(RpcMessage.Error.HANDLER_FAILED); + mutation.accept(invalid); + delegate.deliver(request.response(invalid)); + } + Assertions.assertTrue(received.messages.isEmpty()); + delegate.deliver(request.response(responseEnvelope())); + delegate.deliver(request.response(errorEnvelope(RpcMessage.Error.HANDLER_FAILED))); + Assertions.assertEquals(2, received.messages.size()); + } + } + + @Test + void requiresObjectEnvelopesThroughExistingDataDamFraming() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRpcTransport adapter = DDRpcTransport.forChannel(channel, LANE); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + try (var subscription = adapter.subscribe(endpoint, received)) { + adapter.send(request(endpoint, null)).toCompletableFuture().join(); + DDMessage valid = channel.sent.getLast(); + for (JsonElement value : List.of(new JsonPrimitive("text"), new JsonArray(), JsonNull.INSTANCE)) { + JsonObject invalid = valid.payload(); + invalid.add("data", value); + channel.deliver(new DDMessage(valid.topic(), valid.type(), invalid)); + } + Assertions.assertTrue(received.messages.isEmpty()); + channel.deliver(valid); + Assertions.assertEquals(1, received.messages.size()); + } + } + + @Test + void neutralRpcDistinguishesNestedErrorsOuterErrorsAndInvalidResponses() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + try (var endpoint = RpcEndpoint.open(new DDRpcTransport(delegate), CODEC, OPTIONS)) { + Text proxy = endpoint.proxy("text", Text.class); + for (RpcMessage.Error error : RpcMessage.Error.values()) { + delegate.forward = request -> delegate.deliver(request.response(errorEnvelope(error))); + RpcException failure = Assertions.assertThrows(RpcException.class, () -> proxy.apply("request")); + Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, failure.reason()); + Assertions.assertEquals(error, failure.remoteError()); + Assertions.assertNull(failure.getCause()); + } + for (RequestReplyMessage.Error error : RequestReplyMessage.Error.values()) { + delegate.forward = request -> delegate.deliver(request.failure(error)); + RpcException failure = Assertions.assertThrows(RpcException.class, () -> proxy.apply("request")); + Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, failure.reason()); + Assertions.assertNull(failure.remoteError()); + RequestReplyException outer = Assertions.assertInstanceOf(RequestReplyException.class, failure.getCause()); + Assertions.assertEquals(error, outer.remoteError()); + } + delegate.forward = request -> delegate.deliver(request.response(null)); + RpcException invalid = Assertions.assertThrows(RpcException.class, () -> proxy.apply("request")); + Assertions.assertEquals(RpcException.Reason.INVALID_RESPONSE, invalid.reason()); + } + } + + @Test + void malformedAndUnknownVersionRepliesRemainUnansweredUntilTimeout() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + RequestReplyOptions options = new RequestReplyOptions(Duration.ofMillis(100), 4, 2); + try (var endpoint = RpcEndpoint.open(new DDRpcTransport(delegate), CODEC, options)) { + for (Consumer mutation : List.>of( + envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v2"), + envelope -> envelope.addProperty("type", "UNKNOWN"), + envelope -> envelope.remove("data") + )) { + JsonObject invalid = responseEnvelope(); + mutation.accept(invalid); + delegate.forward = request -> delegate.deliver(request.response(invalid)); + RpcException failure = Assertions.assertThrows(RpcException.class, + () -> endpoint.proxy("text", Text.class).apply("request")); + Assertions.assertEquals(RpcException.Reason.TIMEOUT, failure.reason()); + } + Assertions.assertEquals(3, delegate.sent.size()); + } + } + + @Test + void dispatchesRealRpcFailuresInsideSuccessfulRequestReplyResponses() throws Exception { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = right::deliver; + right.forward = left::deliver; + try (var caller = RequestReplyEndpoint.open(DDRpcTransport.forChannel(left, LANE), OPTIONS); + var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right, LANE), CODEC, OPTIONS); + var service = handler.register("text", Text.class, value -> { + throw new IllegalStateException("Handler failed"); + })) { + RpcMessage missingService = caller.request(RpcMessage.request("missing", "apply", null)); + Assertions.assertEquals(RpcMessage.failure(RpcMessage.Error.UNKNOWN_SERVICE), missingService); + RpcMessage missingOperation = caller.request(RpcMessage.request("text", "missing", null)); + Assertions.assertEquals(RpcMessage.failure(RpcMessage.Error.UNKNOWN_OPERATION), missingOperation); + String operation = "apply(" + String.class.getTypeName() + ")"; + RpcMessage codecFailure = caller.request(RpcMessage.request("text", operation, new JsonObject())); + Assertions.assertEquals(RpcMessage.failure(RpcMessage.Error.CODEC_FAILED), codecFailure); + RpcMessage handlerFailure = caller.request(RpcMessage.request("text", operation, new JsonPrimitive("value"))); + Assertions.assertEquals(RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED), handlerFailure); + Assertions.assertTrue(right.sent.stream().allMatch(message -> + "RESPONSE".equals(message.payload().get("type").getAsString()))); + } + } + + @Test + void rejectsMismatchedOutgoingFramesBeforeCallingTheDelegate() { + MemoryTransport delegate = new MemoryTransport(); + DDRpcTransport adapter = new DDRpcTransport(delegate); + UUID endpoint = UUID.randomUUID(); + RequestReplyMessage> request = request(endpoint, null); + List>> malformed = List.of( + RequestReplyMessage.request(request.requestId(), endpoint, RpcMessage.response(null)), + RequestReplyMessage.request(request.requestId(), endpoint, RpcMessage.failure(RpcMessage.Error.INVALID_REQUEST)), + request.response(request.payload()) + ); + for (RequestReplyMessage> message : malformed) { + ExecutionException failure = Assertions.assertThrows(ExecutionException.class, + () -> adapter.send(message).toCompletableFuture().get(2, TimeUnit.SECONDS)); + Assertions.assertInstanceOf(IllegalArgumentException.class, failure.getCause()); + } + Assertions.assertTrue(delegate.sent.isEmpty()); + } + + @Test + void preservesAsynchronousAndSynchronousSendFailuresWithoutRetriesOrWrapping() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRpcTransport adapter = new DDRpcTransport(delegate); + RequestReplyMessage> request = request(UUID.randomUUID(), null); + CompletableFuture pending = new CompletableFuture<>(); + delegate.sendResult = pending; + CompletionStage pendingResult = adapter.send(request); + Assertions.assertFalse(pendingResult.toCompletableFuture().isDone()); + pending.complete(null); + Assertions.assertNull(pendingResult.toCompletableFuture().get(2, TimeUnit.SECONDS)); + for (RequestReplyException.Reason reason : List.of(RequestReplyException.Reason.DISCONNECTED, + RequestReplyException.Reason.CLOSED, RequestReplyException.Reason.OVERLOADED, RequestReplyException.Reason.SEND_FAILED)) { + RequestReplyException unavailable = new RequestReplyException(reason); + delegate.sendResult = CompletableFuture.failedFuture(unavailable); + CompletionStage result = adapter.send(request); + ExecutionException failure = Assertions.assertThrows(ExecutionException.class, + () -> result.toCompletableFuture().get(2, TimeUnit.SECONDS)); + Assertions.assertSame(unavailable, failure.getCause()); + } + IllegalStateException unavailable = new IllegalStateException("Unavailable"); + delegate.sendFailure = unavailable; + for (int i = 0; i < 129; i++) { + CompletionStage result = Assertions.assertDoesNotThrow(() -> adapter.send(request)); + assertSendFailure(unavailable, result); + } + delegate.sendFailure = null; + delegate.sendResult = CompletableFuture.completedFuture(null); + adapter.send(request).toCompletableFuture().get(2, TimeUnit.SECONDS); + Assertions.assertEquals(135, delegate.sent.size()); + } + + @Test + void boundsFramingBeforeCopyingAndHoldsPermitsUntilActualSendCompletion() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRpcTransport adapter = new DDRpcTransport(delegate); + AtomicInteger copies = new AtomicInteger(); + JsonElement value = new JsonElement() { + @Override + @NotNull + public JsonElement deepCopy() { + copies.incrementAndGet(); + return new JsonPrimitive("snapshot"); + } + }; + RequestReplyMessage> request = request(UUID.randomUUID(), value); + RequestReplyMessage> response = request.response(RpcMessage.response(value)); + List> pending = new ArrayList<>(); + List> results = new ArrayList<>(); + try { + for (int i = 0; i < 128; i++) { + CompletableFuture send = new CompletableFuture<>(); + pending.add(send); + delegate.sendResult = send; + results.add(adapter.send(response).toCompletableFuture()); + } + Assertions.assertEquals(128, delegate.sent.size()); + Assertions.assertEquals(128, copies.get()); + Assertions.assertTrue(results.stream().noneMatch(CompletableFuture::isDone)); + assertOverloaded(adapter.send(response)); + assertOverloaded(adapter.send(request)); + assertOverloaded(adapter.send(request.failure(RequestReplyMessage.Error.OVERLOADED))); + Assertions.assertEquals(128, delegate.sent.size()); + Assertions.assertEquals(128, copies.get()); + + pending.getFirst().complete(null); + Assertions.assertNull(results.getFirst().get(2, TimeUnit.SECONDS)); + CompletableFuture afterCompletion = new CompletableFuture<>(); + pending.add(afterCompletion); + delegate.sendResult = afterCompletion; + results.add(adapter.send(request).toCompletableFuture()); + Assertions.assertEquals(129, delegate.sent.size()); + assertOverloaded(adapter.send(response)); + + RequestReplyException unavailable = new RequestReplyException(RequestReplyException.Reason.DISCONNECTED); + pending.get(1).completeExceptionally(unavailable); + assertSendFailure(unavailable, results.get(1)); + CompletableFuture afterFailure = new CompletableFuture<>(); + pending.add(afterFailure); + delegate.sendResult = afterFailure; + results.add(adapter.send(response).toCompletableFuture()); + Assertions.assertEquals(130, delegate.sent.size()); + assertOverloaded(adapter.send(response)); + + Assertions.assertTrue(results.get(2).cancel(false)); + Assertions.assertFalse(pending.get(2).isDone()); + assertOverloaded(adapter.send(response)); + Assertions.assertEquals(130, delegate.sent.size()); + Assertions.assertEquals(130, copies.get()); + pending.get(2).complete(null); + Assertions.assertTrue(results.get(2).isCancelled()); + CompletableFuture afterCancellation = new CompletableFuture<>(); + pending.add(afterCancellation); + delegate.sendResult = afterCancellation; + results.add(adapter.send(response).toCompletableFuture()); + Assertions.assertEquals(131, delegate.sent.size()); + Assertions.assertEquals(131, copies.get()); + assertOverloaded(adapter.send(response)); + } finally { + pending.forEach(send -> send.complete(null)); + } + } + + @Test + void releasesPermitsAfterSnapshotFramingFailures() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRpcTransport adapter = new DDRpcTransport(delegate); + IllegalStateException unavailable = new IllegalStateException("Snapshot unavailable"); + JsonElement value = new JsonElement() { + @Override + @NotNull + public JsonElement deepCopy() { + throw unavailable; + } + }; + UUID endpoint = UUID.randomUUID(); + RequestReplyMessage> request = request(endpoint, value); + for (int i = 0; i < 129; i++) { + CompletionStage result = Assertions.assertDoesNotThrow(() -> adapter.send(request)); + assertSendFailure(unavailable, result); + } + Assertions.assertTrue(delegate.sent.isEmpty()); + adapter.send(request(endpoint, new JsonPrimitive("recovered"))).toCompletableFuture().get(2, TimeUnit.SECONDS); + Assertions.assertEquals(1, delegate.sent.size()); + } + + @Test + void forwardsDisconnectsAndDelegatesSubscriptionOwnership() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRpcTransport adapter = new DDRpcTransport(delegate); + Received received = new Received(); + MessageSubscription subscription = adapter.subscribe(UUID.randomUUID(), received); + Assertions.assertSame(delegate.subscription, subscription); + Objects.requireNonNull(delegate.listener).onDisconnect(); + Assertions.assertEquals(1, received.disconnects); + subscription.close(); + Assertions.assertTrue(delegate.closed); + RequestReplyException unavailable = new RequestReplyException(RequestReplyException.Reason.DISCONNECTED); + MemoryTransport failing = new MemoryTransport(); + failing.subscribeFailure = unavailable; + RequestReplyException failure = Assertions.assertThrows(RequestReplyException.class, + () -> new DDRpcTransport(failing).subscribe(UUID.randomUUID(), new Received())); + Assertions.assertSame(unavailable, failure); + } + + @Test + void rollsBackDelegatedChannelSubscriptionsWithoutClosingTheBorrowedChannel() { + FakeChannel channel = new FakeChannel(); + channel.failTopic = LANE; + DDRpcTransport adapter = DDRpcTransport.forChannel(channel, LANE); + RequestReplyException failure = Assertions.assertThrows(RequestReplyException.class, + () -> adapter.subscribe(UUID.randomUUID(), new Received())); + Assertions.assertSame(channel.subscriptionFailure, failure.getCause()); + Assertions.assertEquals(1, channel.subscriptions.size()); + Assertions.assertFalse(channel.subscriptions.getFirst().active); + Assertions.assertTrue(channel.observers.isEmpty()); + Assertions.assertFalse(channel.closed); + Assertions.assertThrows(IllegalStateException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); + } + + @Test + void channelDisconnectTerminatesPendingRpcAndClosingLeavesTheChannelIntact() throws Exception { + FakeChannel channel = new FakeChannel(); + channel.forward = message -> channel.setStatus(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); + try (var endpoint = RpcEndpoint.open(DDRpcTransport.forChannel(channel, LANE), CODEC, OPTIONS)) { + RpcException failure = Assertions.assertThrows(RpcException.class, + () -> endpoint.proxy("text", Text.class).apply("request")); + Assertions.assertEquals(RpcException.Reason.DISCONNECTED, failure.reason()); + } + Assertions.assertFalse(channel.closed); + Assertions.assertTrue(channel.observers.isEmpty()); + Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active)); + } + + @NotNull + private static RequestReplyMessage> request(@NotNull UUID endpoint, @Nullable JsonElement value) { + return RequestReplyMessage.request(UUID.randomUUID(), endpoint, RpcMessage.request("projects", "apply", value)); + } + + private static void assertSendFailure(@NotNull Throwable expected, @NotNull CompletionStage result) { + ExecutionException failure = Assertions.assertThrows(ExecutionException.class, + () -> result.toCompletableFuture().get(2, TimeUnit.SECONDS)); + Assertions.assertSame(expected, failure.getCause()); + } + + private static void assertOverloaded(@NotNull CompletionStage result) { + ExecutionException failure = Assertions.assertThrows(ExecutionException.class, + () -> result.toCompletableFuture().get(2, TimeUnit.SECONDS)); + RequestReplyException unavailable = Assertions.assertInstanceOf(RequestReplyException.class, failure.getCause()); + Assertions.assertEquals(RequestReplyException.Reason.OVERLOADED, unavailable.reason()); + } + + @NotNull + private static JsonObject requestEnvelope() { + return JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v1\",\"type\":\"REQUEST\"," + + "\"topic\":\"projects\",\"operation\":\"apply\",\"data\":null}").getAsJsonObject(); + } + + @NotNull + private static JsonObject responseEnvelope() { + return JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v1\",\"type\":\"RESPONSE\",\"data\":null}").getAsJsonObject(); + } + + @NotNull + private static JsonObject errorEnvelope(@NotNull RpcMessage.Error error) { + JsonObject result = JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v1\",\"type\":\"ERROR\"}").getAsJsonObject(); + result.addProperty("error", error.name()); + return result; + } + + /** Exchanges concretely typed generic values. */ + public interface Names { + @NotNull + List apply(@NotNull List values); + } + + /** Exchanges application records. */ + public interface Records { + @NotNull + Item apply(@NotNull Item value); + } + + /** Exchanges nullable application values. */ + public interface Text { + @Nullable + String apply(@Nullable String value); + } + + /** Completes calls without an application result. */ + public interface Notification { + void accept(@Nullable String value); + } + + public record Item(@NotNull String name, @NotNull List tags) { + } + + private static final class GsonCodec implements RpcCodec { + private final Gson gson = new Gson(); + + @Override + @Nullable + public JsonElement encode(@Nullable Object value, @NotNull Type type) { + JsonElement encoded = gson.toJsonTree(value, type); + return encoded.isJsonNull() ? null : encoded.deepCopy(); + } + + @Override + @Nullable + public Object decode(@Nullable JsonElement payload, @NotNull Type type) { + return gson.fromJson(payload == null ? JsonNull.INSTANCE : payload.deepCopy(), type); + } + } + + private static final class Received implements RequestReplyTransport.Listener> { + private final List>> messages = new ArrayList<>(); + private int disconnects; + + @Override + public void onMessage(@NotNull RequestReplyMessage> message) { + messages.add(message); + } + + @Override + public void onDisconnect() { + disconnects++; + } + } + + private static final class MemoryTransport implements RequestReplyTransport { + private final List> sent = new ArrayList<>(); + private final MessageSubscription subscription = () -> this.closed = true; + private CompletionStage sendResult = CompletableFuture.completedFuture(null); + @Nullable + private UUID endpoint; + @Nullable + private Listener listener; + @Nullable + private Consumer> forward; + @Nullable + private RequestReplyException subscribeFailure; + @Nullable + private RuntimeException sendFailure; + private boolean closed; + + @Override + @NotNull + public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener receiver) + throws RequestReplyException { + if (subscribeFailure != null) { + throw subscribeFailure; + } + endpoint = endpointId; + listener = receiver; + return subscription; + } + + @Override + @NotNull + public CompletionStage send(@NotNull RequestReplyMessage message) { + sent.add(message); + if (sendFailure != null) { + throw sendFailure; + } + if (forward != null) { + forward.accept(message); + } + return sendResult; + } + + private void deliver(@NotNull RequestReplyMessage message) { + Objects.requireNonNull(listener).onMessage(message); + } + } + + private static final class FakeChannel implements DDMessageChannel { + private final List subscriptions = new CopyOnWriteArrayList<>(); + private final List> observers = new CopyOnWriteArrayList<>(); + private final List sent = new CopyOnWriteArrayList<>(); + private final RuntimeException subscriptionFailure = new IllegalStateException("Subscription unavailable"); + private DDChannelStatus status = new DDChannelStatus(DDChannelState.CONNECTED, CONNECTION); + @Nullable + private String failTopic; + @Nullable + private Consumer forward; + private boolean closed; + + @Override + @NotNull + public DDChannelStatus getStatus() { + return status; + } + + @Override + @NotNull + public DDSubscription observeState(@NotNull Consumer observer) { + observers.add(observer); + observer.accept(status); + return () -> observers.remove(observer); + } + + @Override + @NotNull + public CompletionStage send(@NotNull DDMessage message) { + sent.add(message); + if (forward != null) { + forward.accept(message); + } + return CompletableFuture.completedFuture(null); + } + + @Override + @NotNull + public DDSubscription subscribe(@NotNull String topic, @NotNull DDMessageListener listener) { + if (topic.equals(failTopic)) { + throw subscriptionFailure; + } + TopicSubscription subscription = new TopicSubscription(topic, listener); + subscriptions.add(subscription); + return () -> subscription.active = false; + } + + @Override + @NotNull + public DDSubscription addConnectionListener(@NotNull DDConnectionListener listener) { + throw new UnsupportedOperationException(); + } + + private void deliver(@NotNull DDMessage message) { + for (TopicSubscription subscription : subscriptions) { + if (subscription.active && subscription.topic.equals(message.topic())) { + subscription.listener.onMessage(CONNECTION, message); + } + } + } + + private void setStatus(@NotNull DDChannelStatus next) { + status = next; + observers.forEach(observer -> observer.accept(next)); + } + + @Override + public void close() { + closed = true; + } + } + + private static final class TopicSubscription { + private final String topic; + private final DDMessageListener listener; + private volatile boolean active = true; + + private TopicSubscription(@NotNull String topic, @NotNull DDMessageListener listener) { + this.topic = topic; + this.listener = listener; + } + } +} diff --git a/apis/com.dbeaver.messaging/META-INF/MANIFEST.MF b/apis/com.dbeaver.messaging/META-INF/MANIFEST.MF index 8908cf5..885d6fd 100644 --- a/apis/com.dbeaver.messaging/META-INF/MANIFEST.MF +++ b/apis/com.dbeaver.messaging/META-INF/MANIFEST.MF @@ -5,7 +5,8 @@ Bundle-Name: DBeaver Messaging Bundle-SymbolicName: com.dbeaver.messaging Bundle-Version: 0.0.17.qualifier Bundle-RequiredExecutionEnvironment: JavaSE-21 -Export-Package: com.dbeaver.messaging.requestreply +Export-Package: com.dbeaver.messaging.requestreply, + com.dbeaver.messaging.rpc Bundle-ActivationPolicy: lazy Require-Bundle: org.jkiss.utils Automatic-Module-Name: com.dbeaver.messaging diff --git a/apis/com.dbeaver.messaging/README.md b/apis/com.dbeaver.messaging/README.md index 68311ac..6d96a85 100644 --- a/apis/com.dbeaver.messaging/README.md +++ b/apis/com.dbeaver.messaging/README.md @@ -1,8 +1,8 @@ -# Messaging: request/reply +# Messaging: request/reply and typed RPC This module supplies a transport-independent request/reply endpoint for Java 21. It has no dependency on DataDam, HTTP, WebSocket, JSON, Spring, or Eclipse runtime services. The only non-JDK production dependency supplies nullability -annotations. Typed RPC is not part of this module's current API. +annotations. The `rpc` package adds ordinary Java interface calls over the same request/reply mechanism. ## Usage @@ -85,3 +85,94 @@ Implement `RequestReplyTransport

` to integrate a message transport: Transport write failure or a lost reply can prevent a caller from seeing a completed remote operation. Response send failure is not retried. An already claimed transport send can finish while closure or disconnection is taking place. + +## Typed RPC + +Both peers use the same classes. A local service registration and a proxy for a remote service are independent: +neither requires a service in the reverse direction, and either peer can initiate calls after transport establishment. +There is no client/server distinction in the RPC API. + +The application shares an interface between peers and supplies its implementation on the receiving side: + +```java +public interface Documents { + Document load(DocumentRequest request); + void save(SaveRequest request); +} +``` + +The receiver creates one reusable endpoint, selects its codec once, and registers its service under a logical topic: + +```java +try (var rpc = RpcEndpoint.open(transport, codec); + var registration = rpc.register("documents", Documents.class, implementation)) { + serveApplication(); +} +``` + +The caller creates its own endpoint over the corresponding transport and obtains a proxy: + +```java +try (var rpc = RpcEndpoint.open(transport, codec)) { + Documents documents = rpc.proxy("documents", Documents.class); + Document document = documents.load(request); + documents.save(saveRequest); +} +``` + +The same endpoint can also register other local services or obtain proxies for services on the opposite peer. RPC topics +are logical service keys within one request/reply channel, not new connections or subscriptions. Both peers must select +the same underlying request/reply lane, with one receiving endpoint per peer. A topic can have one local implementation; +different topics can use the same interface with different implementations. Proxy construction does not contact the +remote peer or prove service presence. + +Proxy methods block the calling thread. Call them from worker or virtual threads, not a transport listener or event loop. + +`RpcEndpoint

` consumes `RequestReplyTransport>`. The transport adapter encodes the neutral protocol +envelope; `RpcCodec

` encodes only application arguments and results. A TCP, WebSocket, long-polling, or in-memory +transport can implement this interface without changing RPC code. No WebSocket types or JSON library are used here. + +### Contracts and codecs + +- Contracts are public, nonsealed interfaces with at least one abstract operation and exactly one argument per method. + Return values can be ordinary reference or primitive types, or `void`. Reference arguments and results can be null. +- Inherited abstract operations and overloads are supported. Operation IDs combine the method name and its erased + argument type; only methods from the registered interface can be invoked. Incoming names never trigger class loading + or arbitrary reflective lookup on the implementation. +- Concrete generic argument and result types, such as `List`, reach the codec as `Type` values. Unresolved + type variables, generic methods, default methods, varargs, and `Future`/`CompletionStage` returns are rejected during + proxy construction or registration. Generic-parent substitutions are not inferred. Inherited operations with + ambiguous generic argument or result types must be explicitly redeclared in the public contract. +- `RpcCodec.encode(value, type)` and `decode(payload, type)` are selected once per endpoint. Implementations must handle + nulls and concurrent calls. Serialization remains application-owned; the RPC core does not construct application + types or select a serialization framework. Payload immutability and snapshot requirements are the same as request/reply. + Peers must use compatible contracts and codecs; no automatic contract negotiation or class transfer is performed. +- Implementations may receive concurrent calls and must manage their own application state safely. Incoming execution + uses the existing bounded request/reply handlers; the RPC layer adds no executor or unbounded message queue. +- `equals`, `hashCode`, and `toString` on a proxy are local identity operations and do not send requests or call codecs. + +### Failure and ownership + +`void` still waits for remote handler completion and a successful reply. It is not fire-and-forget, and its response +does not invoke the application codec. `RpcException` is unchecked, so a business interface does not need a library +exception declaration. Its reason distinguishes timeout, interruption, disconnection, overload, closure, send failure, +codec failure, invalid response, and remote failure. Caller interruption restores the interrupt flag. + +An absent logical service returns `UNKNOWN_SERVICE`; an absent operation returns `UNKNOWN_OPERATION`. Invalid arguments, +handler failures, and remote codec failures return fixed error codes, never exception types, messages, or stack traces. +Declared application exceptions are not reconstructed on the caller. A peer with no receiving endpoint can only be +detected through transport failure or timeout. Local failures retain their causes. + +`RpcEndpoint.open(transport, codec, options)` accepts the existing `RequestReplyOptions` for deadlines and capacity. +The deadline covers the underlying request/reply call, not arbitrary application codec execution. Codecs should complete +promptly. Timeout or interruption leaves the remote outcome potentially unknown; the RPC layer adds no retries or +remote cancellation promises. + +Closing a service registration removes only that local implementation. It does not close the endpoint or its proxies, +and work already dispatched to the implementation can finish. Repeatedly closing an old registration cannot remove a +replacement. Closing the RPC endpoint invalidates its proxies, clears service registrations, and closes its underlying +request/reply endpoint, failing pending calls. The borrowed physical transport remains open. + +The overload `RpcEndpoint.open(requests, codec)` accepts an existing `RequestReplyEndpoint>` with no +registered handler and transfers its ownership on success. A failed opening does not remove another handler. Do not +close or install other handlers on that request/reply endpoint while RPC owns it. diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java new file mode 100644 index 0000000..717faca --- /dev/null +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java @@ -0,0 +1,31 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.lang.reflect.Type; + +/** Encodes and decodes application values using their declared RPC types. */ +public interface RpcCodec

{ + @Nullable + P encode(@Nullable Object value, @NotNull Type type) throws Exception; + + @Nullable + Object decode(@Nullable P payload, @NotNull Type type) throws Exception; +} diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java new file mode 100644 index 0000000..e4bc21e --- /dev/null +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java @@ -0,0 +1,156 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.WildcardType; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Future; + +final class RpcContract { + private static final Map, Class> BOXED = Map.of( + boolean.class, Boolean.class, byte.class, Byte.class, short.class, Short.class, char.class, Character.class, + int.class, Integer.class, long.class, Long.class, float.class, Float.class, double.class, Double.class + ); + + private final Map operations; + + private RpcContract(@NotNull Map operations) { + this.operations = Map.copyOf(operations); + } + + @NotNull + static RpcContract create(@NotNull Class type) { + Objects.requireNonNull(type); + if (!type.isInterface() || !Modifier.isPublic(type.getModifiers()) || type.isSealed() || type.getTypeParameters().length != 0) { + throw new IllegalArgumentException("RPC contract must be a public, nonsealed interface with concrete types"); + } + Map> declarations = new HashMap<>(); + for (Method method : type.getMethods()) { + if (Modifier.isStatic(method.getModifiers()) || method.isBridge() || method.isSynthetic() || isObjectMethod(method)) { + continue; + } + validate(method); + declarations.computeIfAbsent(operationId(method), ignored -> new ArrayList<>()).add(method); + } + if (declarations.isEmpty()) { + throw new IllegalArgumentException("RPC contract must declare at least one operation"); + } + Map operations = new HashMap<>(); + declarations.forEach((id, methods) -> operations.put(id, selectOperation(methods))); + for (Method method : type.getMethods()) { + if (method.isBridge() && method.getParameterCount() == 1 && !operations.containsKey(operationId(method))) { + throw new IllegalArgumentException("RPC contract has an unresolved generic bridge operation"); + } + } + return new RpcContract<>(operations); + } + + void requireAccessible(@NotNull Object implementation) { + for (Method method : operations.values()) { + if (!method.canAccess(implementation) && !method.trySetAccessible()) { + throw new IllegalArgumentException("RPC contract operation is not accessible"); + } + } + } + + @Nullable + Method operation(@NotNull String id) { + return operations.get(id); + } + + @NotNull + static String operationId(@NotNull Method method) { + return method.getName() + "(" + method.getParameterTypes()[0].getTypeName() + ")"; + } + + static boolean accepts(@NotNull Class type, @Nullable Object value) { + return value == null ? !type.isPrimitive() : BOXED.getOrDefault(type, type).isInstance(value); + } + + static boolean isObjectMethod(@NotNull Method method) { + return switch (method.getName()) { + case "equals" -> Arrays.equals(method.getParameterTypes(), new Class[] {Object.class}); + case "hashCode", "toString" -> method.getParameterCount() == 0; + default -> false; + }; + } + + @NotNull + private static Method selectOperation(@NotNull List declarations) { + List effective = declarations.stream().filter(method -> declarations.stream().noneMatch(other -> + method.getDeclaringClass() != other.getDeclaringClass() + && method.getDeclaringClass().isAssignableFrom(other.getDeclaringClass()))).toList(); + Method selected = effective.getFirst(); + for (Method method : effective) { + if (selected.getReturnType() != method.getReturnType() && selected.getReturnType().isAssignableFrom(method.getReturnType()) + || selected.getReturnType() == method.getReturnType() && !Modifier.isPublic(selected.getDeclaringClass().getModifiers()) + && Modifier.isPublic(method.getDeclaringClass().getModifiers())) { + selected = method; + } + } + for (Method method : effective) { + if (!Arrays.equals(selected.getGenericParameterTypes(), method.getGenericParameterTypes()) + || !method.getReturnType().isAssignableFrom(selected.getReturnType()) + || selected.getReturnType() == method.getReturnType() + && !selected.getGenericReturnType().equals(method.getGenericReturnType())) { + throw new IllegalArgumentException("RPC contract must explicitly redeclare operations with ambiguous generic types"); + } + } + return selected; + } + + private static void validate(@NotNull Method method) { + if (method.isDefault() || method.isVarArgs() || method.getParameterCount() != 1 || method.getTypeParameters().length != 0 + || !isConcrete(method.getGenericParameterTypes()[0]) || !isConcrete(method.getGenericReturnType()) + || CompletionStage.class.isAssignableFrom(method.getReturnType()) || Future.class.isAssignableFrom(method.getReturnType()) + || operationId(method).length() > 1024) { + throw new IllegalArgumentException("RPC operations must be abstract, synchronous methods with one concretely typed argument"); + } + } + + private static boolean isConcrete(@NotNull Type type) { + if (type instanceof Class) { + return true; + } + if (type instanceof ParameterizedType parameterized) { + return (parameterized.getOwnerType() == null || isConcrete(parameterized.getOwnerType())) + && Arrays.stream(parameterized.getActualTypeArguments()).allMatch(RpcContract::isConcrete); + } + if (type instanceof GenericArrayType array) { + return isConcrete(array.getGenericComponentType()); + } + if (type instanceof WildcardType wildcard) { + return Arrays.stream(wildcard.getUpperBounds()).allMatch(RpcContract::isConcrete) + && Arrays.stream(wildcard.getLowerBounds()).allMatch(RpcContract::isConcrete); + } + return false; + } +} diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java new file mode 100644 index 0000000..1f1f293 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java @@ -0,0 +1,271 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import com.dbeaver.messaging.requestreply.MessageSubscription; +import com.dbeaver.messaging.requestreply.RequestReplyEndpoint; +import com.dbeaver.messaging.requestreply.RequestReplyException; +import com.dbeaver.messaging.requestreply.RequestReplyOptions; +import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +public final class RpcEndpoint

implements AutoCloseable { + private final Object lock = new Object(); + private final RequestReplyEndpoint> requests; + private final RpcCodec

codec; + private final MessageSubscription handler; + private final Map services = new HashMap<>(); + private boolean closed; + + private RpcEndpoint(@NotNull RequestReplyEndpoint> requests, @NotNull RpcCodec

codec) { + this.requests = Objects.requireNonNull(requests); + this.codec = Objects.requireNonNull(codec); + this.handler = requests.handle(this::dispatch); + } + + @NotNull + public static

RpcEndpoint

open(@NotNull RequestReplyEndpoint> requests, @NotNull RpcCodec

codec) { + return new RpcEndpoint<>(requests, codec); + } + + @NotNull + public static

RpcEndpoint

open(@NotNull RequestReplyTransport> transport, @NotNull RpcCodec

codec) + throws RequestReplyException { + return open(transport, codec, RequestReplyOptions.DEFAULT); + } + + @NotNull + public static

RpcEndpoint

open( + @NotNull RequestReplyTransport> transport, + @NotNull RpcCodec

codec, + @NotNull RequestReplyOptions options + ) throws RequestReplyException { + Objects.requireNonNull(codec); + RequestReplyEndpoint> requests = RequestReplyEndpoint.open(transport, options); + try { + return open(requests, codec); + } catch (RuntimeException e) { + try { + requests.close(); + } catch (RuntimeException closeFailure) { + e.addSuppressed(closeFailure); + } + throw e; + } + } + + @NotNull + public MessageSubscription register(@NotNull String topic, @NotNull Class type, @NotNull T implementation) { + RpcMessage.requireTopic(topic); + RpcContract contract = RpcContract.create(type); + if (!type.isInstance(Objects.requireNonNull(implementation))) { + throw new IllegalArgumentException("RPC implementation does not implement its contract"); + } + contract.requireAccessible(implementation); + synchronized (lock) { + requireOpen(); + if (services.containsKey(topic)) { + throw new IllegalStateException("RPC topic already has a registered service"); + } + ServiceRegistration registration = new ServiceRegistration(topic, contract, implementation); + services.put(topic, registration); + return registration; + } + } + + @NotNull + public T proxy(@NotNull String topic, @NotNull Class type) { + RpcMessage.requireTopic(topic); + RpcContract contract = RpcContract.create(type); + synchronized (lock) { + requireOpen(); + } + Object proxy = Proxy.newProxyInstance(type.getClassLoader(), new Class[] {type}, (instance, method, arguments) -> { + if (RpcContract.isObjectMethod(method)) { + return switch (method.getName()) { + case "equals" -> instance == Objects.requireNonNull(arguments)[0]; + case "hashCode" -> System.identityHashCode(instance); + case "toString" -> "RPC proxy[" + topic + ", " + type.getName() + "]"; + default -> throw new IllegalStateException("Unsupported proxy object method"); + }; + } + Method operation = Objects.requireNonNull(contract.operation(RpcContract.operationId(method))); + return invoke(topic, operation, Objects.requireNonNull(arguments)[0]); + }); + return type.cast(proxy); + } + + @Nullable + private Object invoke(@NotNull String topic, @NotNull Method operation, @Nullable Object argument) { + synchronized (lock) { + requireOpen(); + } + if (Thread.currentThread().isInterrupted()) { + throw new RpcException(RpcException.Reason.INTERRUPTED); + } + RpcMessage

request; + try { + P payload = codec.encode(argument, operation.getGenericParameterTypes()[0]); + request = RpcMessage.request(topic, RpcContract.operationId(operation), payload); + } catch (Exception e) { + throw codecFailure(e); + } + RpcMessage

response; + try { + response = requests.request(request); + } catch (RequestReplyException e) { + throw new RpcException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RpcException(RpcException.Reason.INTERRUPTED, e); + } + if (response == null || response.type() == RpcMessage.Type.REQUEST) { + throw new RpcException(RpcException.Reason.INVALID_RESPONSE); + } + if (response.type() == RpcMessage.Type.ERROR) { + throw new RpcException(Objects.requireNonNull(response.error())); + } + if (operation.getReturnType() == void.class) { + if (response.payload() != null) { + throw new RpcException(RpcException.Reason.INVALID_RESPONSE); + } + return null; + } + Object result; + try { + result = codec.decode(response.payload(), operation.getGenericReturnType()); + } catch (Exception e) { + throw codecFailure(e); + } + if (!RpcContract.accepts(operation.getReturnType(), result)) { + throw new RpcException(RpcException.Reason.INVALID_RESPONSE); + } + return result; + } + + @NotNull + private RpcMessage

dispatch(@NotNull RpcMessage

request) { + if (request.type() != RpcMessage.Type.REQUEST) { + return RpcMessage.failure(RpcMessage.Error.INVALID_REQUEST); + } + ServiceRegistration registration; + synchronized (lock) { + if (closed) { + return RpcMessage.failure(RpcMessage.Error.UNKNOWN_SERVICE); + } + registration = services.get(request.topic()); + } + if (registration == null) { + return RpcMessage.failure(RpcMessage.Error.UNKNOWN_SERVICE); + } + Method operation = registration.contract.operation(Objects.requireNonNull(request.operation())); + if (operation == null) { + return RpcMessage.failure(RpcMessage.Error.UNKNOWN_OPERATION); + } + Object argument; + try { + argument = codec.decode(request.payload(), operation.getGenericParameterTypes()[0]); + } catch (Exception e) { + restoreInterruption(e); + return RpcMessage.failure(RpcMessage.Error.CODEC_FAILED); + } + if (!RpcContract.accepts(operation.getParameterTypes()[0], argument)) { + return RpcMessage.failure(RpcMessage.Error.INVALID_REQUEST); + } + Object result; + try { + result = operation.invoke(registration.implementation, argument); + } catch (InvocationTargetException e) { + restoreInterruption(e.getCause()); + return RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED); + } catch (ReflectiveOperationException | RuntimeException e) { + return RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED); + } + if (operation.getReturnType() == void.class) { + return RpcMessage.response(null); + } + try { + return RpcMessage.response(codec.encode(result, operation.getGenericReturnType())); + } catch (Exception e) { + restoreInterruption(e); + return RpcMessage.failure(RpcMessage.Error.CODEC_FAILED); + } + } + + @NotNull + private static RpcException codecFailure(@NotNull Exception cause) { + restoreInterruption(cause); + RpcException.Reason reason = cause instanceof InterruptedException + ? RpcException.Reason.INTERRUPTED : RpcException.Reason.CODEC_FAILED; + return new RpcException(reason, cause); + } + + private static void restoreInterruption(@Nullable Throwable failure) { + if (failure instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + } + + private void requireOpen() { + if (closed) { + throw new RpcException(RpcException.Reason.CLOSED); + } + } + + @Override + public void close() { + synchronized (lock) { + if (closed) { + return; + } + closed = true; + services.clear(); + } + try { + handler.close(); + } finally { + requests.close(); + } + } + + private final class ServiceRegistration implements MessageSubscription { + private final String topic; + private final RpcContract contract; + private final Object implementation; + + private ServiceRegistration(@NotNull String topic, @NotNull RpcContract contract, @NotNull Object implementation) { + this.topic = topic; + this.contract = contract; + this.implementation = implementation; + } + + @Override + public void close() { + synchronized (lock) { + services.remove(topic, this); + } + } + } +} diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcException.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcException.java new file mode 100644 index 0000000..ee16de1 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcException.java @@ -0,0 +1,70 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import com.dbeaver.messaging.requestreply.RequestReplyException; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.Objects; + +public class RpcException extends RuntimeException { + public enum Reason { + CLOSED, DISCONNECTED, OVERLOADED, SEND_FAILED, TIMEOUT, INTERRUPTED, CODEC_FAILED, INVALID_RESPONSE, REMOTE_ERROR + } + + private final Reason reason; + @Nullable + private final RpcMessage.Error remoteError; + + public RpcException(@NotNull Reason reason) { + this(reason, null); + } + + public RpcException(@NotNull Reason reason, @Nullable Throwable cause) { + super("RPC call failed: " + Objects.requireNonNull(reason), cause); + this.reason = reason; + this.remoteError = null; + } + + public RpcException(@NotNull RpcMessage.Error remoteError) { + super("Remote RPC call failed: " + Objects.requireNonNull(remoteError)); + this.reason = Reason.REMOTE_ERROR; + this.remoteError = remoteError; + } + + RpcException(@NotNull RequestReplyException cause) { + this(switch (cause.reason()) { + case CLOSED -> Reason.CLOSED; + case DISCONNECTED -> Reason.DISCONNECTED; + case OVERLOADED -> Reason.OVERLOADED; + case SEND_FAILED -> Reason.SEND_FAILED; + case TIMEOUT -> Reason.TIMEOUT; + case REMOTE_ERROR -> Reason.REMOTE_ERROR; + }, cause); + } + + @NotNull + public Reason reason() { + return reason; + } + + @Nullable + public RpcMessage.Error remoteError() { + return remoteError; + } +} diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java new file mode 100644 index 0000000..05ab574 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java @@ -0,0 +1,73 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.Objects; + +public record RpcMessage

( + @NotNull Type type, + @Nullable String topic, + @Nullable String operation, + @Nullable P payload, + @Nullable Error error +) { + public enum Type { + REQUEST, RESPONSE, ERROR + } + + public enum Error { + UNKNOWN_SERVICE, UNKNOWN_OPERATION, INVALID_REQUEST, HANDLER_FAILED, CODEC_FAILED + } + + public RpcMessage { + Objects.requireNonNull(type); + if (type == Type.REQUEST) { + requireTopic(Objects.requireNonNull(topic)); + if (operation == null || operation.isBlank() || operation.length() > 1024 || error != null) { + throw new IllegalArgumentException("Invalid RPC request"); + } + } else if (topic != null || operation != null || type == Type.ERROR && (error == null || payload != null) + || type == Type.RESPONSE && error != null) { + throw new IllegalArgumentException("Invalid RPC response"); + } + } + + @NotNull + public static

RpcMessage

request(@NotNull String topic, @NotNull String operation, @Nullable P payload) { + return new RpcMessage<>(Type.REQUEST, topic, operation, payload, null); + } + + @NotNull + public static

RpcMessage

response(@Nullable P payload) { + return new RpcMessage<>(Type.RESPONSE, null, null, payload, null); + } + + @NotNull + public static

RpcMessage

failure(@NotNull Error error) { + return new RpcMessage<>(Type.ERROR, null, null, null, Objects.requireNonNull(error)); + } + + static void requireTopic(@NotNull String topic) { + Objects.requireNonNull(topic); + if (topic.isBlank() || topic.length() > 256) { + throw new IllegalArgumentException("Expected a nonblank RPC topic of at most 256 characters"); + } + } +} diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java new file mode 100644 index 0000000..a1c9784 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java @@ -0,0 +1,399 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Future; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(10) +public class RpcContractTest extends RpcTestSupport { + @Test + void rejectsInvalidContractsBeforeSendingAndLeavesTheTopicAvailable() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + for (Class type : List.of( + String.class, HiddenService.class, SealedService.class, GenericService.class, RawGenericService.class, + EmptyService.class, StaticOnlyService.class, ObjectOnlyService.class, + DefaultService.class, VarargsService.class, NoArgumentService.class, TwoArgumentService.class, + GenericMethodService.class, FutureService.class, StageService.class, CompletableService.class + )) { + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("service", type), type.getName()); + assertThrows(IllegalArgumentException.class, () -> endpoint.register("service", type, null), type.getName()); + } + assertThrows(IllegalArgumentException.class, () -> endpoint.register("service", DefaultService.class, value -> value)); + try (var registration = endpoint.register("service", StaticService.class, value -> value)) { + assertTrue(transport.sent.isEmpty()); + assertTrue(codec.encoded.isEmpty()); + assertTrue(codec.decoded.isEmpty()); + } + } + } + + @Test + void rejectsUnresolvedTypeVariablesInParameterizedOwnerTypes() { + assertThrows(IllegalArgumentException.class, () -> RpcContract.create(RawOwnedGenericService.class)); + } + + @Test + void rejectsGenericParentBridgesWhoseErasedArgumentHasNoConcreteOperation() throws Exception { + var bridge = GenericBridgeChild.class.getDeclaredMethod("echo", Object.class); + assertTrue(bridge.isBridge()); + assertTrue(bridge.isSynthetic()); + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("bridge", GenericBridgeChild.class)); + assertThrows(IllegalArgumentException.class, + () -> endpoint.register("bridge", GenericBridgeChild.class, value -> value)); + assertTrue(codec.encoded.isEmpty()); + assertTrue(codec.decoded.isEmpty()); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void rejectsUnrelatedParentsWithAmbiguousGenericParameterOrReturnTypesBeforeSending() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + for (Class type : List.of(AmbiguousListCounter.class, AmbiguousListLoader.class)) { + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("ambiguous", type), type.getName()); + assertThrows(IllegalArgumentException.class, () -> endpoint.register("ambiguous", type, null), type.getName()); + } + assertTrue(codec.encoded.isEmpty()); + assertTrue(codec.decoded.isEmpty()); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void selectsTheUniquelyNarrowerGenericReturnAcrossAllParentDeclarationOrders() throws Exception { + var expectedType = ArrayListLoader.class.getDeclaredMethod("load", String.class).getGenericReturnType(); + assertEquals("java.util.ArrayList", expectedType.getTypeName()); + + for (Class type : List.of(WideNarrowArrayLoader.class, WideArrayNarrowLoader.class, + NarrowWideArrayLoader.class, NarrowArrayWideLoader.class, ArrayWideNarrowLoader.class, ArrayNarrowWideLoader.class)) { + var operation = RpcContract.create(type).operation("load(java.lang.String)"); + + assertNotNull(operation, type.getName()); + assertEquals(ArrayList.class, operation.getReturnType(), type.getName()); + assertEquals(expectedType, operation.getGenericReturnType(), type.getName()); + } + } + + @Test + void explicitChildRedeclarationsResolveGenericTypesForCallsThroughParentAndChildReferences() throws Exception { + var count = ResolvedListCounter.class.getDeclaredMethod("count", List.class); + var load = ResolvedListLoader.class.getDeclaredMethod("load", String.class); + assertEquals(count, RpcContract.create(ResolvedListCounter.class).operation("count(java.util.List)")); + assertEquals(load, RpcContract.create(ResolvedListLoader.class).operation("load(java.lang.String)")); + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + connect(clientTransport, serverTransport); + try (var client = RpcEndpoint.open(clientTransport, clientCodec, OPTIONS); + var server = RpcEndpoint.open(serverTransport, serverCodec, OPTIONS); + var counter = server.register("count", ResolvedListCounter.class, List::size); + var loader = server.register("load", ResolvedListLoader.class, List::of)) { + ResolvedListCounter childCounter = client.proxy("count", ResolvedListCounter.class); + TypedListCounter typedCounter = childCounter; + assertEquals(2, typedCounter.count(List.of("one", "two"))); + assertEquals(1, childCounter.count(List.of("child"))); + + ResolvedListLoader childLoader = client.proxy("load", ResolvedListLoader.class); + WideListLoader wideLoader = childLoader; + NarrowListLoader narrowLoader = childLoader; + assertEquals(List.of("wide"), wideLoader.load("wide")); + assertEquals(List.of("narrow"), narrowLoader.load("narrow")); + assertEquals(List.of("child"), childLoader.load("child")); + + assertEquals(List.of(List.class, List.class, String.class, String.class, String.class), + clientCodec.encoded.stream().map(CodecCall::type).toList()); + assertEquals(clientCodec.encoded, serverCodec.decoded); + var returnType = load.getGenericReturnType(); + assertEquals("java.util.List", returnType.getTypeName()); + assertEquals(List.of(int.class, int.class, returnType, returnType, returnType), + serverCodec.encoded.stream().map(CodecCall::type).toList()); + assertEquals(serverCodec.encoded, clientCodec.decoded); + } + } + + @Test + void ignoresStaticMethodsAndKeepsConcreteDeclaredTypes() throws Exception { + RpcContract contract = RpcContract.create(StaticService.class); + assertEquals(StaticService.class.getMethod("echo", String.class), contract.operation("echo(java.lang.String)")); + assertNull(contract.operation("ignored()")); + + RpcContract parameterized = RpcContract.create(ParameterizedService.class); + var method = ParameterizedService.class.getMethod("echo", List.class); + assertEquals(method, parameterized.operation("echo(java.util.List)")); + assertEquals(method.getGenericParameterTypes()[0], parameterized.operation("echo(java.util.List)").getGenericParameterTypes()[0]); + assertEquals(method.getGenericReturnType(), parameterized.operation("echo(java.util.List)").getGenericReturnType()); + } + + @Test + void prefersTheConcreteParameterizedReturnRedeclaredByAChildContract() throws Exception { + RpcContract contract = RpcContract.create(NumberService.class); + + assertEquals(NumberService.class.getDeclaredMethod("numbers", String.class), contract.operation("numbers(java.lang.String)")); + assertEquals("java.util.List", + contract.operation("numbers(java.lang.String)").getGenericReturnType().getTypeName()); + } + + interface HiddenService { + @NotNull + String echo(@NotNull String value); + } + + /** Defines a sealed contract that cannot be proxied. */ + public sealed interface SealedService permits PermittedService { + @NotNull + String echo(@NotNull String value); + } + + /** Supplies a permitted subtype for the sealed fixture. */ + public non-sealed interface PermittedService extends SealedService { + } + + /** Defines an unresolved generic operation. */ + public interface GenericService { + @NotNull + T echo(@NotNull T value); + } + + /** Specializes a generic argument and produces an erased argument bridge. */ + public interface GenericBridgeChild extends GenericService { + @NotNull + @Override + String echo(@NotNull String value); + } + + /** Inherits unresolved generic operation types. */ + @SuppressWarnings("rawtypes") + public interface RawGenericService extends GenericService { + } + + public static final class GenericOwner { + public final class Value { + } + } + + /** Uses an unresolved type in a parameterized owner. */ + public interface OwnedGenericService { + @NotNull + String echo(@NotNull GenericOwner.Value value); + } + + /** Inherits an unresolved parameterized owner type. */ + @SuppressWarnings("rawtypes") + public interface RawOwnedGenericService extends OwnedGenericService { + } + + /** Declares no remote operations. */ + public interface EmptyService { + } + + /** Declares only a static operation. */ + public interface StaticOnlyService { + @NotNull + static String ignored() { + return "local"; + } + } + + /** Declares only local object operations. */ + public interface ObjectOnlyService { + boolean equals(@Nullable Object value); + + int hashCode(); + + @NotNull + String toString(); + } + + /** Mixes an abstract operation with an unsupported default method. */ + public interface DefaultService { + @NotNull + String echo(@NotNull String value); + + @NotNull + default String local(@NotNull String value) { + return value; + } + } + + /** Defines an unsupported variable argument operation. */ + public interface VarargsService { + @NotNull + String echo(@NotNull String... values); + } + + /** Defines an operation without an argument. */ + public interface NoArgumentService { + @NotNull + String echo(); + } + + /** Defines an operation with multiple arguments. */ + public interface TwoArgumentService { + @NotNull + String echo(@NotNull String first, @NotNull String second); + } + + /** Defines an unresolved generic method. */ + public interface GenericMethodService { + @NotNull + T echo(@NotNull T value); + } + + /** Defines an unsupported future return type. */ + public interface FutureService { + @NotNull + Future echo(@NotNull String value); + } + + /** Defines an unsupported completion stage return type. */ + public interface StageService { + @NotNull + CompletionStage echo(@NotNull String value); + } + + /** Defines an unsupported concrete asynchronous return type. */ + public interface CompletableService { + @NotNull + CompletableFuture echo(@NotNull String value); + } + + /** Mixes a valid operation with an ignored static method. */ + public interface StaticService { + @NotNull + String echo(@NotNull String value); + + static void ignored() { + } + } + + /** Preserves concrete parameterized operation types. */ + public interface ParameterizedService { + @NotNull + List echo(@NotNull List value); + } + + /** Defines a parameterized return with a concrete wildcard bound. */ + public interface BaseNumberService { + @NotNull + List numbers(@NotNull String value); + } + + /** Narrows an inherited parameterized return to integers. */ + public interface NumberService extends BaseNumberService { + @NotNull + @Override + List numbers(@NotNull String value); + } + + /** Defines a counter with a concrete parameterized argument. */ + public interface TypedListCounter { + int count(@NotNull List values); + } + + /** Defines a counter with a raw list argument. */ + @SuppressWarnings("rawtypes") + public interface RawListCounter { + int count(@NotNull List values); + } + + /** Inherits unrelated counter methods with unequal generic parameter types. */ + public interface AmbiguousListCounter extends TypedListCounter, RawListCounter { + } + + /** Resolves inherited counter signatures by explicitly redeclaring the raw argument. */ + @SuppressWarnings("rawtypes") + public interface ResolvedListCounter extends AmbiguousListCounter { + @Override + int count(@NotNull List values); + } + + /** Defines a loader with a wildcard list result. */ + public interface WideListLoader { + @NotNull + List load(@NotNull String value); + } + + /** Defines a loader with a concrete parameterized result. */ + public interface NarrowListLoader { + @NotNull + List load(@NotNull String value); + } + + /** Inherits unrelated loader methods with unequal generic return types. */ + public interface AmbiguousListLoader extends WideListLoader, NarrowListLoader { + } + + /** Resolves inherited loader results by explicitly redeclaring the concrete type. */ + public interface ResolvedListLoader extends AmbiguousListLoader { + @NotNull + @Override + List load(@NotNull String value); + } + + /** Defines a loader with a uniquely narrower raw return class. */ + public interface ArrayListLoader { + @NotNull + ArrayList load(@NotNull String value); + } + + /** Orders the wildcard and typed list parents before the array list parent. */ + public interface WideNarrowArrayLoader extends WideListLoader, NarrowListLoader, ArrayListLoader { + } + + /** Orders the wildcard parent before the array list and typed list parents. */ + public interface WideArrayNarrowLoader extends WideListLoader, ArrayListLoader, NarrowListLoader { + } + + /** Orders the typed and wildcard list parents before the array list parent. */ + public interface NarrowWideArrayLoader extends NarrowListLoader, WideListLoader, ArrayListLoader { + } + + /** Orders the typed parent before the array list and wildcard list parents. */ + public interface NarrowArrayWideLoader extends NarrowListLoader, ArrayListLoader, WideListLoader { + } + + /** Orders the array list parent before the wildcard and typed list parents. */ + public interface ArrayWideNarrowLoader extends ArrayListLoader, WideListLoader, NarrowListLoader { + } + + /** Orders the array list parent before the typed and wildcard list parents. */ + public interface ArrayNarrowWideLoader extends ArrayListLoader, NarrowListLoader, WideListLoader { + } +} diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java new file mode 100644 index 0000000..6ccd64a --- /dev/null +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java @@ -0,0 +1,921 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import com.dbeaver.messaging.requestreply.MessageSubscription; +import com.dbeaver.messaging.requestreply.RequestReplyEndpoint; +import com.dbeaver.messaging.requestreply.RequestReplyException; +import com.dbeaver.messaging.requestreply.RequestReplyMessage; +import com.dbeaver.messaging.requestreply.RequestReplyOptions; +import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import com.dbeaver.messaging.rpc.fixtures.PublicInheritedService; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.lang.reflect.Modifier; +import java.lang.reflect.Type; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(30) +public class RpcEndpointTest extends RpcTestSupport { + @Test + void servesIndependentServicesInBothDirectionsAndAcrossTopics() throws Exception { + var leftTransport = new MemoryTransport(); + var rightTransport = new MemoryTransport(); + connect(leftTransport, rightTransport); + try (var left = RpcEndpoint.open(leftTransport, new TestCodec()); + var right = RpcEndpoint.open(rightTransport, new TestCodec(), OPTIONS); + var leftService = left.register("echo", EchoService.class, value -> "left:" + value); + var rightService = right.register("echo", EchoService.class, value -> "right:" + value); + var otherService = right.register("other", EchoService.class, value -> "other:" + value)) { + EchoService fromLeft = left.proxy("echo", EchoService.class); + EchoService fromRight = right.proxy("echo", EchoService.class); + + assertEquals("right:first", fromLeft.echo("first")); + assertEquals("left:second", fromRight.echo("second")); + assertEquals("other:third", left.proxy("other", EchoService.class).echo("third")); + assertEquals(1, leftTransport.listeners.size()); + assertEquals(1, rightTransport.listeners.size()); + } + assertEquals(1, leftTransport.subscriptionCloses.get()); + assertEquals(1, rightTransport.subscriptionCloses.get()); + } + + @Test + void passesDeclaredParameterizedTypesAndNullValuesThroughEveryCodecBoundary() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + connect(clientTransport, serverTransport); + try (var client = RpcEndpoint.open(clientTransport, clientCodec, OPTIONS); + var server = RpcEndpoint.open(serverTransport, serverCodec, OPTIONS); + var registration = server.register("collection", CollectionService.class, + values -> values == null ? null : Map.of("size", values.size()))) { + CollectionService proxy = client.proxy("collection", CollectionService.class); + var values = new ArrayList<>(List.of("one", "two")); + Map expected = Map.of("size", 2); + + assertEquals(expected, proxy.count(values)); + assertNull(proxy.count(null)); + + var operation = CollectionService.class.getMethod("count", List.class); + Type parameterType = operation.getGenericParameterTypes()[0]; + Type returnType = operation.getGenericReturnType(); + assertEquals(List.of(new CodecCall(values, parameterType), new CodecCall(null, parameterType)), clientCodec.encoded); + assertEquals(clientCodec.encoded, serverCodec.decoded); + assertEquals(List.of(new CodecCall(expected, returnType), new CodecCall(null, returnType)), serverCodec.encoded); + assertEquals(serverCodec.encoded, clientCodec.decoded); + assertEquals("count(java.util.List)", clientTransport.next().payload().operation()); + assertNull(clientTransport.next().payload().payload()); + assertNull(serverTransport.next().payload().topic()); + assertEquals(RpcMessage.response(null), serverTransport.next().payload()); + } + } + + @Test + void supportsEveryPrimitiveWithoutChangingItsDeclaredCodecType() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + connect(clientTransport, serverTransport); + try (var client = RpcEndpoint.open(clientTransport, clientCodec, OPTIONS); + var server = RpcEndpoint.open(serverTransport, serverCodec, OPTIONS); + var registration = server.register("primitive", PrimitiveService.class, new PrimitiveEcho())) { + PrimitiveService proxy = client.proxy("primitive", PrimitiveService.class); + + assertTrue(proxy.echo(true)); + assertEquals((byte) 12, proxy.echo((byte) 12)); + assertEquals((short) 123, proxy.echo((short) 123)); + assertEquals('x', proxy.echo('x')); + assertEquals(1234, proxy.echo(1234)); + assertEquals(12345L, proxy.echo(12345L)); + assertEquals(1.25f, proxy.echo(1.25f)); + assertEquals(2.5d, proxy.echo(2.5d)); + + List expected = List.of(boolean.class, byte.class, short.class, char.class, + int.class, long.class, float.class, double.class); + assertEquals(expected, clientCodec.encoded.stream().map(CodecCall::type).toList()); + assertEquals(clientCodec.encoded, serverCodec.decoded); + assertEquals(clientCodec.encoded, serverCodec.encoded); + assertEquals(clientCodec.encoded, clientCodec.decoded); + } + } + + @Test + void dispatchesOverloadsAndInheritedMethodsWithTheNarrowestCovariantReturn() throws Exception { + assertTrue(Arrays.stream(OverloadedService.class.getDeclaredMethods()).anyMatch(method -> + method.isBridge() && method.isSynthetic() && Arrays.equals(method.getParameterTypes(), new Class[] {String.class}))); + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + connect(clientTransport, serverTransport); + var implementation = new OverloadedService() { + @NotNull + @Override + public String convert(@NotNull String value) { + return "text:" + value; + } + + @NotNull + @Override + public String convert(int value) { + return "number:" + value; + } + + @NotNull + @Override + public String inherited(@NotNull Long value) { + return "inherited:" + value; + } + }; + try (var client = RpcEndpoint.open(clientTransport, clientCodec, OPTIONS); + var server = RpcEndpoint.open(serverTransport, serverCodec, OPTIONS); + var registration = server.register("overload", OverloadedService.class, implementation)) { + OverloadedService proxy = client.proxy("overload", OverloadedService.class); + BaseService base = proxy; + + assertEquals("text:value", base.convert("value")); + assertEquals("text:child", proxy.convert("child")); + assertEquals("number:42", proxy.convert(42)); + assertEquals("inherited:9", proxy.inherited(9L)); + + assertEquals("convert(java.lang.String)", clientTransport.next().payload().operation()); + assertEquals("convert(java.lang.String)", clientTransport.next().payload().operation()); + assertEquals("convert(int)", clientTransport.next().payload().operation()); + assertEquals("inherited(java.lang.Long)", clientTransport.next().payload().operation()); + assertEquals(List.of(String.class, String.class, String.class, String.class), + clientCodec.decoded.stream().map(CodecCall::type).toList()); + assertEquals(clientCodec.decoded, serverCodec.encoded); + } + } + + @Test + void invokesAnOperationInheritedFromANonPublicParentInAnotherPackage() throws Exception { + PublicInheritedService implementation = value -> "inherited:" + value; + var operation = PublicInheritedService.class.getMethod("inherited", String.class); + assertFalse(Modifier.isPublic(operation.getDeclaringClass().getModifiers())); + assertNotEquals(RpcEndpoint.class.getPackageName(), operation.getDeclaringClass().getPackageName()); + assertFalse(operation.canAccess(implementation)); + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + connect(clientTransport, serverTransport); + try (var client = RpcEndpoint.open(clientTransport, new TestCodec(), OPTIONS); + var server = RpcEndpoint.open(serverTransport, new TestCodec(), OPTIONS); + var registration = server.register("inherited", PublicInheritedService.class, implementation)) { + PublicInheritedService proxy = client.proxy("inherited", PublicInheritedService.class); + + assertEquals("inherited:value", proxy.inherited("value")); + assertEquals("inherited(java.lang.String)", clientTransport.next().payload().operation()); + assertEquals(RpcMessage.response("inherited:value"), serverTransport.next().payload()); + } + } + + @Test + void voidCallsWaitForHandlerCompletionAndDoNotEncodeOrDecodeAResult() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + connect(clientTransport, serverTransport); + try (var client = RpcEndpoint.open(clientTransport, clientCodec, OPTIONS); + var server = RpcEndpoint.open(serverTransport, serverCodec, OPTIONS); + var registration = server.register("void", VoidService.class, value -> { + assertEquals("work", value); + entered.countDown(); + await(release); + })) { + try { + Future pending = async(() -> { + client.proxy("void", VoidService.class).accept("work"); + return null; + }); + await(entered); + assertFalse(pending.isDone()); + assertTrue(serverTransport.sent.isEmpty()); + + release.countDown(); + + assertNull(result(pending)); + assertEquals(RpcMessage.response(null), serverTransport.next().payload()); + assertEquals(List.of(new CodecCall("work", String.class)), clientCodec.encoded); + assertEquals(clientCodec.encoded, serverCodec.decoded); + assertTrue(serverCodec.encoded.isEmpty()); + assertTrue(clientCodec.decoded.isEmpty()); + } finally { + release.countDown(); + } + } + } + + @Test + void reportsHandlerFailuresWithoutLeakingPrivateDetailsOrRetrying() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var calls = new AtomicInteger(); + connect(clientTransport, serverTransport); + try (var client = RpcEndpoint.open(clientTransport, new TestCodec(), OPTIONS); + var server = RpcEndpoint.open(serverTransport, new TestCodec(), OPTIONS)) { + for (Throwable cause : List.of(new Exception("private checked detail"), + new IllegalStateException("private runtime detail"), new AssertionError("private error detail"))) { + try (var registration = server.register("failure", FailingService.class, value -> { + calls.incrementAndGet(); + if (cause instanceof Exception exception) { + throw exception; + } + throw (Error) cause; + })) { + RpcException failure = remoteFailure(RpcMessage.Error.HANDLER_FAILED, + () -> client.proxy("failure", FailingService.class).fail("secret argument")); + + assertFalse(failure.toString().contains("private")); + assertFalse(failure.toString().contains("secret")); + assertEquals(RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED), serverTransport.next().payload()); + assertEquals("secret argument", clientTransport.next().payload().payload()); + assertTrue(clientTransport.sent.isEmpty()); + assertTrue(serverTransport.sent.isEmpty()); + } + } + assertEquals(3, calls.get()); + } + } + + @Test + void distinguishesLocalAndRemoteCodecFailuresAtAllFourBoundariesAndRecovers() throws Exception { + for (int boundary = 0; boundary < 4; boundary++) { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + var cause = new Exception("private codec detail"); + var calls = new AtomicInteger(); + Conversion broken = (value, type) -> { + throw cause; + }; + switch (boundary) { + case 0 -> clientCodec.encoder = broken; + case 1 -> serverCodec.decoder = broken; + case 2 -> serverCodec.encoder = broken; + case 3 -> clientCodec.decoder = broken; + default -> throw new AssertionError("Unexpected codec boundary"); + } + connect(clientTransport, serverTransport); + try (var client = RpcEndpoint.open(clientTransport, clientCodec, OPTIONS); + var server = RpcEndpoint.open(serverTransport, serverCodec, OPTIONS); + var registration = server.register("echo", EchoService.class, value -> { + calls.incrementAndGet(); + return value; + })) { + EchoService proxy = client.proxy("echo", EchoService.class); + if (boundary == 0 || boundary == 3) { + RpcException failure = failure(RpcException.Reason.CODEC_FAILED, () -> proxy.echo("secret argument")); + assertSame(cause, failure.getCause()); + assertNull(failure.remoteError()); + } else { + RpcException failure = remoteFailure(RpcMessage.Error.CODEC_FAILED, () -> proxy.echo("secret argument")); + assertFalse(failure.toString().contains("private")); + assertEquals(RpcMessage.failure(RpcMessage.Error.CODEC_FAILED), serverTransport.next().payload()); + } + assertEquals(boundary < 2 ? 0 : 1, calls.get()); + assertEquals(boundary == 0 ? 0 : 1, clientTransport.sent.size()); + + clientCodec.encoder = (value, type) -> value; + clientCodec.decoder = (value, type) -> value; + serverCodec.encoder = (value, type) -> value; + serverCodec.decoder = (value, type) -> value; + assertEquals("recovered", proxy.echo("recovered")); + assertEquals(boundary < 2 ? 1 : 2, calls.get()); + } + } + } + + @Test + void rejectsPrimitiveNullsAndWrongDecodedArgumentTypesBeforeInvokingAService() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var codec = new TestCodec(); + var calls = new AtomicInteger(); + connect(clientTransport, serverTransport); + try (var client = RpcEndpoint.open(clientTransport, new TestCodec(), OPTIONS); + var server = RpcEndpoint.open(serverTransport, codec, OPTIONS); + var references = server.register("reference", EchoService.class, value -> { + calls.incrementAndGet(); + return value; + }); + var primitives = server.register("primitive", IntService.class, value -> { + calls.incrementAndGet(); + return value; + })) { + for (Object invalid : Arrays.asList(null, "wrong primitive", 1L)) { + codec.decoder = (value, type) -> invalid; + remoteFailure(RpcMessage.Error.INVALID_REQUEST, () -> client.proxy("primitive", IntService.class).echo(1)); + } + codec.decoder = (value, type) -> 42; + remoteFailure(RpcMessage.Error.INVALID_REQUEST, () -> client.proxy("reference", EchoService.class).echo("value")); + + assertEquals(0, calls.get()); + assertTrue(codec.encoded.isEmpty()); + } + } + + @Test + void rejectsPrimitiveNullsAndWrongDecodedResultTypesAsInvalidResponses() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var codec = new TestCodec(); + connect(clientTransport, serverTransport); + try (var client = RpcEndpoint.open(clientTransport, codec, OPTIONS); + var server = RpcEndpoint.open(serverTransport, new TestCodec(), OPTIONS); + var references = server.register("reference", EchoService.class, value -> value); + var primitives = server.register("primitive", IntService.class, value -> value)) { + for (Object invalid : Arrays.asList(null, "wrong primitive", 1L)) { + codec.decoder = (value, type) -> invalid; + RpcException failure = failure(RpcException.Reason.INVALID_RESPONSE, + () -> client.proxy("primitive", IntService.class).echo(1)); + assertNull(failure.remoteError()); + } + codec.decoder = (value, type) -> 42; + failure(RpcException.Reason.INVALID_RESPONSE, () -> client.proxy("reference", EchoService.class).echo("value")); + } + } + + @Test + void rejectsUnknownTopicsUnknownOperationsAndNonRequestEnvelopesBeforeDecoding() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var codec = new TestCodec(); + var calls = new AtomicInteger(); + connect(clientTransport, serverTransport); + try (var client = RequestReplyEndpoint.open(clientTransport, OPTIONS); + var server = RpcEndpoint.open(serverTransport, codec, OPTIONS); + var registration = server.register("echo", EchoService.class, value -> { + calls.incrementAndGet(); + return value; + })) { + assertEquals(RpcMessage.failure(RpcMessage.Error.UNKNOWN_SERVICE), + client.request(RpcMessage.request("missing", "echo(java.lang.String)", "value"))); + for (String operation : List.of("missing(java.lang.String)", "echo(int)")) { + assertEquals(RpcMessage.failure(RpcMessage.Error.UNKNOWN_OPERATION), + client.request(RpcMessage.request("echo", operation, "value"))); + } + for (RpcMessage invalid : List.of(RpcMessage.response("not a request"), + RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED))) { + assertEquals(RpcMessage.failure(RpcMessage.Error.INVALID_REQUEST), client.request(invalid)); + } + assertEquals(0, calls.get()); + assertTrue(codec.encoded.isEmpty()); + assertTrue(codec.decoded.isEmpty()); + } + } + + @Test + void rejectsMissingResponseEnvelopesRequestsUsedAsRepliesAndNonemptyVoidReplies() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class); + List> invalid = Arrays.asList(null, RpcMessage.request("echo", "echo(java.lang.String)", "value")); + for (RpcMessage reply : invalid) { + transport.sender = request -> { + transport.deliver(request.response(reply)); + return CompletableFuture.completedFuture(null); + }; + failure(RpcException.Reason.INVALID_RESPONSE, () -> proxy.echo("value")); + } + transport.sender = request -> { + transport.deliver(request.response(RpcMessage.response("unexpected void payload"))); + return CompletableFuture.completedFuture(null); + }; + failure(RpcException.Reason.INVALID_RESPONSE, () -> endpoint.proxy("void", VoidService.class).accept("value")); + assertTrue(codec.decoded.isEmpty()); + assertEquals(3, transport.sent.size()); + } + } + + @Test + void scopesRegistrationHandlesByIdentityAndLetsAcceptedCallsFinishAfterUnregistering() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + connect(clientTransport, serverTransport); + try (var client = RpcEndpoint.open(clientTransport, new TestCodec(), OPTIONS); + var server = RpcEndpoint.open(serverTransport, new TestCodec(), OPTIONS); + var other = server.register("other", EchoService.class, value -> "other:" + value); + var original = server.register("echo", EchoService.class, value -> { + entered.countDown(); + await(release); + return "original:" + value; + })) { + try { + EchoService proxy = client.proxy("echo", EchoService.class); + assertThrows(IllegalStateException.class, () -> server.register("echo", EchoService.class, value -> value)); + Future accepted = async(() -> proxy.echo("accepted")); + await(entered); + + original.close(); + remoteFailure(RpcMessage.Error.UNKNOWN_SERVICE, () -> proxy.echo("unregistered")); + try (var replacement = server.register("echo", EchoService.class, value -> "replacement:" + value)) { + original.close(); + assertEquals("replacement:next", proxy.echo("next")); + assertEquals("other:next", client.proxy("other", EchoService.class).echo("next")); + release.countDown(); + assertEquals("original:accepted", result(accepted)); + } + remoteFailure(RpcMessage.Error.UNKNOWN_SERVICE, () -> proxy.echo("removed replacement")); + assertEquals("other:surviving", client.proxy("other", EchoService.class).echo("surviving")); + assertEquals(1, serverTransport.listeners.size()); + assertEquals(0, serverTransport.subscriptionCloses.get()); + } finally { + release.countDown(); + } + } + } + + @Test + void closingAnEndpointFailsPendingCallsAndClosesTheSuccessfullyBorrowedRequestEndpoint() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var requests = RequestReplyEndpoint.open(transport, OPTIONS); + var endpoint = RpcEndpoint.open(requests, codec)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class); + Future pending = async(() -> proxy.echo("pending")); + transport.next(); + + endpoint.close(); + + RpcException failure = failure(RpcException.Reason.CLOSED, pending); + assertNull(failure.remoteError()); + failure(RpcException.Reason.CLOSED, () -> proxy.echo("closed")); + failure(RpcException.Reason.CLOSED, () -> endpoint.proxy("echo", EchoService.class)); + failure(RpcException.Reason.CLOSED, () -> endpoint.register("echo", EchoService.class, value -> value)); + RequestReplyException closed = assertThrows(RequestReplyException.class, + () -> requests.request(RpcMessage.request("echo", "echo(java.lang.String)", "closed"))); + assertEquals(RequestReplyException.Reason.CLOSED, closed.reason()); + assertTrue(transport.listeners.isEmpty()); + assertTrue(transport.sent.isEmpty()); + assertEquals(1, transport.subscriptionCloses.get()); + assertEquals(1, codec.encoded.size()); + endpoint.close(); + assertEquals(1, transport.subscriptionCloses.get()); + } + } + + @Test + void failedBorrowedOpenPreservesTheExistingHandlerAndSubscription() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + connect(clientTransport, serverTransport); + try (var client = RequestReplyEndpoint.open(clientTransport, OPTIONS); + var requests = RequestReplyEndpoint.open(serverTransport, OPTIONS); + var handler = requests.handle(message -> RpcMessage.response("existing handler"))) { + assertThrows(IllegalStateException.class, () -> RpcEndpoint.open(requests, new TestCodec())); + assertThrows(NullPointerException.class, () -> RpcEndpoint.open(requests, null)); + + assertEquals(RpcMessage.response("existing handler"), client.request(RpcMessage.request("echo", "echo(int)", 1))); + assertEquals(1, serverTransport.listeners.size()); + assertEquals(0, serverTransport.subscriptionCloses.get()); + } + } + + @Test + void disconnectFailsPendingCallsAndFreshCallsDoNotReplayThem() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = RpcEndpoint.open(transport, new TestCodec(), OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class); + Future pending = async(() -> proxy.echo("old")); + final var oldRequest = transport.next(); + + transport.disconnect(); + + RpcException failure = failure(RpcException.Reason.DISCONNECTED, pending); + assertNull(failure.remoteError()); + assertTrue(transport.sent.isEmpty()); + Future fresh = async(() -> proxy.echo("fresh")); + var freshRequest = transport.next(); + assertEquals("fresh", freshRequest.payload().payload()); + transport.deliver(oldRequest.response(RpcMessage.response("late"))); + assertFalse(fresh.isDone()); + transport.deliver(freshRequest.response(RpcMessage.response("fresh reply"))); + + assertEquals("fresh reply", result(fresh)); + assertTrue(transport.sent.isEmpty()); + assertEquals(0, transport.subscriptionCloses.get()); + } + } + + @Test + void usesTheConfiguredTimeoutWithoutRetryingTheEncodedRequest() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + var options = new RequestReplyOptions(Duration.ofMillis(100), 1, 1); + try (var endpoint = RpcEndpoint.open(transport, codec, options)) { + RpcException failure = failure(RpcException.Reason.TIMEOUT, () -> endpoint.proxy("echo", EchoService.class).echo("expire")); + + assertNull(failure.remoteError()); + assertEquals("expire", transport.next().payload().payload()); + assertTrue(transport.sent.isEmpty()); + assertEquals(List.of(new CodecCall("expire", String.class)), codec.encoded); + assertTrue(codec.decoded.isEmpty()); + } + } + + @Test + void mapsSendFailureAndPendingOverloadWithoutSendingAnExtraRequest() throws Exception { + var transport = new MemoryTransport(); + var cause = new IllegalStateException("send failed"); + var options = new RequestReplyOptions(WAIT, 1, 1); + transport.sender = request -> CompletableFuture.failedFuture(cause); + try (var endpoint = RpcEndpoint.open(transport, new TestCodec(), options)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class); + RpcException failed = failure(RpcException.Reason.SEND_FAILED, () -> proxy.echo("failed")); + assertSame(cause, assertInstanceOf(RequestReplyException.class, failed.getCause()).getCause()); + assertNull(failed.remoteError()); + transport.next(); + assertTrue(transport.sent.isEmpty()); + + transport.sender = request -> CompletableFuture.completedFuture(null); + final Future pending = async(() -> proxy.echo("pending")); + var request = transport.next(); + failure(RpcException.Reason.OVERLOADED, () -> proxy.echo("excess")); + assertTrue(transport.sent.isEmpty()); + transport.deliver(request.response(RpcMessage.response("completed"))); + assertEquals("completed", result(pending)); + } + } + + @Test + void mapsRequestReplyRemoteFailuresWithoutInventingAnRpcErrorCode() throws Exception { + var transport = new MemoryTransport(); + transport.sender = request -> { + transport.deliver(request.failure(RequestReplyMessage.Error.OVERLOADED)); + return CompletableFuture.completedFuture(null); + }; + try (var endpoint = RpcEndpoint.open(transport, new TestCodec(), OPTIONS)) { + RpcException failure = failure(RpcException.Reason.REMOTE_ERROR, () -> endpoint.proxy("echo", EchoService.class).echo("value")); + assertNull(failure.remoteError()); + assertEquals(RequestReplyMessage.Error.OVERLOADED, + assertInstanceOf(RequestReplyException.class, failure.getCause()).remoteError()); + assertEquals(1, transport.sent.size()); + } + } + + @Test + void restoresTheCallerInterruptFlagWhenAPendingRequestIsInterrupted() throws Exception { + var transport = new MemoryTransport(); + try (var endpoint = RpcEndpoint.open(transport, new TestCodec(), OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class); + var pending = new FutureTask<>(() -> { + RpcException failure = failure(RpcException.Reason.INTERRUPTED, () -> proxy.echo("interrupt")); + assertInstanceOf(InterruptedException.class, failure.getCause()); + assertNull(failure.remoteError()); + return Thread.currentThread().isInterrupted(); + }); + Thread caller = start(pending); + transport.next(); + + caller.interrupt(); + + assertEquals(Boolean.TRUE, result(pending)); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void alreadyInterruptedCallersKeepTheirFlagWithoutInvokingTheCodecOrSending() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class); + Future flag = async(() -> { + Thread.currentThread().interrupt(); + try { + failure(RpcException.Reason.INTERRUPTED, () -> proxy.echo("value")); + return Thread.currentThread().isInterrupted(); + } finally { + Thread.interrupted(); + } + }); + + assertEquals(Boolean.TRUE, result(flag)); + assertTrue(codec.encoded.isEmpty()); + assertTrue(codec.decoded.isEmpty()); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void restoresTheCallerInterruptFlagWhenEitherLocalCodecBoundaryIsInterrupted() throws Exception { + for (boolean encoding : List.of(false, true)) { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + Conversion interrupted = (value, type) -> { + throw new InterruptedException("codec interrupted"); + }; + if (encoding) { + codec.encoder = interrupted; + } else { + codec.decoder = interrupted; + } + transport.sender = request -> { + transport.deliver(request.response(RpcMessage.response("value"))); + return CompletableFuture.completedFuture(null); + }; + try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + Future flag = async(() -> { + failure(RpcException.Reason.INTERRUPTED, () -> endpoint.proxy("echo", EchoService.class).echo("value")); + return Thread.currentThread().isInterrupted(); + }); + + assertEquals(Boolean.TRUE, result(flag)); + assertEquals(encoding ? 0 : 1, transport.sent.size()); + } + } + } + + @Test + void processesParallelCallsAndConcurrentDecodesWithOutOfOrderCompletion() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var serverCodec = new TestCodec(); + var decoding = new CountDownLatch(4); + var releaseDecoding = new CountDownLatch(1); + var entered = new CountDownLatch(4); + var release = new LinkedHashMap(); + for (String value : List.of("one", "two", "three", "four")) { + release.put(value, new CountDownLatch(1)); + } + serverCodec.decoder = (value, type) -> { + decoding.countDown(); + await(releaseDecoding); + return value; + }; + connect(clientTransport, serverTransport); + try (var client = RpcEndpoint.open(clientTransport, new TestCodec(), OPTIONS); + var server = RpcEndpoint.open(serverTransport, serverCodec, OPTIONS); + var registration = server.register("echo", EchoService.class, value -> { + entered.countDown(); + await(release.get(value)); + return "reply:" + value; + })) { + try { + EchoService proxy = client.proxy("echo", EchoService.class); + var results = new LinkedHashMap>(); + for (String value : release.keySet()) { + results.put(value, async(() -> proxy.echo(value))); + } + var order = new ArrayList(); + for (int index = 0; index < results.size(); index++) { + order.add(assertInstanceOf(String.class, clientTransport.next().payload().payload())); + } + await(decoding); + releaseDecoding.countDown(); + await(entered); + assertTrue(results.values().stream().noneMatch(Future::isDone)); + + for (int index = order.size() - 1; index >= 0; index--) { + String value = order.get(index); + release.get(value).countDown(); + assertEquals("reply:" + value, result(results.get(value))); + assertEquals(RpcMessage.response("reply:" + value), serverTransport.next().payload()); + } + assertEquals(4, serverCodec.decoded.size()); + assertEquals(4, serverCodec.encoded.size()); + assertTrue(serverTransport.sent.isEmpty()); + } finally { + releaseDecoding.countDown(); + release.values().forEach(CountDownLatch::countDown); + } + } + } + + @Test + void objectMethodsRemainLocalAndUseProxyIdentityEvenAfterClosing() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + EchoService first = endpoint.proxy("echo", EchoService.class); + EchoService second = endpoint.proxy("echo", EchoService.class); + + assertTrue(first.equals(first)); + assertFalse(first.equals(second)); + assertFalse(first.equals(null)); + assertFalse(first.equals(new Object())); + assertEquals(System.identityHashCode(first), first.hashCode()); + assertTrue(first.toString().contains("echo")); + assertTrue(first.toString().contains(EchoService.class.getName())); + assertNotEquals(first, second); + endpoint.close(); + assertTrue(first.equals(first)); + assertEquals(System.identityHashCode(first), first.hashCode()); + assertTrue(first.toString().contains("echo")); + + assertTrue(codec.encoded.isEmpty()); + assertTrue(codec.decoded.isEmpty()); + assertTrue(transport.sent.isEmpty()); + } + } + + @Test + void rejectsInvalidTopicsNullArgumentsAndOptionsBeforeSubscribingOrSending() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + assertThrows(NullPointerException.class, () -> RpcEndpoint.open(transport, null, OPTIONS)); + assertThrows(NullPointerException.class, () -> RpcEndpoint.open(transport, codec, null)); + assertThrows(NullPointerException.class, () -> RpcEndpoint.open((RequestReplyTransport>) null, codec, OPTIONS)); + assertThrows(NullPointerException.class, () -> RpcEndpoint.open((RequestReplyEndpoint>) null, codec)); + assertTrue(transport.listeners.isEmpty()); + + try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + for (String topic : List.of("", " ", "\n", "t".repeat(257))) { + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy(topic, EchoService.class)); + assertThrows(IllegalArgumentException.class, () -> endpoint.register(topic, EchoService.class, value -> value)); + } + assertThrows(NullPointerException.class, () -> endpoint.proxy(null, EchoService.class)); + assertThrows(NullPointerException.class, () -> endpoint.proxy("echo", null)); + assertThrows(NullPointerException.class, () -> endpoint.register(null, EchoService.class, value -> value)); + assertThrows(NullPointerException.class, () -> endpoint.register("echo", EchoService.class, null)); + assertTrue(transport.sent.isEmpty()); + assertTrue(codec.encoded.isEmpty()); + } + } + + @Test + void propagatesSubscriptionFailureWithoutSending() { + var expected = new RequestReplyException(RequestReplyException.Reason.DISCONNECTED); + var transport = new RequestReplyTransport>() { + @NotNull + @Override + public MessageSubscription subscribe( + @NotNull UUID endpointId, + @NotNull Listener> listener + ) throws RequestReplyException { + throw expected; + } + + @NotNull + @Override + public CompletionStage send(@NotNull RequestReplyMessage> message) { + throw new AssertionError("Subscription failure must prevent sending"); + } + }; + + assertSame(expected, assertThrows(RequestReplyException.class, () -> RpcEndpoint.open(transport, new TestCodec(), OPTIONS))); + } + + /** Defines nullable reference calls and redeclares local object methods. */ + public interface EchoService { + @Nullable + String echo(@Nullable String value) throws InterruptedException; + + boolean equals(@Nullable Object value); + + int hashCode(); + + @NotNull + String toString(); + } + + /** Defines a concrete parameterized request and response. */ + public interface CollectionService { + @Nullable + Map count(@Nullable List values); + } + + /** Defines synchronous completion without a result. */ + public interface VoidService { + void accept(@Nullable String value) throws InterruptedException; + } + + /** Defines an operation that may fail with a checked exception. */ + public interface FailingService { + @NotNull + String fail(@NotNull String value) throws Exception; + } + + /** Defines an integer request and response. */ + public interface IntService { + int echo(int value); + } + + /** Defines inherited and covariant remote operations. */ + public interface BaseService { + @NotNull + CharSequence convert(@NotNull String value); + + @NotNull + String inherited(@NotNull Long value); + } + + /** Narrows an inherited return type and adds a primitive overload. */ + public interface OverloadedService extends BaseService { + @NotNull + @Override + String convert(@NotNull String value); + + @NotNull + String convert(int value); + } + + /** Defines overloads for every primitive request and response. */ + public interface PrimitiveService { + boolean echo(boolean value); + + byte echo(byte value); + + short echo(short value); + + char echo(char value); + + int echo(int value); + + long echo(long value); + + float echo(float value); + + double echo(double value); + } + + private static final class PrimitiveEcho implements PrimitiveService { + @Override + public boolean echo(boolean value) { + return value; + } + + @Override + public byte echo(byte value) { + return value; + } + + @Override + public short echo(short value) { + return value; + } + + @Override + public char echo(char value) { + return value; + } + + @Override + public int echo(int value) { + return value; + } + + @Override + public long echo(long value) { + return value; + } + + @Override + public float echo(float value) { + return value; + } + + @Override + public double echo(double value) { + return value; + } + } +} diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java new file mode 100644 index 0000000..6c62805 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java @@ -0,0 +1,102 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@Timeout(10) +class RpcMessageTest { + @Test + void preservesOpaquePayloadsAndAllowsNullApplicationValues() { + Object payload = new Object(); + RpcMessage request = RpcMessage.request("service", "echo(java.lang.String)", payload); + + assertEquals(RpcMessage.Type.REQUEST, request.type()); + assertEquals("service", request.topic()); + assertEquals("echo(java.lang.String)", request.operation()); + assertSame(payload, request.payload()); + assertNull(request.error()); + assertNull(RpcMessage.request("service", "echo(java.lang.String)", null).payload()); + + RpcMessage response = RpcMessage.response(payload); + assertEquals(RpcMessage.Type.RESPONSE, response.type()); + assertSame(payload, response.payload()); + assertNull(response.topic()); + assertNull(response.operation()); + assertNull(response.error()); + assertEquals(new RpcMessage<>(RpcMessage.Type.RESPONSE, null, null, null, null), RpcMessage.response(null)); + } + + @Test + void failuresCarryOnlyASafeErrorCode() { + for (RpcMessage.Error error : RpcMessage.Error.values()) { + RpcMessage failure = RpcMessage.failure(error); + + assertEquals(RpcMessage.Type.ERROR, failure.type()); + assertEquals(error, failure.error()); + assertNull(failure.topic()); + assertNull(failure.operation()); + assertNull(failure.payload()); + } + assertThrows(NullPointerException.class, () -> RpcMessage.failure(null)); + } + + @Test + void validatesTopicAndOperationBoundsWithoutRejectingTheBoundary() { + String topic = "t".repeat(256); + String operation = "o".repeat(1024); + RpcMessage request = RpcMessage.request(topic, operation, null); + assertEquals(topic, request.topic()); + assertEquals(operation, request.operation()); + + for (String invalid : List.of("", " ", "\n", "t".repeat(257))) { + assertThrows(IllegalArgumentException.class, () -> RpcMessage.request(invalid, operation, null)); + } + for (String invalid : List.of("", " ", "\n", "o".repeat(1025))) { + assertThrows(IllegalArgumentException.class, () -> RpcMessage.request(topic, invalid, null)); + } + assertThrows(NullPointerException.class, () -> RpcMessage.request(null, operation, null)); + assertThrows(IllegalArgumentException.class, () -> RpcMessage.request(topic, null, null)); + assertThrows(NullPointerException.class, () -> new RpcMessage<>(null, null, null, null, null)); + } + + @Test + void rejectsRoutingFieldsOnRepliesAndInconsistentPayloadOrErrorFields() { + for (RpcMessage.Type type : List.of(RpcMessage.Type.RESPONSE, RpcMessage.Type.ERROR)) { + RpcMessage.Error error = type == RpcMessage.Type.ERROR ? RpcMessage.Error.HANDLER_FAILED : null; + assertThrows(IllegalArgumentException.class, () -> new RpcMessage<>(type, "service", null, null, error)); + assertThrows(IllegalArgumentException.class, () -> new RpcMessage<>(type, null, "echo(int)", null, error)); + } + assertThrows(IllegalArgumentException.class, () -> new RpcMessage<>(RpcMessage.Type.ERROR, null, null, null, null)); + for (RpcMessage.Error error : RpcMessage.Error.values()) { + assertThrows(IllegalArgumentException.class, + () -> new RpcMessage<>(RpcMessage.Type.REQUEST, "service", "echo(int)", null, error)); + assertThrows(IllegalArgumentException.class, + () -> new RpcMessage<>(RpcMessage.Type.RESPONSE, null, null, null, error)); + assertThrows(IllegalArgumentException.class, + () -> new RpcMessage<>(RpcMessage.Type.ERROR, null, null, "private payload", error)); + } + } +} diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java new file mode 100644 index 0000000..f849e3e --- /dev/null +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java @@ -0,0 +1,197 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import com.dbeaver.messaging.requestreply.MessageSubscription; +import com.dbeaver.messaging.requestreply.RequestReplyMessage; +import com.dbeaver.messaging.requestreply.RequestReplyOptions; +import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.function.Executable; + +import java.lang.reflect.Type; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RpcTestSupport { + static final Duration WAIT = Duration.ofSeconds(5); + static final RequestReplyOptions OPTIONS = new RequestReplyOptions(WAIT, 16, 16); + private final List callers = new ArrayList<>(); + + @AfterEach + void stopCallers() throws InterruptedException { + callers.forEach(Thread::interrupt); + for (Thread caller : callers) { + assertTrue(caller.join(WAIT), "Caller did not finish"); + } + } + + @NotNull + final FutureTask async(@NotNull Callable action) { + var future = new FutureTask<>(action); + start(future); + return future; + } + + @NotNull + final Thread start(@NotNull Runnable action) { + Thread thread = Thread.ofVirtual().start(action); + callers.add(thread); + return thread; + } + + @Nullable + static T result(@NotNull Future future) throws InterruptedException, ExecutionException, TimeoutException { + return future.get(WAIT.toMillis(), TimeUnit.MILLISECONDS); + } + + @NotNull + static RpcException failure(@NotNull RpcException.Reason reason, @NotNull Executable action) { + RpcException failure = assertThrows(RpcException.class, action); + assertEquals(reason, failure.reason()); + return failure; + } + + @NotNull + static RpcException failure(@NotNull RpcException.Reason reason, @NotNull Future future) { + ExecutionException wrapper = assertThrows(ExecutionException.class, () -> result(future)); + RpcException failure = assertInstanceOf(RpcException.class, wrapper.getCause()); + assertEquals(reason, failure.reason()); + return failure; + } + + @NotNull + static RpcException remoteFailure(@NotNull RpcMessage.Error error, @NotNull Executable action) { + RpcException failure = failure(RpcException.Reason.REMOTE_ERROR, action); + assertEquals(error, failure.remoteError()); + assertNull(failure.getCause()); + return failure; + } + + static void await(@NotNull CountDownLatch latch) throws InterruptedException { + assertTrue(latch.await(WAIT.toMillis(), TimeUnit.MILLISECONDS), "Latch was not released"); + } + + static void connect(@NotNull MemoryTransport first, @NotNull MemoryTransport second) { + first.sender = message -> { + second.deliver(message); + return CompletableFuture.completedFuture(null); + }; + second.sender = message -> { + first.deliver(message); + return CompletableFuture.completedFuture(null); + }; + } + + @FunctionalInterface + interface Conversion { + @Nullable + Object apply(@Nullable Object value, @NotNull Type type) throws Exception; + } + + record CodecCall(@Nullable Object value, @NotNull Type type) { + } + + static final class TestCodec implements RpcCodec { + final List encoded = new CopyOnWriteArrayList<>(); + final List decoded = new CopyOnWriteArrayList<>(); + volatile Conversion encoder = (value, type) -> value; + volatile Conversion decoder = (value, type) -> value; + + @Nullable + @Override + public Object encode(@Nullable Object value, @NotNull Type type) throws Exception { + encoded.add(new CodecCall(value, type)); + return encoder.apply(value, type); + } + + @Nullable + @Override + public Object decode(@Nullable Object payload, @NotNull Type type) throws Exception { + decoded.add(new CodecCall(payload, type)); + return decoder.apply(payload, type); + } + } + + static final class MemoryTransport implements RequestReplyTransport> { + final Map>> listeners = new ConcurrentHashMap<>(); + final BlockingQueue>> sent = new LinkedBlockingQueue<>(); + final AtomicInteger subscriptionCloses = new AtomicInteger(); + volatile Function>, CompletionStage> sender = + message -> CompletableFuture.completedFuture(null); + + @NotNull + @Override + public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) { + assertNull(listeners.putIfAbsent(endpointId, listener)); + return () -> { + if (listeners.remove(endpointId, listener)) { + subscriptionCloses.incrementAndGet(); + } + }; + } + + @NotNull + @Override + public CompletionStage send(@NotNull RequestReplyMessage> message) { + sent.add(message); + return sender.apply(message); + } + + void deliver(@NotNull RequestReplyMessage> message) { + List.copyOf(listeners.values()).forEach(listener -> listener.onMessage(message)); + } + + void disconnect() { + List.copyOf(listeners.values()).forEach(Listener::onDisconnect); + } + + @NotNull + RequestReplyMessage> next() throws InterruptedException { + RequestReplyMessage> message = sent.poll(WAIT.toMillis(), TimeUnit.MILLISECONDS); + assertNotNull(message, "No message was sent"); + return message; + } + } +} diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java new file mode 100644 index 0000000..d0d00a1 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java @@ -0,0 +1,24 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc.fixtures; + +import org.jkiss.code.NotNull; + +interface HiddenInheritedService { + @NotNull + String inherited(@NotNull String value); +} diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java new file mode 100644 index 0000000..92246b7 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java @@ -0,0 +1,21 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc.fixtures; + +/** Exposes an operation inherited from a non-public parent in a separate package. */ +public interface PublicInheritedService extends HiddenInheritedService { +} From 73432462f5c7f65b236d1bfc480a6279ee1972d4 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Wed, 21 Oct 2026 17:38:39 +0400 Subject: [PATCH 09/30] dbeaver/pro#11108 Add internal connection account lookup --- .../README.md | 135 +++++- .../api/client/DDExternalDamClient.java | 29 ++ .../requestreply/DDRequestReplyTransport.java | 93 ++++- .../external/api/rpc/DDGsonRpcCodec.java | 47 +++ .../external/api/rpc/DDRpcServices.java | 40 ++ .../api/client/DDExternalDamClientTest.java | 42 ++ .../DDRequestReplyTransportTest.java | 310 +++++++++++++- .../external/api/rpc/DDGsonRpcCodecTest.java | 83 ++++ .../api/rpc/DDRpcServicesIntegrationTest.java | 164 ++++++++ .../external/api/rpc/DDRpcServicesTest.java | 41 ++ apis/com.dbeaver.messaging/README.md | 52 ++- .../dbeaver/messaging/rpc/RpcEndpoint.java | 395 ++++++++++-------- .../messaging/rpc/RpcEndpointFacadeTest.java | 185 ++++++++ 13 files changed, 1410 insertions(+), 206 deletions(-) create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcServices.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesIntegrationTest.java create mode 100644 apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesTest.java create mode 100644 apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java diff --git a/apis/com.dbeaver.datadam.external.api/README.md b/apis/com.dbeaver.datadam.external.api/README.md index 1aac21d..d64d396 100644 --- a/apis/com.dbeaver.datadam.external.api/README.md +++ b/apis/com.dbeaver.datadam.external.api/README.md @@ -13,6 +13,7 @@ scalar JSON type Query { externalDamConnections(accountId: ID!): [ExternalDamConnection!]! + externalDamConnectionAccount(connectionId: ID!): ID } type Mutation { @@ -44,9 +45,12 @@ input ExternalDamSubscriptionInput { } ``` -- `accountId` is a UUID and is mandatory for connection queries, sends, and subscription registration. - Connection lookup and sending must use the account and connection ID together. -- Connection queries return an immutable client-side snapshot of currently ready sockets, not persisted DAM installations or history. +- `accountId` is a UUID and is mandatory for account-wide connection lists, sends, and subscription registration. + Sending must use the account and connection ID together. +- `externalDamConnectionAccount` resolves a unique physical connection ID to the account from its verified identity. + It returns null for a missing or not-ready connection. The backend client reports that result as a checked + `DDExternalDamException`. This backend-only lookup does not authorize end-user access to that account. +- Connection lists return an immutable client-side snapshot of currently ready sockets, not persisted DAM installations or history. - Concurrent sockets can share a Deployment ID and application run ID. Consumers must not deduplicate sockets using either ID. - Product and version are Desktop-supplied presentation metadata, not authenticated identity or authorization claims. - Message payloads must be JSON objects. They are passed through GraphQL variables, not interpolated into query text or encoded as JSON strings. @@ -70,6 +74,7 @@ HTTP error bodies, GraphQL error details, and malformed response contents are no The service secret authenticates a trusted backend, not an end user. A backend exposing these operations to users must derive `accountId` from its authorized account context rather than trusting an arbitrary account ID supplied by the caller. +Likewise, a connection ID alone is not end-user authorization; only expose selections from an authorized account's list. The server must validate the service secret before executing internal GraphQL operations and fail closed when its secret is missing or invalid. A browser session or Desktop JWT must not grant access to this endpoint. The secret belongs to backend runtime configuration only and must never be distributed to Desktop or browser clients. @@ -165,33 +170,30 @@ from starting; an already claimed HTTP send can finish. Physical channels report callbacks have no physical connection-state feed, so pending calls use their deadlines when the peer disappears. Timeout or interruption can leave the remote outcome unknown. No request or response is automatically retried. +The direct-channel adapter orders message callbacks and disconnection callbacks through one bounded receiving mailbox. +Source validation and mailbox insertion share the same lifecycle lock; listener callbacks run outside that lock. +If a new physical source is visible before its state observer runs, the adapter inserts the generation boundary before +that source's first message. Delayed observers read the current channel status instead of reverting to an old source. +The mailbox holds at most 128 queued callbacks and adds no executor. Excess frames are dropped; a disconnection takes +priority over a full queued batch so pending calls are still invalidated. Listener failure closes the receiving routes. + ## Typed RPC `com.dbeaver.messaging.rpc` adds typed Java proxies and registered implementations without depending on DataDam or WebSocket. `DDRpcTransport` adapts the existing request/reply transport to the neutral `RpcMessage` envelope. It contains only protocol framing; application serialization is selected by the developer through `RpcCodec`. -For example, an application can choose Gson once for an endpoint: +For example, an application can choose Gson once for an endpoint using the supplied adapter: ```java Gson gson = new Gson(); -RpcCodec codec = new RpcCodec<>() { - @Override - public JsonElement encode(Object value, Type type) { - return gson.toJsonTree(value, type); - } - - @Override - public Object decode(JsonElement payload, Type type) { - return gson.fromJson(payload, type); - } -}; +RpcCodec codec = new DDGsonRpcCodec(gson); ``` The Desktop side installs an implementation and can independently obtain proxies for backend services: ```java -var transport = DDRpcTransport.forChannel(channel, "rpc.account"); +var transport = DDRpcTransport.forChannel(channel, DDRpcServices.topic(DDRpcServices.ACCOUNT)); try (var rpc = RpcEndpoint.open(transport, codec); var registration = rpc.register("desktop.projects", DesktopProjects.class, implementation)) { ServerDocuments documents = rpc.proxy("server.documents", ServerDocuments.class); @@ -203,7 +205,7 @@ The backend selects its physical account/connection scope once and uses the same ```java var scope = subscriptions.forConnection(accountId, connectionId); -var transport = new DDRpcTransport(scope.requestReply("rpc.account")); +var transport = new DDRpcTransport(scope.requestReply(DDRpcServices.topic(DDRpcServices.ACCOUNT))); try (var rpc = RpcEndpoint.open(transport, codec); var registration = rpc.register("server.documents", ServerDocuments.class, implementation)) { DesktopProjects projects = rpc.proxy("desktop.projects", DesktopProjects.class); @@ -222,6 +224,105 @@ Sharing a lane between multiple backend receivers is not remote service discover `UNKNOWN_SERVICE` before the intended service. Replica routing and callback ownership retain the existing process-local subscription constraints. +`DDRpcServices` supplies the `account` and `storage` service IDs and maps configured service IDs to `rpc.` +lanes. A service ID contains 1 to 128 ASCII letters, digits, dots, underscores, or hyphens. Other backend services use +the same mapping without extending an enumeration. IDs and lane names are case-sensitive. + +`DDGsonRpcCodec` retains the supplied `Gson` instance, including application adapters and naming policies, and preserves +the declared generic `Type` passed by RPC. Encoded JSON null is represented as a nullable application payload. The +neutral messaging module remains independent of Gson, and applications can supply another compatible codec at setup. +The resulting `RpcEndpoint` is not parameterized by JSON or by any other payload representation. + +### Ready endpoints in Account, Storage, and Desktop + +Account and Storage already import `DDExternalDamClientConfiguration` from `com.dbeaver.datadam.external.base`. +That configuration supplies the callback receiver, subscriptions, the default RPC codec, and a shared +`DDExternalDamRpc` connection owner. Inject that owner once, select a Desktop connection, and obtain its endpoint: + +```java +var connections = rpcConnections.getConnections(accountId); +var selected = chooseConnection(connections); +RpcEndpoint rpc = rpcConnections.forConnection(selected.connectionId()); +``` + +`rpcConnections` is the `DDExternalDamRpc` Spring bean. `getConnections(accountId)` returns an immutable current +snapshot without opening RPC endpoints; `chooseConnection` represents application logic or a user selection from +those descriptions. Selection and release require only the unique physical connection ID, with no account wrapper. +The owner resolves the account through the authenticated private relay API when opening an endpoint, so a known +connection ID also works without listing connections first. No local discovery cache is required. The transport +retains the resolved account internally and preserves account-ownership checks for subscriptions and sends. +The application's configured subscriber ID selects its lane: Account uses `rpc.account`, Storage uses `rpc.storage`. +Repeated lookups return the same open endpoint; callers do not construct adapters or pass account/connection IDs +to each registration or remote call. + +Desktop obtains the corresponding endpoint from its existing connection service: + +```java +var connections = DDDesktopConnectionService.getInstance(); +RpcEndpoint accountRpc = connections.getRpc(DDRpcServices.ACCOUNT); +RpcEndpoint storageRpc = connections.getRpc(DDRpcServices.STORAGE); +``` + +The Desktop connection must first become ready. Endpoint access never starts login or waits for a new connection. +Account and Storage have independent receiving endpoints on the same WebSocket channel. Other configured backend +services use their own service ID and the same lane mapping. + +Pass the obtained endpoint to ordinary Java objects. The receiving object registers an implementation at runtime: + +```java +public final class GreetingReceiver { + private final RpcEndpoint rpc; + + public GreetingReceiver(RpcEndpoint rpc) { + this.rpc = rpc; + } + + public MessageSubscription enable(Greeting implementation) { + return rpc.register("greeting", Greeting.class, implementation); + } +} +``` + +The other peer keeps its calling code separate: + +```java +public final class GreetingCaller { + private final RpcEndpoint rpc; + + public GreetingCaller(RpcEndpoint rpc) { + this.rpc = rpc; + } + + public String hello(String value) { + Greeting greeting = rpc.proxy("greeting", Greeting.class); + return greeting.hello(value); + } +} +``` + +The common business contract is shared between those two peers, not installed on the relay: + +```java +/** Answers greeting requests. */ +public interface Greeting { + String hello(String value); +} +``` + +For example, one peer enables `value -> value + " World"`, while the other calls `hello("Hello")`. Reverse-direction +services are independently registered and called through the same endpoint. No business-interface beans are required. + +Keep the returned registration until the implementation should stop accepting new calls, then close that registration. +Proxies require no separate close. Shared endpoint ownership belongs to the connection service, not an individual +caller. Desktop retains endpoints and registrations across transient reconnects, but invalidates them before an +authentication identity change and on shutdown. Backend owners close missing physical connections after a successful +registry reconciliation; failed registry reads preserve existing endpoints. Applications can explicitly release a +backend scope with `rpcConnections.closeConnection(selected.connectionId())`. + +Replacing a closed endpoint does not transfer its old registrations. After an identity change or explicit closure, +obtain the new endpoint and register the appropriate implementations again. Backend connection owners and caches are +process-local; the existing exact-owner callback routing requirement still applies. + The inner JSON envelope uses `protocol: "dbeaver.rpc.v1"`, `type`, and `data`, plus `topic` and `operation` for requests or a fixed `error` code for failures. Application `data` can be any JSON value, including null; the outer DataDam message remains an object and retains its existing request/reply correlation and source validation. Malformed inner envelopes diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java index bd56152..21b09f9 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java @@ -35,8 +35,10 @@ public final class DDExternalDamClient extends DDExternalDamGraphQlClient { private static final int READ_TIMEOUT_MILLIS = 10_000; private static final String CONNECTIONS_FIELD = "externalDamConnections"; + private static final String CONNECTION_ACCOUNT_FIELD = "externalDamConnectionAccount"; private static final String SEND_FIELD = "sendExternalDamMessage"; private static final String CONNECTIONS_OPERATION = "ExternalDamConnections"; + private static final String CONNECTION_ACCOUNT_OPERATION = "ExternalDamConnectionAccount"; private static final String SEND_OPERATION = "SendExternalDamMessage"; private static final String CONNECTIONS_QUERY = """ query ExternalDamConnections($accountId: ID!) { @@ -45,6 +47,11 @@ query ExternalDamConnections($accountId: ID!) { } } """; + private static final String CONNECTION_ACCOUNT_QUERY = """ + query ExternalDamConnectionAccount($connectionId: ID!) { + externalDamConnectionAccount(connectionId: $connectionId) + } + """; private static final String SEND_MUTATION = """ mutation SendExternalDamMessage($accountId: ID!, $connectionId: ID!, $message: ExternalDamMessageInput!) { sendExternalDamMessage(accountId: $accountId, connectionId: $connectionId, message: $message) @@ -83,6 +90,28 @@ public List getConnections(@NotNull UUID accountId) thr return List.copyOf(connections); } + @NotNull + public UUID getConnectionAccount(@NotNull String connectionId) throws DDExternalDamException { + if (connectionId.isBlank() || connectionId.length() > 256) { + throw new IllegalArgumentException("Invalid connection ID"); + } + JsonElement result = request(CONNECTION_ACCOUNT_OPERATION, CONNECTION_ACCOUNT_QUERY, + Map.of("connectionId", connectionId), CONNECTION_ACCOUNT_FIELD); + if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isString()) { + throw invalidResponse(); + } + UUID accountId; + try { + accountId = UUID.fromString(result.getAsString()); + } catch (IllegalArgumentException e) { + throw invalidResponse(); + } + if (!accountId.toString().equalsIgnoreCase(result.getAsString())) { + throw invalidResponse(); + } + return accountId; + } + public boolean sendMessage(@NotNull UUID accountId, @NotNull String connectionId, @NotNull DDMessage message) throws DDExternalDamException { if (connectionId.isBlank()) { diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java index 3f4354c..2f6561f 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java @@ -36,6 +36,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.HexFormat; import java.util.List; @@ -48,6 +49,7 @@ public final class DDRequestReplyTransport implements RequestReplyTransport, AutoCloseable { private static final String MESSAGE_TYPE = "datadam.requestreply.v1"; private static final int MAX_PENDING_SENDS = 128; + private static final int MAX_PENDING_DELIVERIES = 128; private final Object lock = new Object(); private final Semaphore sendBudget = new Semaphore(MAX_PENDING_SENDS); private final String topic; @@ -263,10 +265,12 @@ private final class Registration implements MessageSubscription { private final String replyTopic; private final Listener listener; private final List subscriptions = new ArrayList<>(); + private final ArrayDeque deliveries = new ArrayDeque<>(); @Nullable private DDChannelStatus status; private boolean opening = true; private boolean openingDisconnected; + private boolean delivering; private boolean closed; private Registration(@NotNull UUID endpointId, @NotNull Listener listener) { @@ -297,22 +301,37 @@ private void requireReady() throws RequestReplyException { } private void state(@NotNull DDChannelStatus next) { - boolean disconnected; + boolean drain; synchronized (lock) { - if (closed || next.equals(status)) { + if (closed) { return; } - disconnected = !next.isReady() || status != null && status.isReady() - && !Objects.equals(status.connection(), next.connection()); - status = next; - openingDisconnected |= opening && disconnected; + openingDisconnected |= opening && !next.isReady(); + drain = updateState(channel == null ? next : channel.getStatus()); + } + if (drain) { + drain(); } - if (disconnected) { - listener.onDisconnect(); + } + + private boolean updateState(@NotNull DDChannelStatus next) { + if (next.equals(status)) { + return false; } + boolean disconnected = !next.isReady() || status != null && status.isReady() + && !Objects.equals(status.connection(), next.connection()); + status = next; + openingDisconnected |= opening && disconnected; + return disconnected && enqueue(null); } private void receive(@NotNull DDConnectionInfo connection, @NotNull DDMessage message, boolean requestTopic) { + RequestReplyMessage frame = decode(message); + if (frame == null || (requestTopic ? frame.type() != RequestReplyMessage.Type.REQUEST + : frame.type() == RequestReplyMessage.Type.REQUEST || !endpointId.equals(frame.replyTo()))) { + return; + } + boolean drain = false; synchronized (lock) { if (closed || !(requestTopic ? topic : replyTopic).equals(message.topic())) { return; @@ -322,12 +341,58 @@ private void receive(@NotNull DDConnectionInfo connection, @NotNull DDMessage me if (!current.isReady() || !connection.equals(current.connection())) { return; } + drain = updateState(current); } + drain |= enqueue(frame); } - RequestReplyMessage frame = decode(message); - if (frame != null && (requestTopic ? frame.type() == RequestReplyMessage.Type.REQUEST - : frame.type() != RequestReplyMessage.Type.REQUEST && endpointId.equals(frame.replyTo()))) { - listener.onMessage(frame); + if (drain) { + drain(); + } + } + + private boolean enqueue(@Nullable RequestReplyMessage message) { + if (message == null) { + if (!deliveries.isEmpty() && deliveries.getLast().message() == null) { + return false; + } + if (deliveries.size() >= MAX_PENDING_DELIVERIES) { + deliveries.clear(); + } + } else if (deliveries.size() >= MAX_PENDING_DELIVERIES) { + return false; + } + deliveries.add(new Delivery(message)); + if (delivering) { + return false; + } + delivering = true; + return true; + } + + private void drain() { + try { + while (true) { + Delivery delivery; + synchronized (lock) { + delivery = deliveries.poll(); + if (closed || delivery == null) { + delivering = false; + return; + } + } + if (delivery.message() == null) { + listener.onDisconnect(); + } else { + listener.onMessage(delivery.message()); + } + } + } catch (RuntimeException | Error e) { + try { + close(); + } catch (RuntimeException closeFailure) { + e.addSuppressed(closeFailure); + } + throw e; } } @@ -339,6 +404,7 @@ public void close() { return; } closed = true; + deliveries.clear(); stopped = List.copyOf(subscriptions); subscriptions.clear(); } @@ -359,4 +425,7 @@ public void close() { } } } + + private record Delivery(@Nullable RequestReplyMessage message) { + } } diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java new file mode 100644 index 0000000..75b65b1 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java @@ -0,0 +1,47 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.rpc; + +import com.dbeaver.messaging.rpc.RpcCodec; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.lang.reflect.Type; +import java.util.Objects; + +public final class DDGsonRpcCodec implements RpcCodec { + private final Gson gson; + + public DDGsonRpcCodec(@NotNull Gson gson) { + this.gson = Objects.requireNonNull(gson); + } + + @Override + @Nullable + public JsonElement encode(@Nullable Object value, @NotNull Type type) { + JsonElement encoded = gson.toJsonTree(value, Objects.requireNonNull(type)); + return encoded == null || encoded.isJsonNull() ? null : encoded; + } + + @Override + @Nullable + public Object decode(@Nullable JsonElement payload, @NotNull Type type) { + return gson.fromJson(payload, Objects.requireNonNull(type)); + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcServices.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcServices.java new file mode 100644 index 0000000..f844e83 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcServices.java @@ -0,0 +1,40 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.rpc; + +import org.jkiss.code.NotNull; + +import java.util.Objects; + +public final class DDRpcServices { + public static final String ACCOUNT = "account"; + public static final String STORAGE = "storage"; + + private DDRpcServices() { + } + + @NotNull + public static String topic(@NotNull String serviceId) { + Objects.requireNonNull(serviceId); + if (serviceId.isEmpty() || serviceId.length() > 128 || !serviceId.chars().allMatch(value -> + value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' + || value == '.' || value == '_' || value == '-')) { + throw new IllegalArgumentException("Expected a service ID of 1 to 128 ASCII letters, digits, dots, underscores or hyphens"); + } + return "rpc." + serviceId; + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java index a7a81c8..200780b 100644 --- a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java @@ -65,6 +65,7 @@ class DDExternalDamClientTest { private static final String SERVICE_SECRET = "external-dam-service-secret-2026-test"; private static final String UPSTREAM_MARKER = "private-upstream-response-marker"; private static final String CONNECTIONS_FIELD = "externalDamConnections"; + private static final String CONNECTION_ACCOUNT_FIELD = "externalDamConnectionAccount"; private static final String SEND_FIELD = "sendExternalDamMessage"; private final AtomicReference responseJson = new AtomicReference<>("{}"); private final AtomicInteger responseStatus = new AtomicInteger(200); @@ -116,6 +117,46 @@ void postsConnectionQueriesToTheInternalEndpointWithEitherRootUrlForm() throws D assertEquals(2, requestCount.get()); } + @Test + void resolvesTheConnectionOwnerWithOneAuthenticatedReadAndNoDiscovery() throws DDExternalDamException { + String connectionId = "connection-\"\\\n) { injectedConnection }"; + for (UUID accountId : List.of(ACCOUNT_ID, OTHER_ACCOUNT_ID)) { + respond(CONNECTION_ACCOUNT_FIELD, JsonParser.parseString("\"" + accountId + "\"")); + + assertEquals(accountId, client.getConnectionAccount(connectionId)); + + JsonObject body = posted(); + assertEquals("ExternalDamConnectionAccount", body.get("operationName").getAsString()); + String query = body.get("query").getAsString().replaceAll("\\s+", " "); + assertTrue(query.contains("query ExternalDamConnectionAccount($connectionId: ID!)")); + assertTrue(query.contains("externalDamConnectionAccount(connectionId: $connectionId)")); + assertEquals(Set.of("connectionId"), body.getAsJsonObject("variables").keySet()); + assertEquals(connectionId, body.getAsJsonObject("variables").get("connectionId").getAsString()); + assertFalse(query.contains("injectedConnection")); + } + assertEquals(2, requestCount.get()); + } + + @Test + void rejectsUnavailableNonStringAndMalformedConnectionOwnersWithoutRetrying() { + for (String value : List.of("null", "true", "false", "42", "{}", "[]", "\"\"", "\" \\t\"", + "\"not-a-uuid\"", "\"1-1-1-1-1\"", "\"" + ACCOUNT_ID + " \"", "\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\"")) { + respond(CONNECTION_ACCOUNT_FIELD, JsonParser.parseString(value)); + + assertRequestFails(() -> client.getConnectionAccount("connection-1")); + } + responseJson.set("{\"data\":{}}"); + assertRequestFails(() -> client.getConnectionAccount("connection-1")); + } + + @Test + void rejectsBlankAndOversizedConnectionOwnerLookupIdsBeforeSendingAnyRequest() { + for (String connectionId : List.of("", " ", "\t\n", "s".repeat(257))) { + assertThrows(IllegalArgumentException.class, () -> client.getConnectionAccount(connectionId)); + } + assertEquals(0, requestCount.get()); + } + @Test void preservesDistinctConcurrentConnectionsAndReturnsAnImmutableSnapshot() throws DDExternalDamException { JsonArray connections = new JsonArray(); @@ -387,6 +428,7 @@ void rejectsHttpFailuresWithoutExposingTheResponseOrRetrying() { responseStatus.set(status); assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); + assertRequestFails(() -> client.getConnectionAccount("connection-1")); assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); } } diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java index 82b10b4..8bdccaf 100644 --- a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java @@ -45,7 +45,10 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; @@ -235,6 +238,271 @@ void propagatesDisconnectAndAcceptsOnlyTheCurrentPhysicalSource() throws Excepti } } + @Test + void completesMessageDeliveryBeforeNotifyingAConcurrentConnectionReplacement() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + CountDownLatch messageEntered = new CountDownLatch(1); + CountDownLatch releaseMessage = new CountDownLatch(1); + List delivered = new CopyOnWriteArrayList<>(); + try (var subscription = adapter.subscribe(endpoint, new RequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull RequestReplyMessage message) { + messageEntered.countDown(); + await(releaseMessage); + delivered.add("message"); + } + + @Override + public void onDisconnect() { + delivered.add("disconnect"); + } + })) { + adapter.send(request(endpoint)).toCompletableFuture().join(); + DDMessage wire = channel.sent.getLast(); + FutureTask message = async(() -> channel.deliver(CONNECTION, wire)); + try { + await(messageEntered); + CountDownLatch replacing = new CountDownLatch(1); + DDConnectionInfo replacement = new DDConnectionInfo("desktop", "next-run", "next-socket"); + FutureTask state = async(() -> { + replacing.countDown(); + channel.setStatus(new DDChannelStatus(DDChannelState.CONNECTED, replacement)); + }); + try { + await(replacing); + state.get(2, TimeUnit.SECONDS); + Assertions.assertTrue(delivered.isEmpty()); + } finally { + releaseMessage.countDown(); + } + message.get(2, TimeUnit.SECONDS); + state.get(2, TimeUnit.SECONDS); + Assertions.assertEquals(List.of("message", "disconnect"), delivered); + + channel.deliver(CONNECTION, wire); + Assertions.assertEquals(List.of("message", "disconnect"), delivered); + } finally { + releaseMessage.countDown(); + message.get(2, TimeUnit.SECONDS); + } + } + } + + @Test + void notifiesConnectionReplacementBeforeDeliveringANewSourceMessage() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + CountDownLatch disconnectEntered = new CountDownLatch(1); + CountDownLatch releaseDisconnect = new CountDownLatch(1); + List delivered = new CopyOnWriteArrayList<>(); + try (var subscription = adapter.subscribe(endpoint, new RequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull RequestReplyMessage message) { + delivered.add("message"); + } + + @Override + public void onDisconnect() { + disconnectEntered.countDown(); + await(releaseDisconnect); + delivered.add("disconnect"); + } + })) { + adapter.send(request(endpoint)).toCompletableFuture().join(); + DDMessage wire = channel.sent.getLast(); + DDConnectionInfo replacement = new DDConnectionInfo("desktop", "next-run", "next-socket"); + FutureTask state = async(() -> channel.setStatus(new DDChannelStatus(DDChannelState.CONNECTED, replacement))); + try { + await(disconnectEntered); + CountDownLatch receiving = new CountDownLatch(1); + FutureTask message = async(() -> { + receiving.countDown(); + channel.deliver(replacement, wire); + }); + try { + await(receiving); + message.get(2, TimeUnit.SECONDS); + Assertions.assertTrue(delivered.isEmpty()); + } finally { + releaseDisconnect.countDown(); + } + state.get(2, TimeUnit.SECONDS); + message.get(2, TimeUnit.SECONDS); + Assertions.assertEquals(List.of("disconnect", "message"), delivered); + } finally { + releaseDisconnect.countDown(); + state.get(2, TimeUnit.SECONDS); + } + } + } + + @Test + void defersReentrantStateNotificationsUntilTheCurrentMessageReturns() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + List delivered = new ArrayList<>(); + try (var subscription = adapter.subscribe(endpoint, new RequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull RequestReplyMessage message) { + channel.setStatus(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); + delivered.add("message"); + } + + @Override + public void onDisconnect() { + delivered.add("disconnect"); + } + })) { + adapter.send(request(endpoint)).toCompletableFuture().join(); + + channel.deliver(CONNECTION, channel.sent.getLast()); + + Assertions.assertEquals(List.of("message", "disconnect"), delivered); + } + } + + @Test + void establishesTheNewGenerationBeforeMessagesWhenStateObserversAreDelayed() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + List delivered = new ArrayList<>(); + try (var subscription = adapter.subscribe(endpoint, new RequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull RequestReplyMessage message) { + delivered.add("message"); + } + + @Override + public void onDisconnect() { + delivered.add("disconnect"); + } + })) { + adapter.send(request(endpoint)).toCompletableFuture().join(); + DDMessage wire = channel.sent.getLast(); + DDConnectionInfo replacement = new DDConnectionInfo("desktop", "next-run", "next-socket"); + DDChannelStatus ready = new DDChannelStatus(DDChannelState.CONNECTED, replacement); + channel.status = ready; + + channel.deliver(replacement, wire); + + Assertions.assertEquals(List.of("disconnect", "message"), delivered); + for (Consumer observer : List.copyOf(channel.observers)) { + observer.accept(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); + observer.accept(ready); + } + Assertions.assertEquals(List.of("disconnect", "message"), delivered); + channel.deliver(CONNECTION, wire); + Assertions.assertEquals(List.of("disconnect", "message"), delivered); + channel.deliver(replacement, wire); + Assertions.assertEquals(List.of("disconnect", "message", "message"), delivered); + } + } + + @Test + void permitsConcurrentInlineRepliesWithoutHoldingCallbackLocks() throws Exception { + FakeChannel leftChannel = new FakeChannel(); + FakeChannel rightChannel = new FakeChannel(); + DDRequestReplyTransport left = DDRequestReplyTransport.forChannel(leftChannel, TOPIC); + DDRequestReplyTransport right = DDRequestReplyTransport.forChannel(rightChannel, TOPIC); + UUID leftId = UUID.randomUUID(); + UUID rightId = UUID.randomUUID(); + CountDownLatch requests = new CountDownLatch(2); + AtomicInteger replies = new AtomicInteger(); + try (var leftSubscription = left.subscribe(leftId, inlineReplies(left, requests, replies)); + var rightSubscription = right.subscribe(rightId, inlineReplies(right, requests, replies))) { + left.send(request(leftId)).toCompletableFuture().join(); + right.send(request(rightId)).toCompletableFuture().join(); + DDMessage toRight = leftChannel.sent.getLast(); + DDMessage toLeft = rightChannel.sent.getLast(); + leftChannel.forward = message -> rightChannel.deliver(CONNECTION, message); + rightChannel.forward = message -> leftChannel.deliver(CONNECTION, message); + + FutureTask leftDelivery = async(() -> leftChannel.deliver(CONNECTION, toLeft)); + FutureTask rightDelivery = async(() -> rightChannel.deliver(CONNECTION, toRight)); + + leftDelivery.get(2, TimeUnit.SECONDS); + rightDelivery.get(2, TimeUnit.SECONDS); + Assertions.assertEquals(2, replies.get()); + } + } + + @Test + void boundsQueuedCallbacksAndPreservesDisconnectWhenTheQueueIsFull() throws Exception { + for (boolean disconnect : List.of(false, true)) { + FakeChannel channel = new FakeChannel(); + DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger messages = new AtomicInteger(); + AtomicInteger disconnects = new AtomicInteger(); + try (var subscription = adapter.subscribe(endpoint, new RequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull RequestReplyMessage message) { + entered.countDown(); + await(release); + messages.incrementAndGet(); + } + + @Override + public void onDisconnect() { + disconnects.incrementAndGet(); + } + })) { + adapter.send(request(endpoint)).toCompletableFuture().join(); + DDMessage wire = channel.sent.getLast(); + FutureTask delivery = async(() -> channel.deliver(CONNECTION, wire)); + try { + await(entered); + for (int i = 0; i < 130; i++) { + channel.deliver(CONNECTION, wire); + } + if (disconnect) { + channel.setStatus(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); + } + } finally { + release.countDown(); + delivery.get(2, TimeUnit.SECONDS); + } + Assertions.assertEquals(disconnect ? 1 : 129, messages.get()); + Assertions.assertEquals(disconnect ? 1 : 0, disconnects.get()); + } + } + } + + @Test + void releasesReceivingResourcesWhenAListenerFails() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + UUID endpoint = UUID.randomUUID(); + IllegalStateException failure = new IllegalStateException("Listener failed"); + try (var subscription = adapter.subscribe(endpoint, new RequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull RequestReplyMessage message) { + throw failure; + } + + @Override + public void onDisconnect() { + } + })) { + adapter.send(request(endpoint)).toCompletableFuture().join(); + + Assertions.assertSame(failure, Assertions.assertThrows(IllegalStateException.class, + () -> channel.deliver(CONNECTION, channel.sent.getLast()))); + + assertFailure(RequestReplyException.Reason.CLOSED, adapter.send(request(endpoint))); + Assertions.assertTrue(channel.subscriptions.stream().noneMatch(value -> value.active)); + Assertions.assertTrue(channel.observers.isEmpty()); + Assertions.assertFalse(channel.closed); + } + } + @Test void rejectsMalformedRecognizedFramesAndWrongTypeTopicAndOrigin() throws Exception { FakeChannel channel = new FakeChannel(); @@ -403,6 +671,46 @@ private static void assertFailure(@NotNull RequestReplyException.Reason reason, Assertions.assertEquals(reason, Assertions.assertInstanceOf(RequestReplyException.class, failure.getCause()).reason()); } + @NotNull + private static FutureTask async(@NotNull Runnable action) { + FutureTask task = new FutureTask<>(action, null); + Thread.ofPlatform().daemon().start(task); + return task; + } + + @NotNull + private static RequestReplyTransport.Listener inlineReplies( + @NotNull DDRequestReplyTransport adapter, + @NotNull CountDownLatch requests, + @NotNull AtomicInteger replies + ) { + return new RequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull RequestReplyMessage message) { + if (message.type() == RequestReplyMessage.Type.REQUEST) { + requests.countDown(); + await(requests); + adapter.send(message.response(null)).toCompletableFuture().join(); + } else { + replies.incrementAndGet(); + } + } + + @Override + public void onDisconnect() { + } + }; + } + + private static void await(@NotNull CountDownLatch latch) { + try { + Assertions.assertTrue(latch.await(2, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + } + private static final class Received implements RequestReplyTransport.Listener { private final List> messages = new ArrayList<>(); private int disconnects; @@ -423,7 +731,7 @@ private static final class FakeChannel implements DDMessageChannel { private final List> observers = new ArrayList<>(); private final List sent = new ArrayList<>(); private final RuntimeException subscriptionFailure = new IllegalStateException("Subscription unavailable"); - private DDChannelStatus status = new DDChannelStatus(DDChannelState.CONNECTED, CONNECTION); + private volatile DDChannelStatus status = new DDChannelStatus(DDChannelState.CONNECTED, CONNECTION); @Nullable private String failTopic; @Nullable diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java new file mode 100644 index 0000000..cd459d2 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java @@ -0,0 +1,83 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.rpc; + +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonPrimitive; +import com.google.gson.reflect.TypeToken; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Type; +import java.util.List; + +class DDGsonRpcCodecTest { + private final DDGsonRpcCodec codec = new DDGsonRpcCodec(new Gson()); + + @Test + void encodesOrdinaryAndPrimitiveValues() { + Assertions.assertEquals(new JsonPrimitive("Hello"), codec.encode("Hello", String.class)); + Assertions.assertEquals("Hello", codec.decode(new JsonPrimitive("Hello"), String.class)); + Assertions.assertEquals(new JsonPrimitive(2), codec.encode(2, int.class)); + Assertions.assertEquals(2, codec.decode(new JsonPrimitive(2), int.class)); + } + + @Test + void normalizesJsonNullAndSupportsNullableValues() { + Assertions.assertNull(codec.encode(null, String.class)); + Assertions.assertNull(codec.encode(JsonNull.INSTANCE, JsonElement.class)); + Assertions.assertNull(codec.decode(null, String.class)); + Assertions.assertNull(codec.decode(JsonNull.INSTANCE, String.class)); + } + + @Test + void usesDeclaredGenericTypesForApplicationRecords() { + Type type = new TypeToken>() { }.getType(); + List values = List.of(new Item("Hello", List.of(1, 2))); + + Object decoded = codec.decode(codec.encode(values, type), type); + + Assertions.assertEquals(values, decoded); + Assertions.assertInstanceOf(Item.class, ((List) decoded).getFirst()); + } + + @Test + void preservesApplicationGsonConfiguration() { + var configured = new DDGsonRpcCodec(new GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create()); + Item value = new Item("configured", List.of(3)); + + JsonElement encoded = configured.encode(value, Item.class); + + Assertions.assertEquals("configured", encoded.getAsJsonObject().get("message_text").getAsString()); + Assertions.assertEquals(value, configured.decode(encoded, Item.class)); + } + + @Test + void requiresAnExplicitGsonAndDeclaredTypes() { + Assertions.assertThrows(NullPointerException.class, () -> new DDGsonRpcCodec(null)); + Assertions.assertThrows(NullPointerException.class, () -> codec.encode("Hello", null)); + Assertions.assertThrows(NullPointerException.class, () -> codec.decode(null, null)); + } + + private record Item(String messageText, List numbers) { + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesIntegrationTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesIntegrationTest.java new file mode 100644 index 0000000..d0074fa --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesIntegrationTest.java @@ -0,0 +1,164 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.rpc; + +import com.dbeaver.datadam.external.api.model.DDConnectionInfo; +import com.dbeaver.datadam.external.api.model.DDMessage; +import com.dbeaver.datadam.external.api.transport.DDChannelState; +import com.dbeaver.datadam.external.api.transport.DDChannelStatus; +import com.dbeaver.datadam.external.api.transport.DDConnectionListener; +import com.dbeaver.datadam.external.api.transport.DDMessageChannel; +import com.dbeaver.datadam.external.api.transport.DDMessageListener; +import com.dbeaver.datadam.external.api.transport.DDSubscription; +import com.dbeaver.messaging.requestreply.RequestReplyOptions; +import com.dbeaver.messaging.rpc.RpcEndpoint; +import com.dbeaver.messaging.rpc.RpcException; +import com.dbeaver.messaging.rpc.RpcMessage; +import com.google.gson.Gson; +import org.jkiss.code.NotNull; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +@Timeout(10) +class DDRpcServicesIntegrationTest { + private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("desktop", "run", "physical-connection"); + private static final RequestReplyOptions OPTIONS = new RequestReplyOptions(Duration.ofSeconds(2), 16, 8); + + @Test + void servesAccountAndStorageBidirectionallyOverOneDesktopChannel() throws Exception { + Channel desktop = new Channel(); + Channel account = new Channel(); + Channel storage = new Channel(); + desktop.forward = message -> { + account.receive(message); + storage.receive(message); + }; + account.forward = desktop::receive; + storage.forward = desktop::receive; + var codec = new DDGsonRpcCodec(new Gson()); + try (var desktopAccount = RpcEndpoint.open( + DDRpcTransport.forChannel(desktop, DDRpcServices.topic(DDRpcServices.ACCOUNT)), codec, OPTIONS); + var desktopStorage = RpcEndpoint.open( + DDRpcTransport.forChannel(desktop, DDRpcServices.topic(DDRpcServices.STORAGE)), codec, OPTIONS); + var accountRpc = RpcEndpoint.open( + DDRpcTransport.forChannel(account, DDRpcServices.topic(DDRpcServices.ACCOUNT)), codec, OPTIONS); + var storageRpc = RpcEndpoint.open( + DDRpcTransport.forChannel(storage, DDRpcServices.topic(DDRpcServices.STORAGE)), codec, OPTIONS); + var accountGreeting = accountRpc.register("greeting", Greeting.class, value -> value + " World from Account"); + var storageGreeting = storageRpc.register("greeting", Greeting.class, value -> value + " World from Storage"); + var accountCounter = desktopAccount.register("counter", Counter.class, value -> value + 1); + var storageCounter = desktopStorage.register("counter", Counter.class, value -> value + 10)) { + Assertions.assertEquals("Hello World from Account", desktopAccount.proxy("greeting", Greeting.class).hello("Hello")); + Assertions.assertEquals("Hello World from Storage", desktopStorage.proxy("greeting", Greeting.class).hello("Hello")); + Assertions.assertEquals(2, accountRpc.proxy("counter", Counter.class).increment(1)); + Assertions.assertEquals(11, storageRpc.proxy("counter", Counter.class).increment(1)); + + accountCounter.close(); + + RpcException missing = Assertions.assertThrows(RpcException.class, + () -> accountRpc.proxy("counter", Counter.class).increment(1)); + Assertions.assertEquals(RpcMessage.Error.UNKNOWN_SERVICE, missing.remoteError()); + Assertions.assertEquals(11, storageRpc.proxy("counter", Counter.class).increment(1)); + Assertions.assertEquals("Still World from Account", desktopAccount.proxy("greeting", Greeting.class).hello("Still")); + } + for (Channel channel : List.of(desktop, account, storage)) { + Assertions.assertFalse(channel.closed); + Assertions.assertTrue(channel.subscriptions.isEmpty()); + Assertions.assertTrue(channel.observers.isEmpty()); + } + } + + /** Answers a greeting on the selected backend. */ + public interface Greeting { + @NotNull + String hello(@NotNull String value); + } + + /** Increments a value on the selected Desktop lane. */ + public interface Counter { + int increment(int value); + } + + private static final class Channel implements DDMessageChannel { + private final List subscriptions = new CopyOnWriteArrayList<>(); + private final List> observers = new CopyOnWriteArrayList<>(); + private Consumer forward = message -> { }; + private boolean closed; + + @Override + @NotNull + public DDChannelStatus getStatus() { + return new DDChannelStatus(closed ? DDChannelState.CLOSED : DDChannelState.CONNECTED, closed ? null : CONNECTION); + } + + @Override + @NotNull + public DDSubscription observeState(@NotNull Consumer observer) { + observers.add(observer); + observer.accept(getStatus()); + return () -> observers.remove(observer); + } + + @Override + @NotNull + public CompletionStage send(@NotNull DDMessage message) { + forward.accept(message); + return CompletableFuture.completedFuture(null); + } + + private void receive(@NotNull DDMessage message) { + for (Subscription subscription : subscriptions) { + if (subscription.topic.equals(message.topic())) { + subscription.listener.onMessage(CONNECTION, message); + } + } + } + + @Override + @NotNull + public DDSubscription subscribe(@NotNull String topic, @NotNull DDMessageListener listener) { + var subscription = new Subscription(topic, listener); + subscriptions.add(subscription); + return () -> subscriptions.remove(subscription); + } + + @Override + @NotNull + public DDSubscription addConnectionListener(@NotNull DDConnectionListener listener) { + return () -> { }; + } + + @Override + public void close() { + closed = true; + observers.forEach(observer -> observer.accept(getStatus())); + subscriptions.clear(); + observers.clear(); + } + } + + private record Subscription(@NotNull String topic, @NotNull DDMessageListener listener) { + } +} diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesTest.java new file mode 100644 index 0000000..f1272e0 --- /dev/null +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesTest.java @@ -0,0 +1,41 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.external.api.rpc; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; + +class DDRpcServicesTest { + @Test + void separatesBackendServicesAndSupportsConfiguredExtensions() { + Assertions.assertEquals("rpc.account", DDRpcServices.topic(DDRpcServices.ACCOUNT)); + Assertions.assertEquals("rpc.storage", DDRpcServices.topic(DDRpcServices.STORAGE)); + Assertions.assertEquals("rpc.other-service.v2_1", DDRpcServices.topic("other-service.v2_1")); + Assertions.assertNotEquals(DDRpcServices.topic(DDRpcServices.ACCOUNT), DDRpcServices.topic(DDRpcServices.STORAGE)); + Assertions.assertEquals(132, DDRpcServices.topic("a".repeat(128)).length()); + } + + @Test + void rejectsInvalidServiceIdsBeforeChoosingALane() { + for (String value : List.of("", " ", "a b", "a/b", "a:b", "a\nb", "a\u0000b", "сервис", "a".repeat(129))) { + Assertions.assertThrows(IllegalArgumentException.class, () -> DDRpcServices.topic(value), value); + } + Assertions.assertThrows(NullPointerException.class, () -> DDRpcServices.topic(null)); + } +} diff --git a/apis/com.dbeaver.messaging/README.md b/apis/com.dbeaver.messaging/README.md index 6d96a85..94b30a2 100644 --- a/apis/com.dbeaver.messaging/README.md +++ b/apis/com.dbeaver.messaging/README.md @@ -128,9 +128,55 @@ remote peer or prove service presence. Proxy methods block the calling thread. Call them from worker or virtual threads, not a transport listener or event loop. -`RpcEndpoint

` consumes `RequestReplyTransport>`. The transport adapter encodes the neutral protocol -envelope; `RpcCodec

` encodes only application arguments and results. A TCP, WebSocket, long-polling, or in-memory -transport can implement this interface without changing RPC code. No WebSocket types or JSON library are used here. +`RpcEndpoint` has no payload type parameter. Its generic `open` factories check that +`RequestReplyTransport>` and `RpcCodec

` use the same payload representation, then retain that choice +internally. Business code receives an ordinary `RpcEndpoint` and only uses its typed interfaces. The transport adapter +encodes the neutral protocol envelope; the codec encodes application arguments and results. A TCP, WebSocket, +long-polling, or in-memory transport can implement this interface without changing RPC code. No WebSocket types or +JSON library are used here. + +### Application-owned registrations and callers + +Obtain an endpoint once from the application's connection owner, then pass it to ordinary Java objects. Registration +and proxy creation are runtime operations and do not require Spring or a separate bean for each business interface. + +```java +public final class DocumentReceiver { + private final RpcEndpoint rpc; + + public DocumentReceiver(RpcEndpoint rpc) { + this.rpc = rpc; + } + + public MessageSubscription enable(Documents implementation) { + return rpc.register("documents", Documents.class, implementation); + } +} +``` + +The opposite peer uses its own endpoint in a separate caller: + +```java +public final class DocumentCaller { + private final RpcEndpoint rpc; + + public DocumentCaller(RpcEndpoint rpc) { + this.rpc = rpc; + } + + public Document load(DocumentRequest request) { + Documents documents = rpc.proxy("documents", Documents.class); + return documents.load(request); + } +} +``` + +Keep the receiving `MessageSubscription` while the implementation is available, then close it to unregister. A proxy +is an ordinary business interface, not a registration handle, and requires no separate close. Application code sharing +an endpoint closes its own registrations; the connection owner closes the endpoint when its scope ends. Owners can +use `isClosed()` to reject new use once closure starts, and `isCloseComplete()` before releasing a cached endpoint's +scope reservation. The latter becomes true after handler and receiving-subscription cleanup finishes, including when +cleanup throws. A repeated `close()` does not wait for another thread's ongoing cleanup. ### Contracts and codecs diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java index 1f1f293..ddc11e4 100644 --- a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java @@ -31,33 +31,26 @@ import java.util.Map; import java.util.Objects; -public final class RpcEndpoint

implements AutoCloseable { - private final Object lock = new Object(); - private final RequestReplyEndpoint> requests; - private final RpcCodec

codec; - private final MessageSubscription handler; - private final Map services = new HashMap<>(); - private boolean closed; +public final class RpcEndpoint implements AutoCloseable { + private final State state; - private RpcEndpoint(@NotNull RequestReplyEndpoint> requests, @NotNull RpcCodec

codec) { - this.requests = Objects.requireNonNull(requests); - this.codec = Objects.requireNonNull(codec); - this.handler = requests.handle(this::dispatch); + private RpcEndpoint(@NotNull State state) { + this.state = state; } @NotNull - public static

RpcEndpoint

open(@NotNull RequestReplyEndpoint> requests, @NotNull RpcCodec

codec) { - return new RpcEndpoint<>(requests, codec); + public static

RpcEndpoint open(@NotNull RequestReplyEndpoint> requests, @NotNull RpcCodec

codec) { + return new RpcEndpoint(new State<>(requests, codec)); } @NotNull - public static

RpcEndpoint

open(@NotNull RequestReplyTransport> transport, @NotNull RpcCodec

codec) + public static

RpcEndpoint open(@NotNull RequestReplyTransport> transport, @NotNull RpcCodec

codec) throws RequestReplyException { return open(transport, codec, RequestReplyOptions.DEFAULT); } @NotNull - public static

RpcEndpoint

open( + public static

RpcEndpoint open( @NotNull RequestReplyTransport> transport, @NotNull RpcCodec

codec, @NotNull RequestReplyOptions options @@ -78,193 +71,249 @@ public static

RpcEndpoint

open( @NotNull public MessageSubscription register(@NotNull String topic, @NotNull Class type, @NotNull T implementation) { - RpcMessage.requireTopic(topic); - RpcContract contract = RpcContract.create(type); - if (!type.isInstance(Objects.requireNonNull(implementation))) { - throw new IllegalArgumentException("RPC implementation does not implement its contract"); - } - contract.requireAccessible(implementation); - synchronized (lock) { - requireOpen(); - if (services.containsKey(topic)) { - throw new IllegalStateException("RPC topic already has a registered service"); - } - ServiceRegistration registration = new ServiceRegistration(topic, contract, implementation); - services.put(topic, registration); - return registration; - } + return state.register(topic, type, implementation); } @NotNull public T proxy(@NotNull String topic, @NotNull Class type) { - RpcMessage.requireTopic(topic); - RpcContract contract = RpcContract.create(type); - synchronized (lock) { - requireOpen(); - } - Object proxy = Proxy.newProxyInstance(type.getClassLoader(), new Class[] {type}, (instance, method, arguments) -> { - if (RpcContract.isObjectMethod(method)) { - return switch (method.getName()) { - case "equals" -> instance == Objects.requireNonNull(arguments)[0]; - case "hashCode" -> System.identityHashCode(instance); - case "toString" -> "RPC proxy[" + topic + ", " + type.getName() + "]"; - default -> throw new IllegalStateException("Unsupported proxy object method"); - }; - } - Method operation = Objects.requireNonNull(contract.operation(RpcContract.operationId(method))); - return invoke(topic, operation, Objects.requireNonNull(arguments)[0]); - }); - return type.cast(proxy); + return state.proxy(topic, type); } - @Nullable - private Object invoke(@NotNull String topic, @NotNull Method operation, @Nullable Object argument) { - synchronized (lock) { - requireOpen(); - } - if (Thread.currentThread().isInterrupted()) { - throw new RpcException(RpcException.Reason.INTERRUPTED); - } - RpcMessage

request; - try { - P payload = codec.encode(argument, operation.getGenericParameterTypes()[0]); - request = RpcMessage.request(topic, RpcContract.operationId(operation), payload); - } catch (Exception e) { - throw codecFailure(e); - } - RpcMessage

response; - try { - response = requests.request(request); - } catch (RequestReplyException e) { - throw new RpcException(e); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RpcException(RpcException.Reason.INTERRUPTED, e); + public boolean isClosed() { + return state.isClosed(); + } + + public boolean isCloseComplete() { + return state.isCloseComplete(); + } + + @Override + public void close() { + state.close(); + } + + private static final class State

{ + private final Object lock = new Object(); + private final RequestReplyEndpoint> requests; + private final RpcCodec

codec; + private final MessageSubscription handler; + private final Map services = new HashMap<>(); + private boolean closed; + private boolean closeComplete; + + private State(@NotNull RequestReplyEndpoint> requests, @NotNull RpcCodec

codec) { + this.requests = Objects.requireNonNull(requests); + this.codec = Objects.requireNonNull(codec); + this.handler = requests.handle(this::dispatch); } - if (response == null || response.type() == RpcMessage.Type.REQUEST) { - throw new RpcException(RpcException.Reason.INVALID_RESPONSE); + + @NotNull + private MessageSubscription register(@NotNull String topic, @NotNull Class type, @NotNull T implementation) { + RpcMessage.requireTopic(topic); + RpcContract contract = RpcContract.create(type); + if (!type.isInstance(Objects.requireNonNull(implementation))) { + throw new IllegalArgumentException("RPC implementation does not implement its contract"); + } + contract.requireAccessible(implementation); + synchronized (lock) { + requireOpen(); + if (services.containsKey(topic)) { + throw new IllegalStateException("RPC topic already has a registered service"); + } + ServiceRegistration registration = new ServiceRegistration(topic, contract, implementation); + services.put(topic, registration); + return registration; + } } - if (response.type() == RpcMessage.Type.ERROR) { - throw new RpcException(Objects.requireNonNull(response.error())); + + @NotNull + private T proxy(@NotNull String topic, @NotNull Class type) { + RpcMessage.requireTopic(topic); + RpcContract contract = RpcContract.create(type); + synchronized (lock) { + requireOpen(); + } + Object proxy = Proxy.newProxyInstance(type.getClassLoader(), new Class[] {type}, (instance, method, arguments) -> { + if (RpcContract.isObjectMethod(method)) { + return switch (method.getName()) { + case "equals" -> instance == Objects.requireNonNull(arguments)[0]; + case "hashCode" -> System.identityHashCode(instance); + case "toString" -> "RPC proxy[" + topic + ", " + type.getName() + "]"; + default -> throw new IllegalStateException("Unsupported proxy object method"); + }; + } + Method operation = Objects.requireNonNull(contract.operation(RpcContract.operationId(method))); + return invoke(topic, operation, Objects.requireNonNull(arguments)[0]); + }); + return type.cast(proxy); } - if (operation.getReturnType() == void.class) { - if (response.payload() != null) { + + @Nullable + private Object invoke(@NotNull String topic, @NotNull Method operation, @Nullable Object argument) { + synchronized (lock) { + requireOpen(); + } + if (Thread.currentThread().isInterrupted()) { + throw new RpcException(RpcException.Reason.INTERRUPTED); + } + RpcMessage

request; + try { + P payload = codec.encode(argument, operation.getGenericParameterTypes()[0]); + request = RpcMessage.request(topic, RpcContract.operationId(operation), payload); + } catch (Exception e) { + throw codecFailure(e); + } + RpcMessage

response; + try { + response = requests.request(request); + } catch (RequestReplyException e) { + throw new RpcException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RpcException(RpcException.Reason.INTERRUPTED, e); + } + if (response == null || response.type() == RpcMessage.Type.REQUEST) { throw new RpcException(RpcException.Reason.INVALID_RESPONSE); } - return null; - } - Object result; - try { - result = codec.decode(response.payload(), operation.getGenericReturnType()); - } catch (Exception e) { - throw codecFailure(e); - } - if (!RpcContract.accepts(operation.getReturnType(), result)) { - throw new RpcException(RpcException.Reason.INVALID_RESPONSE); + if (response.type() == RpcMessage.Type.ERROR) { + throw new RpcException(Objects.requireNonNull(response.error())); + } + if (operation.getReturnType() == void.class) { + if (response.payload() != null) { + throw new RpcException(RpcException.Reason.INVALID_RESPONSE); + } + return null; + } + Object result; + try { + result = codec.decode(response.payload(), operation.getGenericReturnType()); + } catch (Exception e) { + throw codecFailure(e); + } + if (!RpcContract.accepts(operation.getReturnType(), result)) { + throw new RpcException(RpcException.Reason.INVALID_RESPONSE); + } + return result; } - return result; - } - @NotNull - private RpcMessage

dispatch(@NotNull RpcMessage

request) { - if (request.type() != RpcMessage.Type.REQUEST) { - return RpcMessage.failure(RpcMessage.Error.INVALID_REQUEST); - } - ServiceRegistration registration; - synchronized (lock) { - if (closed) { + @NotNull + private RpcMessage

dispatch(@NotNull RpcMessage

request) { + if (request.type() != RpcMessage.Type.REQUEST) { + return RpcMessage.failure(RpcMessage.Error.INVALID_REQUEST); + } + ServiceRegistration registration; + synchronized (lock) { + if (closed) { + return RpcMessage.failure(RpcMessage.Error.UNKNOWN_SERVICE); + } + registration = services.get(request.topic()); + } + if (registration == null) { return RpcMessage.failure(RpcMessage.Error.UNKNOWN_SERVICE); } - registration = services.get(request.topic()); - } - if (registration == null) { - return RpcMessage.failure(RpcMessage.Error.UNKNOWN_SERVICE); - } - Method operation = registration.contract.operation(Objects.requireNonNull(request.operation())); - if (operation == null) { - return RpcMessage.failure(RpcMessage.Error.UNKNOWN_OPERATION); - } - Object argument; - try { - argument = codec.decode(request.payload(), operation.getGenericParameterTypes()[0]); - } catch (Exception e) { - restoreInterruption(e); - return RpcMessage.failure(RpcMessage.Error.CODEC_FAILED); - } - if (!RpcContract.accepts(operation.getParameterTypes()[0], argument)) { - return RpcMessage.failure(RpcMessage.Error.INVALID_REQUEST); - } - Object result; - try { - result = operation.invoke(registration.implementation, argument); - } catch (InvocationTargetException e) { - restoreInterruption(e.getCause()); - return RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED); - } catch (ReflectiveOperationException | RuntimeException e) { - return RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED); - } - if (operation.getReturnType() == void.class) { - return RpcMessage.response(null); - } - try { - return RpcMessage.response(codec.encode(result, operation.getGenericReturnType())); - } catch (Exception e) { - restoreInterruption(e); - return RpcMessage.failure(RpcMessage.Error.CODEC_FAILED); + Method operation = registration.contract.operation(Objects.requireNonNull(request.operation())); + if (operation == null) { + return RpcMessage.failure(RpcMessage.Error.UNKNOWN_OPERATION); + } + Object argument; + try { + argument = codec.decode(request.payload(), operation.getGenericParameterTypes()[0]); + } catch (Exception e) { + restoreInterruption(e); + return RpcMessage.failure(RpcMessage.Error.CODEC_FAILED); + } + if (!RpcContract.accepts(operation.getParameterTypes()[0], argument)) { + return RpcMessage.failure(RpcMessage.Error.INVALID_REQUEST); + } + Object result; + try { + result = operation.invoke(registration.implementation, argument); + } catch (InvocationTargetException e) { + restoreInterruption(e.getCause()); + return RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED); + } catch (ReflectiveOperationException | RuntimeException e) { + return RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED); + } + if (operation.getReturnType() == void.class) { + return RpcMessage.response(null); + } + try { + return RpcMessage.response(codec.encode(result, operation.getGenericReturnType())); + } catch (Exception e) { + restoreInterruption(e); + return RpcMessage.failure(RpcMessage.Error.CODEC_FAILED); + } } - } - @NotNull - private static RpcException codecFailure(@NotNull Exception cause) { - restoreInterruption(cause); - RpcException.Reason reason = cause instanceof InterruptedException - ? RpcException.Reason.INTERRUPTED : RpcException.Reason.CODEC_FAILED; - return new RpcException(reason, cause); - } - - private static void restoreInterruption(@Nullable Throwable failure) { - if (failure instanceof InterruptedException) { - Thread.currentThread().interrupt(); + @NotNull + private static RpcException codecFailure(@NotNull Exception cause) { + restoreInterruption(cause); + RpcException.Reason reason = cause instanceof InterruptedException + ? RpcException.Reason.INTERRUPTED : RpcException.Reason.CODEC_FAILED; + return new RpcException(reason, cause); } - } - private void requireOpen() { - if (closed) { - throw new RpcException(RpcException.Reason.CLOSED); + private static void restoreInterruption(@Nullable Throwable failure) { + if (failure instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } } - } - @Override - public void close() { - synchronized (lock) { + private void requireOpen() { if (closed) { - return; + throw new RpcException(RpcException.Reason.CLOSED); } - closed = true; - services.clear(); - } - try { - handler.close(); - } finally { - requests.close(); } - } - private final class ServiceRegistration implements MessageSubscription { - private final String topic; - private final RpcContract contract; - private final Object implementation; + private boolean isClosed() { + synchronized (lock) { + return closed; + } + } - private ServiceRegistration(@NotNull String topic, @NotNull RpcContract contract, @NotNull Object implementation) { - this.topic = topic; - this.contract = contract; - this.implementation = implementation; + private boolean isCloseComplete() { + synchronized (lock) { + return closeComplete; + } } - @Override - public void close() { + private void close() { synchronized (lock) { - services.remove(topic, this); + if (closed) { + return; + } + closed = true; + services.clear(); + } + try { + try { + handler.close(); + } finally { + requests.close(); + } + } finally { + synchronized (lock) { + closeComplete = true; + } + } + } + + private final class ServiceRegistration implements MessageSubscription { + private final String topic; + private final RpcContract contract; + private final Object implementation; + + private ServiceRegistration(@NotNull String topic, @NotNull RpcContract contract, @NotNull Object implementation) { + this.topic = topic; + this.contract = contract; + this.implementation = implementation; + } + + @Override + public void close() { + synchronized (lock) { + services.remove(topic, this); + } } } } diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java new file mode 100644 index 0000000..936ee01 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java @@ -0,0 +1,185 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import com.dbeaver.messaging.requestreply.MessageSubscription; +import com.dbeaver.messaging.requestreply.RequestReplyEndpoint; +import com.dbeaver.messaging.requestreply.RequestReplyMessage; +import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(30) +public class RpcEndpointFacadeTest extends RpcTestSupport { + @Test + void usesTheSameFacadeForTextAndBinaryPayloadRepresentations() throws Exception { + var textTransport = new LoopbackTransport(() -> { }); + var binaryTransport = new LoopbackTransport(() -> { }); + try (var requests = RequestReplyEndpoint.open(textTransport, OPTIONS); + RpcEndpoint text = RpcEndpoint.open(requests, new TextCodec()); + RpcEndpoint binary = RpcEndpoint.open(binaryTransport, new BinaryCodec(), OPTIONS)) { + for (RpcEndpoint endpoint : List.of(text, binary)) { + assertFalse(endpoint.isClosed()); + assertFalse(endpoint.isCloseComplete()); + try (var registration = endpoint.register("text", TextService.class, value -> value)) { + TextService proxy = endpoint.proxy("text", TextService.class); + assertEquals("value:é", proxy.apply("value:é")); + assertNull(proxy.apply(null)); + endpoint.close(); + assertTrue(endpoint.isClosed()); + assertTrue(endpoint.isCloseComplete()); + failure(RpcException.Reason.CLOSED, () -> proxy.apply("closed")); + } + } + } + } + + @Test + void publishesClosedStateBeforeCleanupCompletesAndAllowsAFreshEndpoint() throws Exception { + var cleanupStarted = new CountDownLatch(1); + var releaseCleanup = new CountDownLatch(1); + var transport = new LoopbackTransport(() -> { + cleanupStarted.countDown(); + try { + await(releaseCleanup); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + }); + try (RpcEndpoint endpoint = RpcEndpoint.open(transport, new TextCodec(), OPTIONS)) { + assertFalse(endpoint.isClosed()); + Future closing = async(() -> { + endpoint.close(); + return null; + }); + try { + await(cleanupStarted); + assertTrue(endpoint.isClosed()); + assertFalse(endpoint.isCloseComplete()); + assertFalse(closing.isDone()); + failure(RpcException.Reason.CLOSED, () -> endpoint.proxy("text", TextService.class)); + endpoint.close(); + } finally { + releaseCleanup.countDown(); + } + assertNull(result(closing)); + assertTrue(endpoint.isClosed()); + assertTrue(endpoint.isCloseComplete()); + } + try (RpcEndpoint reopened = RpcEndpoint.open(transport, new TextCodec(), OPTIONS); + var registration = reopened.register("text", TextService.class, value -> "fresh:" + value)) { + assertFalse(reopened.isClosed()); + assertEquals("fresh:value", reopened.proxy("text", TextService.class).apply("value")); + } + } + + @Test + void publishesCleanupCompletionEvenWhenTheTransportCleanupFails() throws Exception { + IllegalStateException failure = new IllegalStateException("Cleanup failed"); + var transport = new LoopbackTransport(() -> { + throw failure; + }); + RpcEndpoint endpoint = RpcEndpoint.open(transport, new TextCodec(), OPTIONS); + + assertSame(failure, assertThrows(IllegalStateException.class, endpoint::close)); + + assertTrue(endpoint.isClosed()); + assertTrue(endpoint.isCloseComplete()); + endpoint.close(); + } + + /** Exchanges nullable text values. */ + public interface TextService { + @Nullable + String apply(@Nullable String value); + } + + private static final class TextCodec implements RpcCodec { + @Nullable + @Override + public String encode(@Nullable Object value, @NotNull Type type) { + return String.class.cast(value); + } + + @Nullable + @Override + public Object decode(@Nullable String payload, @NotNull Type type) { + return payload; + } + } + + private static final class BinaryCodec implements RpcCodec { + @Nullable + @Override + public byte[] encode(@Nullable Object value, @NotNull Type type) { + return value == null ? null : String.class.cast(value).getBytes(StandardCharsets.UTF_8); + } + + @Nullable + @Override + public Object decode(@Nullable byte[] payload, @NotNull Type type) { + return payload == null ? null : new String(payload, StandardCharsets.UTF_8); + } + } + + private static final class LoopbackTransport

implements RequestReplyTransport> { + private final Map>> listeners = new ConcurrentHashMap<>(); + private final Runnable onClose; + + private LoopbackTransport(@NotNull Runnable onClose) { + this.onClose = onClose; + } + + @NotNull + @Override + public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) { + listeners.put(endpointId, listener); + return () -> { + listeners.remove(endpointId, listener); + onClose.run(); + }; + } + + @NotNull + @Override + public CompletionStage send(@NotNull RequestReplyMessage> message) { + List.copyOf(listeners.values()).forEach(listener -> listener.onMessage(message)); + return CompletableFuture.completedFuture(null); + } + } +} From 3b8802b829ef2bc4f944fe56ec700c2993ac9852 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Thu, 22 Oct 2026 11:43:38 +0400 Subject: [PATCH 10/30] dbeaver/pro#11108 Add per-binding codecs and direct RPC topics --- .../README.md | 73 +++-- .../external/api/rpc/DDRpcServices.java | 40 --- .../external/api/rpc/DDRpcTransport.java | 14 +- .../api/transport/DDMessageChannel.java | 1 + .../external/api/rpc/DDRpcServicesTest.java | 41 --- ...t.java => DDRpcTopicsIntegrationTest.java} | 96 ++++-- .../external/api/rpc/DDRpcTransportTest.java | 128 +++++--- apis/com.dbeaver.messaging/README.md | 58 ++-- .../com/dbeaver/messaging/rpc/RpcCodec.java | 2 +- .../dbeaver/messaging/rpc/RpcEndpoint.java | 303 ++++++++++++++---- .../com/dbeaver/messaging/rpc/RpcMessage.java | 19 +- .../dbeaver/messaging/rpc/RpcTransport.java | 28 ++ .../messaging/rpc/RpcContractTest.java | 34 +- .../messaging/rpc/RpcEndpointFacadeTest.java | 273 +++++++++++++++- .../messaging/rpc/RpcEndpointTest.java | 269 ++++++++-------- .../dbeaver/messaging/rpc/RpcMessageTest.java | 36 +-- .../dbeaver/messaging/rpc/RpcTestSupport.java | 51 ++- 17 files changed, 976 insertions(+), 490 deletions(-) delete mode 100644 apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcServices.java delete mode 100644 apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesTest.java rename apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/{DDRpcServicesIntegrationTest.java => DDRpcTopicsIntegrationTest.java} (54%) create mode 100644 apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java diff --git a/apis/com.dbeaver.datadam.external.api/README.md b/apis/com.dbeaver.datadam.external.api/README.md index d64d396..94971c6 100644 --- a/apis/com.dbeaver.datadam.external.api/README.md +++ b/apis/com.dbeaver.datadam.external.api/README.md @@ -183,7 +183,7 @@ priority over a full queued batch so pending calls are still invalidated. Listen WebSocket. `DDRpcTransport` adapts the existing request/reply transport to the neutral `RpcMessage` envelope. It contains only protocol framing; application serialization is selected by the developer through `RpcCodec`. -For example, an application can choose Gson once for an endpoint using the supplied adapter: +For example, a plugin can supply its own Gson instance and adapters for its proxies and implementations: ```java Gson gson = new Gson(); @@ -193,10 +193,9 @@ RpcCodec codec = new DDGsonRpcCodec(gson); The Desktop side installs an implementation and can independently obtain proxies for backend services: ```java -var transport = DDRpcTransport.forChannel(channel, DDRpcServices.topic(DDRpcServices.ACCOUNT)); -try (var rpc = RpcEndpoint.open(transport, codec); - var registration = rpc.register("desktop.projects", DesktopProjects.class, implementation)) { - ServerDocuments documents = rpc.proxy("server.documents", ServerDocuments.class); +try (var rpc = RpcEndpoint.open(topic -> DDRpcTransport.forChannel(channel, topic)); + var registration = rpc.register("desktop.projects", DesktopProjects.class, implementation, codec)) { + ServerDocuments documents = rpc.proxy("server.documents", ServerDocuments.class, codec); serveApplication(documents); } ``` @@ -205,38 +204,35 @@ The backend selects its physical account/connection scope once and uses the same ```java var scope = subscriptions.forConnection(accountId, connectionId); -var transport = new DDRpcTransport(scope.requestReply(DDRpcServices.topic(DDRpcServices.ACCOUNT))); -try (var rpc = RpcEndpoint.open(transport, codec); - var registration = rpc.register("server.documents", ServerDocuments.class, implementation)) { - DesktopProjects projects = rpc.proxy("desktop.projects", DesktopProjects.class); +try (var rpc = RpcEndpoint.open(topic -> { + try { + return new DDRpcTransport(scope.requestReply(topic)); + } catch (DDExternalDamException e) { + throw new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, e); + } +}); + var registration = rpc.register("server.documents", ServerDocuments.class, implementation, codec)) { + DesktopProjects projects = rpc.proxy("desktop.projects", DesktopProjects.class, codec); serveApplication(projects); } ``` -Use the same underlying request/reply topic (`"rpc.account"` here) at both ends of the selected channel. Logical service topics -such as `desktop.projects` and `server.documents` are multiplexed inside that lane; they do not open connections or add -per-service transport subscriptions. Registering a local service never implicitly registers a service on the other peer. -The existing private reply topic continues to route each answer back to the exact initiating process. - -Each lane has one receiving RPC endpoint at each peer. If Account, Storage, or other backend processes share one Desktop -connection, use separate lanes such as `rpc.account` and `rpc.storage`, with a corresponding Desktop endpoint for each. -Sharing a lane between multiple backend receivers is not remote service discovery: unrelated receivers could answer -`UNKNOWN_SERVICE` before the intended service. Replica routing and callback ownership retain the existing process-local -subscription constraints. - -`DDRpcServices` supplies the `account` and `storage` service IDs and maps configured service IDs to `rpc.` -lanes. A service ID contains 1 to 128 ASCII letters, digits, dots, underscores, or hyphens. Other backend services use -the same mapping without extending an enumeration. IDs and lane names are case-sensitive. +The topic supplied to `register` and `proxy` is the actual DataDam request topic. Topic-scoped request/reply routes +open lazily and are reused locally. The mechanism adds no Account/Storage service routing or automatic prefix; naming +conventions such as `storage.files` are entirely application-owned. Registering a local service never registers one +on the other peer. The existing private reply topic routes each answer back to the exact initiating process. +The application must arrange the intended receiver's subscription before calling it; duplicate backend receivers on +the same topic are not service discovery. Replica routing and exact callback-process ownership constraints still apply. `DDGsonRpcCodec` retains the supplied `Gson` instance, including application adapters and naming policies, and preserves the declared generic `Type` passed by RPC. Encoded JSON null is represented as a nullable application payload. The -neutral messaging module remains independent of Gson, and applications can supply another compatible codec at setup. +neutral messaging module remains independent of Gson, and each proxy or registration can supply another compatible codec. The resulting `RpcEndpoint` is not parameterized by JSON or by any other payload representation. ### Ready endpoints in Account, Storage, and Desktop Account and Storage already import `DDExternalDamClientConfiguration` from `com.dbeaver.datadam.external.base`. -That configuration supplies the callback receiver, subscriptions, the default RPC codec, and a shared +That configuration supplies the callback receiver, subscriptions, and a shared `DDExternalDamRpc` connection owner. Inject that owner once, select a Desktop connection, and obtain its endpoint: ```java @@ -251,34 +247,39 @@ those descriptions. Selection and release require only the unique physical conne The owner resolves the account through the authenticated private relay API when opening an endpoint, so a known connection ID also works without listing connections first. No local discovery cache is required. The transport retains the resolved account internally and preserves account-ownership checks for subscriptions and sends. -The application's configured subscriber ID selects its lane: Account uses `rpc.account`, Storage uses `rpc.storage`. +The configured subscriber ID identifies the callback destination and does not select RPC topic names. Repeated lookups return the same open endpoint; callers do not construct adapters or pass account/connection IDs to each registration or remote call. +The Desktop and backend connection owners provide an uncustomized `DDGsonRpcCodec` as a default, so ordinary +contracts can use `rpc.proxy(topic, Contract.class)` or `rpc.register(topic, Contract.class, implementation)`. +The overloads accepting a codec override it only for that proxy or implementation registration, without modifying +another caller's serialization or requiring a shared application codec bean. + Desktop obtains the corresponding endpoint from its existing connection service: ```java var connections = DDDesktopConnectionService.getInstance(); -RpcEndpoint accountRpc = connections.getRpc(DDRpcServices.ACCOUNT); -RpcEndpoint storageRpc = connections.getRpc(DDRpcServices.STORAGE); +RpcEndpoint rpc = connections.getRpc(); ``` The Desktop connection must first become ready. Endpoint access never starts login or waits for a new connection. -Account and Storage have independent receiving endpoints on the same WebSocket channel. Other configured backend -services use their own service ID and the same lane mapping. +The endpoint can serve independently chosen topics over the same WebSocket, with no reserved backend-service lanes. Pass the obtained endpoint to ordinary Java objects. The receiving object registers an implementation at runtime: ```java public final class GreetingReceiver { private final RpcEndpoint rpc; + private final RpcCodec codec; - public GreetingReceiver(RpcEndpoint rpc) { + public GreetingReceiver(RpcEndpoint rpc, RpcCodec codec) { this.rpc = rpc; + this.codec = codec; } public MessageSubscription enable(Greeting implementation) { - return rpc.register("greeting", Greeting.class, implementation); + return rpc.register("greeting", Greeting.class, implementation, codec); } } ``` @@ -288,13 +289,15 @@ The other peer keeps its calling code separate: ```java public final class GreetingCaller { private final RpcEndpoint rpc; + private final RpcCodec codec; - public GreetingCaller(RpcEndpoint rpc) { + public GreetingCaller(RpcEndpoint rpc, RpcCodec codec) { this.rpc = rpc; + this.codec = codec; } public String hello(String value) { - Greeting greeting = rpc.proxy("greeting", Greeting.class); + Greeting greeting = rpc.proxy("greeting", Greeting.class, codec); return greeting.hello(value); } } @@ -323,7 +326,7 @@ Replacing a closed endpoint does not transfer its old registrations. After an id obtain the new endpoint and register the appropriate implementations again. Backend connection owners and caches are process-local; the existing exact-owner callback routing requirement still applies. -The inner JSON envelope uses `protocol: "dbeaver.rpc.v1"`, `type`, and `data`, plus `topic` and `operation` for requests +The inner JSON envelope uses `protocol: "dbeaver.rpc.v2"`, `type`, and `data`, plus `operation` for requests or a fixed `error` code for failures. Application `data` can be any JSON value, including null; the outer DataDam message remains an object and retains its existing request/reply correlation and source validation. Malformed inner envelopes are ignored without exposing raw contents. Transport validation and RPC validation do not replace authentication. diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcServices.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcServices.java deleted file mode 100644 index f844e83..0000000 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcServices.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * DBeaver - Universal Database Manager - * Copyright (C) 2010-2026 DBeaver Corp - * - * All Rights Reserved. - * - * NOTICE: All information contained herein is, and remains - * the property of DBeaver Corp and its suppliers, if any. - * The intellectual and technical concepts contained - * herein are proprietary to DBeaver Corp and its suppliers - * and may be covered by U.S. and Foreign Patents, - * patents in process, and are protected by trade secret or copyright law. - * Dissemination of this information or reproduction of this material - * is strictly forbidden unless prior written permission is obtained - * from DBeaver Corp. - */ -package com.dbeaver.datadam.external.api.rpc; - -import org.jkiss.code.NotNull; - -import java.util.Objects; - -public final class DDRpcServices { - public static final String ACCOUNT = "account"; - public static final String STORAGE = "storage"; - - private DDRpcServices() { - } - - @NotNull - public static String topic(@NotNull String serviceId) { - Objects.requireNonNull(serviceId); - if (serviceId.isEmpty() || serviceId.length() > 128 || !serviceId.chars().allMatch(value -> - value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' - || value == '.' || value == '_' || value == '-')) { - throw new IllegalArgumentException("Expected a service ID of 1 to 128 ASCII letters, digits, dots, underscores or hyphens"); - } - return "rpc." + serviceId; - } -} diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java index d650fca..b947438 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java @@ -23,6 +23,7 @@ import com.dbeaver.messaging.requestreply.RequestReplyMessage; import com.dbeaver.messaging.requestreply.RequestReplyTransport; import com.dbeaver.messaging.rpc.RpcMessage; +import com.dbeaver.messaging.rpc.RpcTransport; import com.google.gson.JsonElement; import com.google.gson.JsonNull; import com.google.gson.JsonObject; @@ -36,7 +37,7 @@ import java.util.concurrent.Semaphore; public final class DDRpcTransport implements RequestReplyTransport> { - private static final String PROTOCOL = "dbeaver.rpc.v1"; + private static final String PROTOCOL = "dbeaver.rpc.v2"; private static final int MAX_PENDING_SENDS = 128; private final Semaphore sendBudget = new Semaphore(MAX_PENDING_SENDS); private final RequestReplyTransport delegate; @@ -45,6 +46,12 @@ public DDRpcTransport(@NotNull RequestReplyTransport delegate) { this.delegate = Objects.requireNonNull(delegate); } + @NotNull + public static RpcTransport forChannel(@NotNull DDMessageChannel channel) { + Objects.requireNonNull(channel); + return topic -> forChannel(channel, topic); + } + @NotNull public static DDRpcTransport forChannel(@NotNull DDMessageChannel channel, @NotNull String topic) { return new DDRpcTransport(DDRequestReplyTransport.forChannel(channel, topic)); @@ -110,7 +117,6 @@ private static JsonObject encode(@NotNull RpcMessage message) { envelope.addProperty("error", Objects.requireNonNull(message.error()).name()); } else { if (message.type() == RpcMessage.Type.REQUEST) { - envelope.addProperty("topic", message.topic()); envelope.addProperty("operation", message.operation()); } envelope.add("data", message.payload() == null ? JsonNull.INSTANCE : message.payload().deepCopy()); @@ -138,12 +144,12 @@ private static RequestReplyMessage> decode(@NotNull Requ payload = RpcMessage.failure(RpcMessage.Error.valueOf(string(envelope, "error"))); } else { JsonElement data = envelope.get("data"); - if (data == null || envelope.size() != (type == RpcMessage.Type.REQUEST ? 5 : 3)) { + if (data == null || envelope.size() != (type == RpcMessage.Type.REQUEST ? 4 : 3)) { return null; } JsonElement snapshot = data.isJsonNull() ? null : data.deepCopy(); payload = type == RpcMessage.Type.REQUEST - ? RpcMessage.request(string(envelope, "topic"), string(envelope, "operation"), snapshot) + ? RpcMessage.request(string(envelope, "operation"), snapshot) : RpcMessage.response(snapshot); } return new RequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), payload, message.error()); diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java index a358840..eb1cc92 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java @@ -24,6 +24,7 @@ /** Provides asynchronous DataDam messaging and connection-state observation. */ public interface DDMessageChannel extends AutoCloseable { + @NotNull DDChannelStatus getStatus(); diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesTest.java deleted file mode 100644 index f1272e0..0000000 --- a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesTest.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * DBeaver - Universal Database Manager - * Copyright (C) 2010-2026 DBeaver Corp - * - * All Rights Reserved. - * - * NOTICE: All information contained herein is, and remains - * the property of DBeaver Corp and its suppliers, if any. - * The intellectual and technical concepts contained - * herein are proprietary to DBeaver Corp and its suppliers - * and may be covered by U.S. and Foreign Patents, - * patents in process, and are protected by trade secret or copyright law. - * Dissemination of this information or reproduction of this material - * is strictly forbidden unless prior written permission is obtained - * from DBeaver Corp. - */ -package com.dbeaver.datadam.external.api.rpc; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.List; - -class DDRpcServicesTest { - @Test - void separatesBackendServicesAndSupportsConfiguredExtensions() { - Assertions.assertEquals("rpc.account", DDRpcServices.topic(DDRpcServices.ACCOUNT)); - Assertions.assertEquals("rpc.storage", DDRpcServices.topic(DDRpcServices.STORAGE)); - Assertions.assertEquals("rpc.other-service.v2_1", DDRpcServices.topic("other-service.v2_1")); - Assertions.assertNotEquals(DDRpcServices.topic(DDRpcServices.ACCOUNT), DDRpcServices.topic(DDRpcServices.STORAGE)); - Assertions.assertEquals(132, DDRpcServices.topic("a".repeat(128)).length()); - } - - @Test - void rejectsInvalidServiceIdsBeforeChoosingALane() { - for (String value : List.of("", " ", "a b", "a/b", "a:b", "a\nb", "a\u0000b", "сервис", "a".repeat(129))) { - Assertions.assertThrows(IllegalArgumentException.class, () -> DDRpcServices.topic(value), value); - } - Assertions.assertThrows(NullPointerException.class, () -> DDRpcServices.topic(null)); - } -} diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesIntegrationTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java similarity index 54% rename from apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesIntegrationTest.java rename to apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java index d0074fa..b5fc338 100644 --- a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcServicesIntegrationTest.java +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java @@ -25,15 +25,20 @@ import com.dbeaver.datadam.external.api.transport.DDMessageListener; import com.dbeaver.datadam.external.api.transport.DDSubscription; import com.dbeaver.messaging.requestreply.RequestReplyOptions; +import com.dbeaver.messaging.rpc.RpcCodec; import com.dbeaver.messaging.rpc.RpcEndpoint; import com.dbeaver.messaging.rpc.RpcException; import com.dbeaver.messaging.rpc.RpcMessage; import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import java.lang.reflect.Type; import java.time.Duration; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -42,48 +47,60 @@ import java.util.function.Consumer; @Timeout(10) -class DDRpcServicesIntegrationTest { +class DDRpcTopicsIntegrationTest { private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("desktop", "run", "physical-connection"); private static final RequestReplyOptions OPTIONS = new RequestReplyOptions(Duration.ofSeconds(2), 16, 8); @Test - void servesAccountAndStorageBidirectionallyOverOneDesktopChannel() throws Exception { + void routesExactDeveloperTopicsBidirectionallyWithIndependentBindingCodecs() { Channel desktop = new Channel(); - Channel account = new Channel(); - Channel storage = new Channel(); + Channel firstBackend = new Channel(); + Channel secondBackend = new Channel(); desktop.forward = message -> { - account.receive(message); - storage.receive(message); + firstBackend.receive(message); + secondBackend.receive(message); }; - account.forward = desktop::receive; - storage.forward = desktop::receive; + firstBackend.forward = desktop::receive; + secondBackend.forward = desktop::receive; var codec = new DDGsonRpcCodec(new Gson()); - try (var desktopAccount = RpcEndpoint.open( - DDRpcTransport.forChannel(desktop, DDRpcServices.topic(DDRpcServices.ACCOUNT)), codec, OPTIONS); - var desktopStorage = RpcEndpoint.open( - DDRpcTransport.forChannel(desktop, DDRpcServices.topic(DDRpcServices.STORAGE)), codec, OPTIONS); - var accountRpc = RpcEndpoint.open( - DDRpcTransport.forChannel(account, DDRpcServices.topic(DDRpcServices.ACCOUNT)), codec, OPTIONS); - var storageRpc = RpcEndpoint.open( - DDRpcTransport.forChannel(storage, DDRpcServices.topic(DDRpcServices.STORAGE)), codec, OPTIONS); - var accountGreeting = accountRpc.register("greeting", Greeting.class, value -> value + " World from Account"); - var storageGreeting = storageRpc.register("greeting", Greeting.class, value -> value + " World from Storage"); - var accountCounter = desktopAccount.register("counter", Counter.class, value -> value + 1); - var storageCounter = desktopStorage.register("counter", Counter.class, value -> value + 10)) { - Assertions.assertEquals("Hello World from Account", desktopAccount.proxy("greeting", Greeting.class).hello("Hello")); - Assertions.assertEquals("Hello World from Storage", desktopStorage.proxy("greeting", Greeting.class).hello("Hello")); - Assertions.assertEquals(2, accountRpc.proxy("counter", Counter.class).increment(1)); - Assertions.assertEquals(11, storageRpc.proxy("counter", Counter.class).increment(1)); - - accountCounter.close(); - + String firstTopic = "hello.files.v2"; + String secondTopic = "developer..chosen-topic"; + var serviceCodec = new PrefixCodec("response:", "argument:"); + var firstProxyCodec = new PrefixCodec("first:", "one:"); + var secondProxyCodec = new PrefixCodec("second:", "two:"); + try (var desktopRpc = RpcEndpoint.open(DDRpcTransport.forChannel(desktop), codec, OPTIONS); + var firstRpc = RpcEndpoint.open(DDRpcTransport.forChannel(firstBackend), OPTIONS); + var secondRpc = RpcEndpoint.open(DDRpcTransport.forChannel(secondBackend), OPTIONS); + var firstGreeting = firstRpc.register(firstTopic, Greeting.class, value -> value, serviceCodec); + var secondGreeting = secondRpc.register(secondTopic, Greeting.class, value -> "other:" + value, codec); + var reverseGreeting = desktopRpc.register(firstTopic, Greeting.class, value -> "desktop:" + value)) { + Greeting first = desktopRpc.proxy(firstTopic, Greeting.class, firstProxyCodec); + Greeting second = desktopRpc.proxy(firstTopic, Greeting.class, secondProxyCodec); + Assertions.assertEquals("one:response:argument:first:Hello", first.hello("Hello")); + Assertions.assertEquals("two:response:argument:second:Hello", second.hello("Hello")); + Assertions.assertEquals("other:Hello", desktopRpc.proxy(secondTopic, Greeting.class).hello("Hello")); + Assertions.assertEquals("desktop:reverse", firstRpc.proxy(firstTopic, Greeting.class, codec).hello("reverse")); + Assertions.assertEquals("response:argument:fallback", desktopRpc.proxy(firstTopic, Greeting.class).hello("fallback")); + Assertions.assertEquals(4, desktop.subscriptions.size()); + Assertions.assertEquals(2, firstBackend.subscriptions.size()); + Assertions.assertEquals(2, secondBackend.subscriptions.size()); + Assertions.assertTrue(desktop.subscriptions.stream().anyMatch(value -> value.topic().equals(firstTopic))); + Assertions.assertTrue(desktop.subscriptions.stream().anyMatch(value -> value.topic().equals(secondTopic))); + for (DDMessage message : desktop.sent) { + if ("REQUEST".equals(message.payload().get("type").getAsString())) { + Assertions.assertTrue(List.of(firstTopic, secondTopic).contains(message.topic())); + JsonElement envelope = message.payload().get("data"); + Assertions.assertFalse(envelope.getAsJsonObject().has("topic")); + Assertions.assertEquals("dbeaver.rpc.v2", envelope.getAsJsonObject().get("protocol").getAsString()); + } + } + firstGreeting.close(); RpcException missing = Assertions.assertThrows(RpcException.class, - () -> accountRpc.proxy("counter", Counter.class).increment(1)); + () -> first.hello("closed")); Assertions.assertEquals(RpcMessage.Error.UNKNOWN_SERVICE, missing.remoteError()); - Assertions.assertEquals(11, storageRpc.proxy("counter", Counter.class).increment(1)); - Assertions.assertEquals("Still World from Account", desktopAccount.proxy("greeting", Greeting.class).hello("Still")); + Assertions.assertEquals("other:still", desktopRpc.proxy(secondTopic, Greeting.class).hello("still")); } - for (Channel channel : List.of(desktop, account, storage)) { + for (Channel channel : List.of(desktop, firstBackend, secondBackend)) { Assertions.assertFalse(channel.closed); Assertions.assertTrue(channel.subscriptions.isEmpty()); Assertions.assertTrue(channel.observers.isEmpty()); @@ -96,14 +113,24 @@ public interface Greeting { String hello(@NotNull String value); } - /** Increments a value on the selected Desktop lane. */ - public interface Counter { - int increment(int value); + private record PrefixCodec(@NotNull String encodedPrefix, @NotNull String decodedPrefix) implements RpcCodec { + @Override + @NotNull + public JsonElement encode(@Nullable Object value, @NotNull Type type) { + return new JsonPrimitive(encodedPrefix + value); + } + + @Override + @NotNull + public Object decode(@Nullable JsonElement payload, @NotNull Type type) { + return decodedPrefix + payload.getAsString(); + } } private static final class Channel implements DDMessageChannel { private final List subscriptions = new CopyOnWriteArrayList<>(); private final List> observers = new CopyOnWriteArrayList<>(); + private final List sent = new CopyOnWriteArrayList<>(); private Consumer forward = message -> { }; private boolean closed; @@ -124,6 +151,7 @@ public DDSubscription observeState(@NotNull Consumer observer) @Override @NotNull public CompletionStage send(@NotNull DDMessage message) { + sent.add(message); forward.accept(message); return CompletableFuture.completedFuture(null); } diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java index fa93d53..9bd9dc8 100644 --- a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java @@ -65,13 +65,13 @@ @Timeout(10) public class DDRpcTransportTest { - private static final String LANE = "rpc"; + private static final String TOPIC = "custom.files"; private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("desktop", "run", "socket"); private static final RequestReplyOptions OPTIONS = new RequestReplyOptions(Duration.ofSeconds(2), 16, 8); private static final RpcCodec CODEC = new GsonCodec(); @Test - void multiplexesIndependentBidirectionalServicesOnOneBorrowedLane() throws Exception { + void servesIndependentBidirectionalTopicsWithoutClosingTheBorrowedChannel() throws Exception { FakeChannel left = new FakeChannel(); FakeChannel right = new FakeChannel(); left.forward = right::deliver; @@ -79,9 +79,8 @@ void multiplexesIndependentBidirectionalServicesOnOneBorrowedLane() throws Excep AtomicInteger nullArguments = new AtomicInteger(); AtomicInteger notifications = new AtomicInteger(); final DDSubscription borrowed = left.subscribe("other", (connection, message) -> { }); - RequestReplyTransport backendLane = DDRequestReplyTransport.forChannel(left, LANE); - try (var caller = RpcEndpoint.open(new DDRpcTransport(backendLane), CODEC, OPTIONS); - var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right, LANE), CODEC, OPTIONS); + try (var caller = RpcEndpoint.open(topic -> new DDRpcTransport(DDRequestReplyTransport.forChannel(left, topic)), CODEC, OPTIONS); + var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); var leftNames = caller.register("names", Names.class, values -> List.of("left", values.getFirst())); var rightNames = handler.register("names", Names.class, values -> List.of("right", values.getFirst())); var otherNames = handler.register("other-names", Names.class, values -> List.of("other")); @@ -102,10 +101,10 @@ void multiplexesIndependentBidirectionalServicesOnOneBorrowedLane() throws Excep Assertions.assertEquals(1, nullArguments.get()); caller.proxy("notifications", Notification.class).accept(null); Assertions.assertEquals(1, notifications.get()); - Assertions.assertEquals(3, left.subscriptions.size()); - Assertions.assertEquals(2, right.subscriptions.size()); - Assertions.assertEquals(LANE, left.subscriptions.getLast().topic); - Assertions.assertEquals(LANE, right.subscriptions.getLast().topic); + Assertions.assertEquals(11, left.subscriptions.size()); + Assertions.assertEquals(10, right.subscriptions.size()); + Assertions.assertEquals("notifications", left.subscriptions.getLast().topic); + Assertions.assertEquals("notifications", right.subscriptions.getLast().topic); otherNames.close(); RpcException missing = Assertions.assertThrows(RpcException.class, () -> caller.proxy("other-names", Names.class).apply(List.of())); @@ -131,8 +130,8 @@ void isolatesGsonArgumentsAndResultsAcrossRealMessageChannels() throws Exception right.forward = left::deliver; AtomicReference received = new AtomicReference<>(); Item argument = new Item("argument", new ArrayList<>(List.of("initial"))); - try (var caller = RpcEndpoint.open(DDRpcTransport.forChannel(left, LANE), CODEC, OPTIONS); - var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right, LANE), CODEC, OPTIONS); + try (var caller = RpcEndpoint.open(DDRpcTransport.forChannel(left), CODEC, OPTIONS); + var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); var service = handler.register("records", Records.class, value -> { received.set(value); value.tags().add("handler"); @@ -164,7 +163,8 @@ void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { Assertions.assertEquals(request.requestId(), sent.requestId()); Assertions.assertEquals(request.replyTo(), sent.replyTo()); Assertions.assertEquals(RequestReplyMessage.Type.REQUEST, sent.type()); - Assertions.assertEquals("dbeaver.rpc.v1", Objects.requireNonNull(sent.payload()).get("protocol").getAsString()); + Assertions.assertEquals("dbeaver.rpc.v2", Objects.requireNonNull(sent.payload()).get("protocol").getAsString()); + Assertions.assertFalse(sent.payload().has("topic")); delegate.deliver(sent); Assertions.assertEquals(request, received.messages.getLast()); RequestReplyMessage> response = request.response(RpcMessage.response(new JsonPrimitive(42))); @@ -262,7 +262,7 @@ void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception List nonStrings = List.of(new JsonPrimitive(1), new JsonPrimitive(true), JsonNull.INSTANCE, new JsonObject(), new JsonArray()); try (var subscription = adapter.subscribe(endpoint, received)) { - for (String field : List.of("protocol", "type", "topic", "operation")) { + for (String field : List.of("protocol", "type", "operation")) { JsonObject missing = requestEnvelope(); missing.remove(field); delegate.deliver(RequestReplyMessage.request(request.requestId(), endpoint, missing)); @@ -273,14 +273,13 @@ void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception } } List> malformed = List.of( - envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v2"), + envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v1"), envelope -> envelope.addProperty("protocol", "unrelated"), envelope -> envelope.addProperty("type", "OTHER"), envelope -> envelope.addProperty("type", "request"), envelope -> envelope.addProperty("type", "RESPONSE"), envelope -> envelope.addProperty("type", "ERROR"), - envelope -> envelope.addProperty("topic", " "), - envelope -> envelope.addProperty("topic", "x".repeat(257)), + envelope -> envelope.addProperty("topic", "legacy.topic"), envelope -> envelope.addProperty("operation", "\t"), envelope -> envelope.addProperty("operation", "x".repeat(1025)), envelope -> envelope.remove("data"), @@ -294,12 +293,10 @@ void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception } Assertions.assertTrue(received.messages.isEmpty()); JsonObject boundary = requestEnvelope(); - boundary.addProperty("topic", "x".repeat(256)); boundary.addProperty("operation", "x".repeat(1024)); delegate.deliver(RequestReplyMessage.request(request.requestId(), endpoint, boundary)); Assertions.assertEquals(1, received.messages.size()); RpcMessage accepted = Objects.requireNonNull(received.messages.getLast().payload()); - Assertions.assertEquals("x".repeat(256), accepted.topic()); Assertions.assertEquals("x".repeat(1024), accepted.operation()); Assertions.assertNull(accepted.payload()); } @@ -354,7 +351,7 @@ void dropsMalformedResponseAndNestedErrorShapes() throws Exception { @Test void requiresObjectEnvelopesThroughExistingDataDamFraming() throws Exception { FakeChannel channel = new FakeChannel(); - DDRpcTransport adapter = DDRpcTransport.forChannel(channel, LANE); + DDRpcTransport adapter = DDRpcTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); Received received = new Received(); try (var subscription = adapter.subscribe(endpoint, received)) { @@ -374,7 +371,7 @@ void requiresObjectEnvelopesThroughExistingDataDamFraming() throws Exception { @Test void neutralRpcDistinguishesNestedErrorsOuterErrorsAndInvalidResponses() throws Exception { MemoryTransport delegate = new MemoryTransport(); - try (var endpoint = RpcEndpoint.open(new DDRpcTransport(delegate), CODEC, OPTIONS)) { + try (var endpoint = RpcEndpoint.open(topic -> new DDRpcTransport(delegate), CODEC, OPTIONS)) { Text proxy = endpoint.proxy("text", Text.class); for (RpcMessage.Error error : RpcMessage.Error.values()) { delegate.forward = request -> delegate.deliver(request.response(errorEnvelope(error))); @@ -401,9 +398,9 @@ void neutralRpcDistinguishesNestedErrorsOuterErrorsAndInvalidResponses() throws void malformedAndUnknownVersionRepliesRemainUnansweredUntilTimeout() throws Exception { MemoryTransport delegate = new MemoryTransport(); RequestReplyOptions options = new RequestReplyOptions(Duration.ofMillis(100), 4, 2); - try (var endpoint = RpcEndpoint.open(new DDRpcTransport(delegate), CODEC, options)) { + try (var endpoint = RpcEndpoint.open(topic -> new DDRpcTransport(delegate), CODEC, options)) { for (Consumer mutation : List.>of( - envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v2"), + envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v1"), envelope -> envelope.addProperty("type", "UNKNOWN"), envelope -> envelope.remove("data") )) { @@ -424,19 +421,21 @@ void dispatchesRealRpcFailuresInsideSuccessfulRequestReplyResponses() throws Exc FakeChannel right = new FakeChannel(); left.forward = right::deliver; right.forward = left::deliver; - try (var caller = RequestReplyEndpoint.open(DDRpcTransport.forChannel(left, LANE), OPTIONS); - var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right, LANE), CODEC, OPTIONS); - var service = handler.register("text", Text.class, value -> { + try (var caller = RequestReplyEndpoint.open(DDRpcTransport.forChannel(left, TOPIC), OPTIONS); + var missingCaller = RequestReplyEndpoint.open(DDRpcTransport.forChannel(left, "missing"), OPTIONS); + var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); + var service = handler.register(TOPIC, Text.class, value -> { throw new IllegalStateException("Handler failed"); })) { - RpcMessage missingService = caller.request(RpcMessage.request("missing", "apply", null)); + handler.proxy("missing", Text.class); + RpcMessage missingService = missingCaller.request(RpcMessage.request("apply", null)); Assertions.assertEquals(RpcMessage.failure(RpcMessage.Error.UNKNOWN_SERVICE), missingService); - RpcMessage missingOperation = caller.request(RpcMessage.request("text", "missing", null)); + RpcMessage missingOperation = caller.request(RpcMessage.request("missing", null)); Assertions.assertEquals(RpcMessage.failure(RpcMessage.Error.UNKNOWN_OPERATION), missingOperation); String operation = "apply(" + String.class.getTypeName() + ")"; - RpcMessage codecFailure = caller.request(RpcMessage.request("text", operation, new JsonObject())); + RpcMessage codecFailure = caller.request(RpcMessage.request(operation, new JsonObject())); Assertions.assertEquals(RpcMessage.failure(RpcMessage.Error.CODEC_FAILED), codecFailure); - RpcMessage handlerFailure = caller.request(RpcMessage.request("text", operation, new JsonPrimitive("value"))); + RpcMessage handlerFailure = caller.request(RpcMessage.request(operation, new JsonPrimitive("value"))); Assertions.assertEquals(RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED), handlerFailure); Assertions.assertTrue(right.sent.stream().allMatch(message -> "RESPONSE".equals(message.payload().get("type").getAsString()))); @@ -610,8 +609,8 @@ void forwardsDisconnectsAndDelegatesSubscriptionOwnership() throws Exception { @Test void rollsBackDelegatedChannelSubscriptionsWithoutClosingTheBorrowedChannel() { FakeChannel channel = new FakeChannel(); - channel.failTopic = LANE; - DDRpcTransport adapter = DDRpcTransport.forChannel(channel, LANE); + channel.failTopic = TOPIC; + DDRpcTransport adapter = DDRpcTransport.forChannel(channel, TOPIC); RequestReplyException failure = Assertions.assertThrows(RequestReplyException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); Assertions.assertSame(channel.subscriptionFailure, failure.getCause()); @@ -622,11 +621,66 @@ void rollsBackDelegatedChannelSubscriptionsWithoutClosingTheBorrowedChannel() { Assertions.assertThrows(IllegalStateException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); } + @Test + void failedLazyTopicSubscriptionRollsBackAndCanBeRetried() { + FakeChannel channel = new FakeChannel(); + channel.failTopic = TOPIC; + try (var endpoint = RpcEndpoint.open(DDRpcTransport.forChannel(channel), CODEC, OPTIONS)) { + RpcException failure = Assertions.assertThrows(RpcException.class, () -> endpoint.proxy(TOPIC, Text.class)); + Assertions.assertEquals(RpcException.Reason.SEND_FAILED, failure.reason()); + Assertions.assertSame(channel.subscriptionFailure, failure.getCause().getCause()); + Assertions.assertTrue(channel.observers.isEmpty()); + Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active)); + channel.failTopic = null; + endpoint.proxy(TOPIC, Text.class); + Assertions.assertEquals(2, channel.subscriptions.stream().filter(subscription -> subscription.active).count()); + } + Assertions.assertFalse(channel.closed); + Assertions.assertTrue(channel.observers.isEmpty()); + Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active)); + } + + @Test + void incompatibleCodecPayloadsUseNormalSendAndRemoteCodecFailures() { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = right::deliver; + right.forward = left::deliver; + RpcCodec incompatible = new RpcCodec<>() { + @Override + @Nullable + public String encode(@Nullable Object value, @NotNull Type type) { + return String.class.cast(value); + } + + @Override + @Nullable + public Object decode(@Nullable String payload, @NotNull Type type) { + return payload; + } + }; + try (var caller = RpcEndpoint.open(DDRpcTransport.forChannel(left), CODEC, OPTIONS); + var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); + var service = handler.register(TOPIC, Text.class, value -> value)) { + RpcException sendFailure = Assertions.assertThrows(RpcException.class, + () -> caller.proxy(TOPIC, Text.class, incompatible).apply("incompatible")); + Assertions.assertEquals(RpcException.Reason.SEND_FAILED, sendFailure.reason()); + Assertions.assertEquals("compatible", caller.proxy(TOPIC, Text.class).apply("compatible")); + service.close(); + try (var replacement = handler.register(TOPIC, Text.class, value -> value, incompatible)) { + RpcException codecFailure = Assertions.assertThrows(RpcException.class, + () -> caller.proxy(TOPIC, Text.class).apply("incompatible")); + Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, codecFailure.reason()); + Assertions.assertEquals(RpcMessage.Error.CODEC_FAILED, codecFailure.remoteError()); + } + } + } + @Test void channelDisconnectTerminatesPendingRpcAndClosingLeavesTheChannelIntact() throws Exception { FakeChannel channel = new FakeChannel(); channel.forward = message -> channel.setStatus(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); - try (var endpoint = RpcEndpoint.open(DDRpcTransport.forChannel(channel, LANE), CODEC, OPTIONS)) { + try (var endpoint = RpcEndpoint.open(DDRpcTransport.forChannel(channel), CODEC, OPTIONS)) { RpcException failure = Assertions.assertThrows(RpcException.class, () -> endpoint.proxy("text", Text.class).apply("request")); Assertions.assertEquals(RpcException.Reason.DISCONNECTED, failure.reason()); @@ -638,7 +692,7 @@ void channelDisconnectTerminatesPendingRpcAndClosingLeavesTheChannelIntact() thr @NotNull private static RequestReplyMessage> request(@NotNull UUID endpoint, @Nullable JsonElement value) { - return RequestReplyMessage.request(UUID.randomUUID(), endpoint, RpcMessage.request("projects", "apply", value)); + return RequestReplyMessage.request(UUID.randomUUID(), endpoint, RpcMessage.request("apply", value)); } private static void assertSendFailure(@NotNull Throwable expected, @NotNull CompletionStage result) { @@ -656,18 +710,18 @@ private static void assertOverloaded(@NotNull CompletionStage result) { @NotNull private static JsonObject requestEnvelope() { - return JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v1\",\"type\":\"REQUEST\"," - + "\"topic\":\"projects\",\"operation\":\"apply\",\"data\":null}").getAsJsonObject(); + return JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v2\",\"type\":\"REQUEST\"," + + "\"operation\":\"apply\",\"data\":null}").getAsJsonObject(); } @NotNull private static JsonObject responseEnvelope() { - return JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v1\",\"type\":\"RESPONSE\",\"data\":null}").getAsJsonObject(); + return JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v2\",\"type\":\"RESPONSE\",\"data\":null}").getAsJsonObject(); } @NotNull private static JsonObject errorEnvelope(@NotNull RpcMessage.Error error) { - JsonObject result = JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v1\",\"type\":\"ERROR\"}").getAsJsonObject(); + JsonObject result = JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v2\",\"type\":\"ERROR\"}").getAsJsonObject(); result.addProperty("error", error.name()); return result; } diff --git a/apis/com.dbeaver.messaging/README.md b/apis/com.dbeaver.messaging/README.md index 94b30a2..4041e17 100644 --- a/apis/com.dbeaver.messaging/README.md +++ b/apis/com.dbeaver.messaging/README.md @@ -101,11 +101,12 @@ public interface Documents { } ``` -The receiver creates one reusable endpoint, selects its codec once, and registers its service under a logical topic: +The connection owner creates one reusable endpoint over a topic-scoped transport factory. The receiver supplies +its own codec when registering an implementation: ```java -try (var rpc = RpcEndpoint.open(transport, codec); - var registration = rpc.register("documents", Documents.class, implementation)) { +try (var rpc = RpcEndpoint.open(transports); + var registration = rpc.register("documents", Documents.class, implementation, codec)) { serveApplication(); } ``` @@ -113,25 +114,32 @@ try (var rpc = RpcEndpoint.open(transport, codec); The caller creates its own endpoint over the corresponding transport and obtains a proxy: ```java -try (var rpc = RpcEndpoint.open(transport, codec)) { - Documents documents = rpc.proxy("documents", Documents.class); +try (var rpc = RpcEndpoint.open(transports)) { + Documents documents = rpc.proxy("documents", Documents.class, codec); Document document = documents.load(request); documents.save(saveRequest); } ``` -The same endpoint can also register other local services or obtain proxies for services on the opposite peer. RPC topics -are logical service keys within one request/reply channel, not new connections or subscriptions. Both peers must select -the same underlying request/reply lane, with one receiving endpoint per peer. A topic can have one local implementation; -different topics can use the same interface with different implementations. Proxy construction does not contact the -remote peer or prove service presence. +An owner can provide a default codec with `RpcEndpoint.open(transports, defaultCodec)`. Its consumers can then use +`rpc.proxy("documents", Documents.class)` and `rpc.register("documents", Documents.class, implementation)`. +The overloads with an explicit codec override that fallback only for the new proxy or registration. The core does +not construct a serialization framework. Without a configured default, no-codec overloads fail before opening routes. + +The same endpoint can register other local services or obtain proxies for services on the opposite peer. The supplied +topic is the actual transport topic, not a service key inside another lane. The factory opens a request/reply transport +for each topic on demand; repeated use shares its local topic scope. A topic has at most one local implementation, +and different topics can use the same interface with different implementations and codecs. Topic naming and recipient +subscriptions belong to the application. Proxy construction establishes local receiving routes but does not contact +the remote implementation or prove service presence. +Opening a topic can establish subscriptions synchronously; use a worker thread when the adapter performs network I/O. Proxy methods block the calling thread. Call them from worker or virtual threads, not a transport listener or event loop. -`RpcEndpoint` has no payload type parameter. Its generic `open` factories check that -`RequestReplyTransport>` and `RpcCodec

` use the same payload representation, then retain that choice -internally. Business code receives an ordinary `RpcEndpoint` and only uses its typed interfaces. The transport adapter -encodes the neutral protocol envelope; the codec encodes application arguments and results. A TCP, WebSocket, +`RpcEndpoint` has no payload type parameter. Its transport factory supplies topic-scoped +`RequestReplyTransport>` adapters. Each proxy and registration supplies a codec compatible with that +payload representation. Business code receives an ordinary `RpcEndpoint` and uses typed interfaces. The transport +adapter encodes the neutral protocol envelope; the local codec encodes application arguments and results. A TCP, WebSocket, long-polling, or in-memory transport can implement this interface without changing RPC code. No WebSocket types or JSON library are used here. @@ -143,13 +151,15 @@ and proxy creation are runtime operations and do not require Spring or a separat ```java public final class DocumentReceiver { private final RpcEndpoint rpc; + private final RpcCodec documentCodec; - public DocumentReceiver(RpcEndpoint rpc) { + public DocumentReceiver(RpcEndpoint rpc, RpcCodec documentCodec) { this.rpc = rpc; + this.documentCodec = documentCodec; } public MessageSubscription enable(Documents implementation) { - return rpc.register("documents", Documents.class, implementation); + return rpc.register("documents", Documents.class, implementation, documentCodec); } } ``` @@ -159,13 +169,15 @@ The opposite peer uses its own endpoint in a separate caller: ```java public final class DocumentCaller { private final RpcEndpoint rpc; + private final RpcCodec documentCodec; - public DocumentCaller(RpcEndpoint rpc) { + public DocumentCaller(RpcEndpoint rpc, RpcCodec documentCodec) { this.rpc = rpc; + this.documentCodec = documentCodec; } public Document load(DocumentRequest request) { - Documents documents = rpc.proxy("documents", Documents.class); + Documents documents = rpc.proxy("documents", Documents.class, documentCodec); return documents.load(request); } } @@ -189,7 +201,7 @@ cleanup throws. A repeated `close()` does not wait for another thread's ongoing type variables, generic methods, default methods, varargs, and `Future`/`CompletionStage` returns are rejected during proxy construction or registration. Generic-parent substitutions are not inferred. Inherited operations with ambiguous generic argument or result types must be explicitly redeclared in the public contract. -- `RpcCodec.encode(value, type)` and `decode(payload, type)` are selected once per endpoint. Implementations must handle +- `RpcCodec.encode(value, type)` and `decode(payload, type)` are selected per proxy or implementation registration. Implementations must handle nulls and concurrent calls. Serialization remains application-owned; the RPC core does not construct application types or select a serialization framework. Payload immutability and snapshot requirements are the same as request/reply. Peers must use compatible contracts and codecs; no automatic contract negotiation or class transfer is performed. @@ -209,7 +221,7 @@ handler failures, and remote codec failures return fixed error codes, never exce Declared application exceptions are not reconstructed on the caller. A peer with no receiving endpoint can only be detected through transport failure or timeout. Local failures retain their causes. -`RpcEndpoint.open(transport, codec, options)` accepts the existing `RequestReplyOptions` for deadlines and capacity. +`RpcEndpoint.open(transports, options)` accepts the existing `RequestReplyOptions` for deadlines and capacity. The deadline covers the underlying request/reply call, not arbitrary application codec execution. Codecs should complete promptly. Timeout or interruption leaves the remote outcome potentially unknown; the RPC layer adds no retries or remote cancellation promises. @@ -217,8 +229,4 @@ remote cancellation promises. Closing a service registration removes only that local implementation. It does not close the endpoint or its proxies, and work already dispatched to the implementation can finish. Repeatedly closing an old registration cannot remove a replacement. Closing the RPC endpoint invalidates its proxies, clears service registrations, and closes its underlying -request/reply endpoint, failing pending calls. The borrowed physical transport remains open. - -The overload `RpcEndpoint.open(requests, codec)` accepts an existing `RequestReplyEndpoint>` with no -registered handler and transfers its ownership on success. A failed opening does not remove another handler. Do not -close or install other handlers on that request/reply endpoint while RPC owns it. +request/reply scopes, failing pending calls. The borrowed physical transport remains open. diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java index 717faca..da2c47f 100644 --- a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java @@ -21,7 +21,7 @@ import java.lang.reflect.Type; -/** Encodes and decodes application values using their declared RPC types. */ +/** Encodes and decodes declared RPC types using the payload representation accepted by the endpoint transport. */ public interface RpcCodec

{ @Nullable P encode(@Nullable Object value, @NotNull Type type) throws Exception; diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java index ddc11e4..1aedb75 100644 --- a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java @@ -20,7 +20,6 @@ import com.dbeaver.messaging.requestreply.RequestReplyEndpoint; import com.dbeaver.messaging.requestreply.RequestReplyException; import com.dbeaver.messaging.requestreply.RequestReplyOptions; -import com.dbeaver.messaging.requestreply.RequestReplyTransport; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; @@ -28,8 +27,13 @@ import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; public final class RpcEndpoint implements AutoCloseable { private final State state; @@ -39,44 +43,55 @@ private RpcEndpoint(@NotNull State state) { } @NotNull - public static

RpcEndpoint open(@NotNull RequestReplyEndpoint> requests, @NotNull RpcCodec

codec) { - return new RpcEndpoint(new State<>(requests, codec)); + public static

RpcEndpoint open(@NotNull RpcTransport

transport) { + return open(transport, RequestReplyOptions.DEFAULT); } @NotNull - public static

RpcEndpoint open(@NotNull RequestReplyTransport> transport, @NotNull RpcCodec

codec) - throws RequestReplyException { - return open(transport, codec, RequestReplyOptions.DEFAULT); + public static

RpcEndpoint open(@NotNull RpcTransport

transport, @NotNull RpcCodec

defaultCodec) { + return open(transport, defaultCodec, RequestReplyOptions.DEFAULT); } @NotNull public static

RpcEndpoint open( - @NotNull RequestReplyTransport> transport, - @NotNull RpcCodec

codec, + @NotNull RpcTransport

transport, @NotNull RequestReplyOptions options - ) throws RequestReplyException { - Objects.requireNonNull(codec); - RequestReplyEndpoint> requests = RequestReplyEndpoint.open(transport, options); - try { - return open(requests, codec); - } catch (RuntimeException e) { - try { - requests.close(); - } catch (RuntimeException closeFailure) { - e.addSuppressed(closeFailure); - } - throw e; - } + ) { + return new RpcEndpoint(new State<>(transport, null, options)); + } + + @NotNull + public static

RpcEndpoint open( + @NotNull RpcTransport

transport, + @NotNull RpcCodec

defaultCodec, + @NotNull RequestReplyOptions options + ) { + return new RpcEndpoint(new State<>(transport, Objects.requireNonNull(defaultCodec), options)); } @NotNull public MessageSubscription register(@NotNull String topic, @NotNull Class type, @NotNull T implementation) { - return state.register(topic, type, implementation); + return register(topic, type, implementation, state.defaultCodec()); + } + + @NotNull + public MessageSubscription register( + @NotNull String topic, + @NotNull Class type, + @NotNull T implementation, + @NotNull RpcCodec codec + ) { + return state.register(topic, type, implementation, codec); } @NotNull public T proxy(@NotNull String topic, @NotNull Class type) { - return state.proxy(topic, type); + return proxy(topic, type, state.defaultCodec()); + } + + @NotNull + public T proxy(@NotNull String topic, @NotNull Class type, @NotNull RpcCodec codec) { + return state.proxy(topic, type, codec); } public boolean isClosed() { @@ -93,43 +108,62 @@ public void close() { } private static final class State

{ + private static final int MAX_TOPICS = 64; private final Object lock = new Object(); - private final RequestReplyEndpoint> requests; - private final RpcCodec

codec; - private final MessageSubscription handler; - private final Map services = new HashMap<>(); + private final RpcTransport

transport; + @Nullable + private final RpcCodec

defaultCodec; + private final RequestReplyOptions options; + private final Map topics = new HashMap<>(); private boolean closed; private boolean closeComplete; - private State(@NotNull RequestReplyEndpoint> requests, @NotNull RpcCodec

codec) { - this.requests = Objects.requireNonNull(requests); - this.codec = Objects.requireNonNull(codec); - this.handler = requests.handle(this::dispatch); + private State(@NotNull RpcTransport

transport, @Nullable RpcCodec

defaultCodec, @NotNull RequestReplyOptions options) { + this.transport = Objects.requireNonNull(transport); + this.defaultCodec = defaultCodec; + this.options = Objects.requireNonNull(options); + } + + @NotNull + private RpcCodec

defaultCodec() { + if (defaultCodec == null) { + throw new IllegalStateException("RPC endpoint has no default codec"); + } + return defaultCodec; } @NotNull - private MessageSubscription register(@NotNull String topic, @NotNull Class type, @NotNull T implementation) { - RpcMessage.requireTopic(topic); + private MessageSubscription register( + @NotNull String topic, + @NotNull Class type, + @NotNull T implementation, + @NotNull RpcCodec codec + ) { + requireTopic(topic); + RpcCodec

bindingCodec = bindingCodec(codec); RpcContract contract = RpcContract.create(type); if (!type.isInstance(Objects.requireNonNull(implementation))) { throw new IllegalArgumentException("RPC implementation does not implement its contract"); } contract.requireAccessible(implementation); + Topic selected = topic(topic); synchronized (lock) { requireOpen(); - if (services.containsKey(topic)) { + if (selected.registration != null) { throw new IllegalStateException("RPC topic already has a registered service"); } - ServiceRegistration registration = new ServiceRegistration(topic, contract, implementation); - services.put(topic, registration); + ServiceRegistration registration = new ServiceRegistration(selected, contract, implementation, bindingCodec); + selected.registration = registration; return registration; } } @NotNull - private T proxy(@NotNull String topic, @NotNull Class type) { - RpcMessage.requireTopic(topic); + private T proxy(@NotNull String topic, @NotNull Class type, @NotNull RpcCodec codec) { + requireTopic(topic); + RpcCodec

bindingCodec = bindingCodec(codec); RpcContract contract = RpcContract.create(type); + Topic selected = topic(topic); synchronized (lock) { requireOpen(); } @@ -143,13 +177,18 @@ private T proxy(@NotNull String topic, @NotNull Class type) { }; } Method operation = Objects.requireNonNull(contract.operation(RpcContract.operationId(method))); - return invoke(topic, operation, Objects.requireNonNull(arguments)[0]); + return invoke(selected, operation, Objects.requireNonNull(arguments)[0], bindingCodec); }); return type.cast(proxy); } @Nullable - private Object invoke(@NotNull String topic, @NotNull Method operation, @Nullable Object argument) { + private Object invoke( + @NotNull Topic topic, + @NotNull Method operation, + @Nullable Object argument, + @NotNull RpcCodec

codec + ) { synchronized (lock) { requireOpen(); } @@ -159,13 +198,13 @@ private Object invoke(@NotNull String topic, @NotNull Method operation, @Nullabl RpcMessage

request; try { P payload = codec.encode(argument, operation.getGenericParameterTypes()[0]); - request = RpcMessage.request(topic, RpcContract.operationId(operation), payload); + request = RpcMessage.request(RpcContract.operationId(operation), payload); } catch (Exception e) { throw codecFailure(e); } RpcMessage

response; try { - response = requests.request(request); + response = Objects.requireNonNull(topic.requests).request(request); } catch (RequestReplyException e) { throw new RpcException(e); } catch (InterruptedException e) { @@ -197,7 +236,7 @@ private Object invoke(@NotNull String topic, @NotNull Method operation, @Nullabl } @NotNull - private RpcMessage

dispatch(@NotNull RpcMessage

request) { + private RpcMessage

dispatch(@NotNull Topic topic, @NotNull RpcMessage

request) { if (request.type() != RpcMessage.Type.REQUEST) { return RpcMessage.failure(RpcMessage.Error.INVALID_REQUEST); } @@ -206,7 +245,7 @@ private RpcMessage

dispatch(@NotNull RpcMessage

request) { if (closed) { return RpcMessage.failure(RpcMessage.Error.UNKNOWN_SERVICE); } - registration = services.get(request.topic()); + registration = topic.registration; } if (registration == null) { return RpcMessage.failure(RpcMessage.Error.UNKNOWN_SERVICE); @@ -217,7 +256,7 @@ private RpcMessage

dispatch(@NotNull RpcMessage

request) { } Object argument; try { - argument = codec.decode(request.payload(), operation.getGenericParameterTypes()[0]); + argument = registration.codec.decode(request.payload(), operation.getGenericParameterTypes()[0]); } catch (Exception e) { restoreInterruption(e); return RpcMessage.failure(RpcMessage.Error.CODEC_FAILED); @@ -238,13 +277,114 @@ private RpcMessage

dispatch(@NotNull RpcMessage

request) { return RpcMessage.response(null); } try { - return RpcMessage.response(codec.encode(result, operation.getGenericReturnType())); + return RpcMessage.response(registration.codec.encode(result, operation.getGenericReturnType())); } catch (Exception e) { restoreInterruption(e); return RpcMessage.failure(RpcMessage.Error.CODEC_FAILED); } } + @NotNull + @SuppressWarnings("unchecked") + private RpcCodec

bindingCodec(@NotNull RpcCodec codec) { + return (RpcCodec

) Objects.requireNonNull(codec); + } + + private static void requireTopic(@NotNull String topic) { + Objects.requireNonNull(topic); + if (topic.isBlank() || topic.length() > 256) { + throw new IllegalArgumentException("Expected a nonblank RPC topic of at most 256 characters"); + } + } + + @NotNull + private Topic topic(@NotNull String name) { + Topic selected; + boolean opening = false; + synchronized (lock) { + requireOpen(); + selected = topics.get(name); + if (selected == null) { + if (topics.size() >= MAX_TOPICS) { + throw new RpcException(RpcException.Reason.OVERLOADED); + } + selected = new Topic(name); + topics.put(name, selected); + opening = true; + } + } + if (opening) { + open(selected); + } + try { + Topic ready = selected.result.get(options.timeout().toNanos(), TimeUnit.NANOSECONDS); + synchronized (lock) { + requireOpen(); + } + return ready; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RpcException(RpcException.Reason.INTERRUPTED, e); + } catch (TimeoutException e) { + throw new RpcException(RpcException.Reason.TIMEOUT, e); + } catch (ExecutionException e) { + if (e.getCause() instanceof RpcException failure) { + throw failure; + } + if (e.getCause() instanceof Error failure) { + throw failure; + } + throw new RpcException(RpcException.Reason.SEND_FAILED, e.getCause()); + } + } + + private void open(@NotNull Topic topic) { + synchronized (lock) { + if (closed) { + finish(topic); + return; + } + } + RequestReplyEndpoint> requests = null; + try { + requests = RequestReplyEndpoint.open(Objects.requireNonNull(transport.forTopic(topic.name)), options); + requests.handle(request -> dispatch(topic, request)); + synchronized (lock) { + topic.requests = requests; + topic.opening = false; + if (!closed) { + topic.result.complete(topic); + return; + } + } + } catch (Throwable e) { + if (requests != null) { + try { + requests.close(); + } catch (RuntimeException | Error closeFailure) { + if (closeFailure != e) { + e.addSuppressed(closeFailure); + } + } + } + synchronized (lock) { + finish(topic); + topic.result.completeExceptionally(e instanceof RequestReplyException failure ? new RpcException(failure) : e); + } + return; + } + try { + close(topic); + } catch (RuntimeException e) { + throw new RpcException(RpcException.Reason.CLOSED, e); + } + } + + private void finish(@NotNull Topic topic) { + topics.remove(topic.name, topic); + closeComplete = closed && topics.isEmpty(); + } + @NotNull private static RpcException codecFailure(@NotNull Exception cause) { restoreInterruption(cause); @@ -278,41 +418,94 @@ private boolean isCloseComplete() { } private void close() { + List stopped; synchronized (lock) { if (closed) { return; } closed = true; - services.clear(); + stopped = topics.values().stream().filter(topic -> !topic.opening).toList(); + topics.values().forEach(topic -> { + topic.registration = null; + topic.result.completeExceptionally(new RpcException(RpcException.Reason.CLOSED)); + }); + closeComplete = topics.isEmpty(); } - try { + Throwable failure = null; + for (Topic topic : stopped) { try { - handler.close(); - } finally { - requests.close(); + close(topic); + } catch (RuntimeException | Error e) { + if (failure == null) { + failure = e; + } else if (failure != e) { + failure.addSuppressed(e); + } + } + } + if (failure instanceof RuntimeException exception) { + throw exception; + } + if (failure instanceof Error error) { + throw error; + } + } + + private void close(@NotNull Topic topic) { + synchronized (lock) { + if (topic.closing) { + return; } + topic.closing = true; + } + try { + Objects.requireNonNull(topic.requests).close(); } finally { synchronized (lock) { - closeComplete = true; + finish(topic); } } } + private final class Topic { + private final String name; + private final CompletableFuture result = new CompletableFuture<>(); + @Nullable + private RequestReplyEndpoint> requests; + @Nullable + private ServiceRegistration registration; + private boolean opening = true; + private boolean closing; + + private Topic(@NotNull String name) { + this.name = name; + } + } + private final class ServiceRegistration implements MessageSubscription { - private final String topic; + private final Topic topic; private final RpcContract contract; private final Object implementation; + private final RpcCodec

codec; - private ServiceRegistration(@NotNull String topic, @NotNull RpcContract contract, @NotNull Object implementation) { + private ServiceRegistration( + @NotNull Topic topic, + @NotNull RpcContract contract, + @NotNull Object implementation, + @NotNull RpcCodec

codec + ) { this.topic = topic; this.contract = contract; this.implementation = implementation; + this.codec = codec; } @Override public void close() { synchronized (lock) { - services.remove(topic, this); + if (topic.registration == this) { + topic.registration = null; + } } } } diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java index 05ab574..a001991 100644 --- a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java @@ -23,7 +23,6 @@ public record RpcMessage

( @NotNull Type type, - @Nullable String topic, @Nullable String operation, @Nullable P payload, @Nullable Error error @@ -39,35 +38,27 @@ public enum Error { public RpcMessage { Objects.requireNonNull(type); if (type == Type.REQUEST) { - requireTopic(Objects.requireNonNull(topic)); if (operation == null || operation.isBlank() || operation.length() > 1024 || error != null) { throw new IllegalArgumentException("Invalid RPC request"); } - } else if (topic != null || operation != null || type == Type.ERROR && (error == null || payload != null) + } else if (operation != null || type == Type.ERROR && (error == null || payload != null) || type == Type.RESPONSE && error != null) { throw new IllegalArgumentException("Invalid RPC response"); } } @NotNull - public static

RpcMessage

request(@NotNull String topic, @NotNull String operation, @Nullable P payload) { - return new RpcMessage<>(Type.REQUEST, topic, operation, payload, null); + public static

RpcMessage

request(@NotNull String operation, @Nullable P payload) { + return new RpcMessage<>(Type.REQUEST, operation, payload, null); } @NotNull public static

RpcMessage

response(@Nullable P payload) { - return new RpcMessage<>(Type.RESPONSE, null, null, payload, null); + return new RpcMessage<>(Type.RESPONSE, null, payload, null); } @NotNull public static

RpcMessage

failure(@NotNull Error error) { - return new RpcMessage<>(Type.ERROR, null, null, null, Objects.requireNonNull(error)); - } - - static void requireTopic(@NotNull String topic) { - Objects.requireNonNull(topic); - if (topic.isBlank() || topic.length() > 256) { - throw new IllegalArgumentException("Expected a nonblank RPC topic of at most 256 characters"); - } + return new RpcMessage<>(Type.ERROR, null, null, Objects.requireNonNull(error)); } } diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java new file mode 100644 index 0000000..436ea56 --- /dev/null +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java @@ -0,0 +1,28 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import com.dbeaver.messaging.requestreply.RequestReplyException; +import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import org.jkiss.code.NotNull; + +/** Opens topic-scoped RPC transports for a selected peer. */ +@FunctionalInterface +public interface RpcTransport

{ + @NotNull + RequestReplyTransport> forTopic(@NotNull String topic) throws RequestReplyException; +} diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java index a1c9784..21e546d 100644 --- a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java @@ -39,18 +39,18 @@ public class RpcContractTest extends RpcTestSupport { void rejectsInvalidContractsBeforeSendingAndLeavesTheTopicAvailable() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { for (Class type : List.of( String.class, HiddenService.class, SealedService.class, GenericService.class, RawGenericService.class, EmptyService.class, StaticOnlyService.class, ObjectOnlyService.class, DefaultService.class, VarargsService.class, NoArgumentService.class, TwoArgumentService.class, GenericMethodService.class, FutureService.class, StageService.class, CompletableService.class )) { - assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("service", type), type.getName()); - assertThrows(IllegalArgumentException.class, () -> endpoint.register("service", type, null), type.getName()); + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("service", type, codec), type.getName()); + assertThrows(IllegalArgumentException.class, () -> endpoint.register("service", type, null, codec), type.getName()); } - assertThrows(IllegalArgumentException.class, () -> endpoint.register("service", DefaultService.class, value -> value)); - try (var registration = endpoint.register("service", StaticService.class, value -> value)) { + assertThrows(IllegalArgumentException.class, () -> endpoint.register("service", DefaultService.class, value -> value, codec)); + try (var registration = endpoint.register("service", StaticService.class, value -> value, codec)) { assertTrue(transport.sent.isEmpty()); assertTrue(codec.encoded.isEmpty()); assertTrue(codec.decoded.isEmpty()); @@ -70,10 +70,10 @@ void rejectsGenericParentBridgesWhoseErasedArgumentHasNoConcreteOperation() thro assertTrue(bridge.isSynthetic()); var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { - assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("bridge", GenericBridgeChild.class)); + try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("bridge", GenericBridgeChild.class, codec)); assertThrows(IllegalArgumentException.class, - () -> endpoint.register("bridge", GenericBridgeChild.class, value -> value)); + () -> endpoint.register("bridge", GenericBridgeChild.class, value -> value, codec)); assertTrue(codec.encoded.isEmpty()); assertTrue(codec.decoded.isEmpty()); assertTrue(transport.sent.isEmpty()); @@ -84,10 +84,10 @@ void rejectsGenericParentBridgesWhoseErasedArgumentHasNoConcreteOperation() thro void rejectsUnrelatedParentsWithAmbiguousGenericParameterOrReturnTypesBeforeSending() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { for (Class type : List.of(AmbiguousListCounter.class, AmbiguousListLoader.class)) { - assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("ambiguous", type), type.getName()); - assertThrows(IllegalArgumentException.class, () -> endpoint.register("ambiguous", type, null), type.getName()); + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("ambiguous", type, codec), type.getName()); + assertThrows(IllegalArgumentException.class, () -> endpoint.register("ambiguous", type, null, codec), type.getName()); } assertTrue(codec.encoded.isEmpty()); assertTrue(codec.decoded.isEmpty()); @@ -121,16 +121,16 @@ void explicitChildRedeclarationsResolveGenericTypesForCallsThroughParentAndChild var clientCodec = new TestCodec(); var serverCodec = new TestCodec(); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, clientCodec, OPTIONS); - var server = RpcEndpoint.open(serverTransport, serverCodec, OPTIONS); - var counter = server.register("count", ResolvedListCounter.class, List::size); - var loader = server.register("load", ResolvedListLoader.class, List::of)) { - ResolvedListCounter childCounter = client.proxy("count", ResolvedListCounter.class); + try (var client = RpcEndpoint.open(clientTransport, OPTIONS); + var server = RpcEndpoint.open(serverTransport, OPTIONS); + var counter = server.register("count", ResolvedListCounter.class, List::size, serverCodec); + var loader = server.register("load", ResolvedListLoader.class, List::of, serverCodec)) { + ResolvedListCounter childCounter = client.proxy("count", ResolvedListCounter.class, clientCodec); TypedListCounter typedCounter = childCounter; assertEquals(2, typedCounter.count(List.of("one", "two"))); assertEquals(1, childCounter.count(List.of("child"))); - ResolvedListLoader childLoader = client.proxy("load", ResolvedListLoader.class); + ResolvedListLoader childLoader = client.proxy("load", ResolvedListLoader.class, clientCodec); WideListLoader wideLoader = childLoader; NarrowListLoader narrowLoader = childLoader; assertEquals(List.of("wide"), wideLoader.load("wide")); diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java index 936ee01..ba38990 100644 --- a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java @@ -17,7 +17,7 @@ package com.dbeaver.messaging.rpc; import com.dbeaver.messaging.requestreply.MessageSubscription; -import com.dbeaver.messaging.requestreply.RequestReplyEndpoint; +import com.dbeaver.messaging.requestreply.RequestReplyException; import com.dbeaver.messaging.requestreply.RequestReplyMessage; import com.dbeaver.messaging.requestreply.RequestReplyTransport; import org.jkiss.code.NotNull; @@ -35,6 +35,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -49,8 +50,7 @@ public class RpcEndpointFacadeTest extends RpcTestSupport { void usesTheSameFacadeForTextAndBinaryPayloadRepresentations() throws Exception { var textTransport = new LoopbackTransport(() -> { }); var binaryTransport = new LoopbackTransport(() -> { }); - try (var requests = RequestReplyEndpoint.open(textTransport, OPTIONS); - RpcEndpoint text = RpcEndpoint.open(requests, new TextCodec()); + try (RpcEndpoint text = RpcEndpoint.open(textTransport, new TextCodec()); RpcEndpoint binary = RpcEndpoint.open(binaryTransport, new BinaryCodec(), OPTIONS)) { for (RpcEndpoint endpoint : List.of(text, binary)) { assertFalse(endpoint.isClosed()); @@ -82,6 +82,7 @@ void publishesClosedStateBeforeCleanupCompletesAndAllowsAFreshEndpoint() throws } }); try (RpcEndpoint endpoint = RpcEndpoint.open(transport, new TextCodec(), OPTIONS)) { + endpoint.proxy("text", TextService.class); assertFalse(endpoint.isClosed()); Future closing = async(() -> { endpoint.close(); @@ -115,6 +116,7 @@ void publishesCleanupCompletionEvenWhenTheTransportCleanupFails() throws Excepti throw failure; }); RpcEndpoint endpoint = RpcEndpoint.open(transport, new TextCodec(), OPTIONS); + endpoint.proxy("text", TextService.class); assertSame(failure, assertThrows(IllegalStateException.class, endpoint::close)); @@ -123,6 +125,233 @@ void publishesCleanupCompletionEvenWhenTheTransportCleanupFails() throws Excepti endpoint.close(); } + @Test + void defaultsAndIndependentBindingCodecsNeverReplaceEachOther() { + var transport = new MemoryTransport(); + transport.sender = message -> { + transport.deliver(message); + return CompletableFuture.completedFuture(null); + }; + var fallback = new TestCodec(); + var service = new TestCodec(); + var first = new TestCodec(); + final var second = new TestCodec(); + service.decoder = (value, type) -> "service:" + value; + service.encoder = (value, type) -> "result:" + value; + first.encoder = (value, type) -> "first:" + value; + first.decoder = (value, type) -> "one:" + value; + second.encoder = (value, type) -> "second:" + value; + second.decoder = (value, type) -> "two:" + value; + try (var endpoint = RpcEndpoint.open(transport, fallback, OPTIONS); + var defaultService = endpoint.register("default", TextService.class, value -> value); + var customService = endpoint.register("custom", TextService.class, value -> value, service)) { + TextService defaultProxy = endpoint.proxy("default", TextService.class); + TextService firstProxy = endpoint.proxy("custom", TextService.class, first); + TextService secondProxy = endpoint.proxy("custom", TextService.class, second); + + assertEquals("before", defaultProxy.apply("before")); + assertEquals("one:result:service:first:value", firstProxy.apply("value")); + assertEquals("two:result:service:second:value", secondProxy.apply("value")); + assertEquals("result:service:fallback", endpoint.proxy("custom", TextService.class).apply("fallback")); + assertEquals("after", defaultProxy.apply("after")); + assertEquals(1, first.encoded.size()); + assertEquals(1, first.decoded.size()); + assertEquals(1, second.encoded.size()); + assertEquals(1, second.decoded.size()); + assertEquals(3, service.encoded.size()); + assertEquals(3, service.decoded.size()); + assertEquals(5, fallback.encoded.size()); + assertEquals(5, fallback.decoded.size()); + assertEquals(2, transport.listeners.size()); + } + } + + @Test + void closesEveryTopicWhenOneSubscriptionCleanupFails() { + var closes = new AtomicInteger(); + var failure = new IllegalStateException("Cleanup failed"); + var transport = new LoopbackTransport(() -> { + if (closes.incrementAndGet() == 1) { + throw failure; + } + }); + var endpoint = RpcEndpoint.open(transport, new TextCodec()); + endpoint.proxy("one", TextService.class); + endpoint.proxy("two", TextService.class); + endpoint.proxy("three", TextService.class); + assertSame(failure, assertThrows(IllegalStateException.class, endpoint::close)); + assertEquals(3, closes.get()); + assertTrue(transport.listeners.isEmpty()); + assertTrue(endpoint.isCloseComplete()); + } + + @Test + void missingDefaultFailsBeforeOpeningAnyTopic() { + var openings = new AtomicInteger(); + try (var endpoint = RpcEndpoint.open(topic -> { + openings.incrementAndGet(); + return new MemoryTransport().forTopic(topic); + })) { + IllegalStateException proxyFailure = assertThrows(IllegalStateException.class, + () -> endpoint.proxy("text", TextService.class)); + assertTrue(proxyFailure.getMessage().contains("default codec")); + assertThrows(IllegalStateException.class, () -> endpoint.register("text", TextService.class, value -> value)); + assertEquals(0, openings.get()); + } + } + + @Test + void incompatibleBindingPayloadsFailThroughTheCodecErrorBoundary() { + var transport = new LoopbackTransport(() -> { }); + try (var endpoint = RpcEndpoint.open(transport, new TextCodec()); + var registration = endpoint.register("text", TextService.class, value -> value)) { + remoteFailure(RpcMessage.Error.CODEC_FAILED, + () -> endpoint.proxy("text", TextService.class, new BinaryCodec()).apply("incompatible")); + assertEquals("compatible", endpoint.proxy("text", TextService.class).apply("compatible")); + } + } + + @Test + void concurrentBindingsShareOneOpeningWithoutBlockingOtherTopics() throws Exception { + var transport = new MemoryTransport(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var openings = new AtomicInteger(); + try (var endpoint = RpcEndpoint.open(topic -> { + openings.incrementAndGet(); + if (topic.equals("blocked")) { + entered.countDown(); + try { + await(release); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, e); + } + } + return transport.forTopic(topic); + }, OPTIONS)) { + Future first = async(() -> endpoint.proxy("blocked", TextService.class, new TestCodec())); + try { + await(entered); + final Future second = async(() -> endpoint.proxy("blocked", TextService.class, new TestCodec())); + endpoint.proxy("independent", TextService.class, new TestCodec()); + assertEquals(2, openings.get()); + release.countDown(); + assertFalse(result(first).equals(result(second))); + assertEquals(2, openings.get()); + assertEquals(2, transport.listeners.size()); + } finally { + release.countDown(); + } + } + } + + @Test + void closeCompletionWaitsForLateOpeningAndItsSubscriptionRollback() throws Exception { + var opening = new CountDownLatch(1); + var releaseOpening = new CountDownLatch(1); + var cleanup = new CountDownLatch(1); + var releaseCleanup = new CountDownLatch(1); + var closes = new AtomicInteger(); + var transport = new LoopbackTransport(() -> { + closes.incrementAndGet(); + cleanup.countDown(); + try { + await(releaseCleanup); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + }); + try (var endpoint = RpcEndpoint.open(topic -> { + opening.countDown(); + try { + await(releaseOpening); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, e); + } + return transport.forTopic(topic); + }, OPTIONS)) { + Future pending = async(() -> endpoint.proxy("text", TextService.class, new TextCodec())); + try { + await(opening); + endpoint.close(); + assertTrue(endpoint.isClosed()); + assertFalse(endpoint.isCloseComplete()); + releaseOpening.countDown(); + await(cleanup); + endpoint.close(); + assertFalse(endpoint.isCloseComplete()); + releaseCleanup.countDown(); + failure(RpcException.Reason.CLOSED, pending); + assertTrue(endpoint.isCloseComplete()); + assertEquals(1, closes.get()); + assertTrue(transport.listeners.isEmpty()); + } finally { + releaseOpening.countDown(); + releaseCleanup.countDown(); + } + } + } + + @Test + void boundsCachedTopicsWithoutReservingDefaultServiceNames() { + var transport = new MemoryTransport(); + try (var endpoint = RpcEndpoint.open(transport, new TestCodec())) { + for (int index = 0; index < 64; index++) { + try (var registration = endpoint.register("topic." + index, TextService.class, value -> value)) { + endpoint.proxy("topic." + index, TextService.class); + } + } + assertEquals(64, transport.listeners.size()); + failure(RpcException.Reason.OVERLOADED, () -> endpoint.proxy("account", TextService.class)); + failure(RpcException.Reason.OVERLOADED, () -> endpoint.register("storage", TextService.class, value -> value)); + endpoint.proxy("topic.0", TextService.class); + assertEquals(64, transport.listeners.size()); + } + assertEquals(64, transport.subscriptionCloses.get()); + } + + @Test + void topicCapacityIncludesOpeningsUntilTheirLateRollbackCompletes() throws Exception { + var transport = new MemoryTransport(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var openings = new AtomicInteger(); + try (var endpoint = RpcEndpoint.open(topic -> { + openings.incrementAndGet(); + if (topic.equals("reserved")) { + entered.countDown(); + try { + await(release); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, e); + } + } + return transport.forTopic(topic); + }, new TestCodec(), OPTIONS)) { + for (int index = 0; index < 63; index++) { + endpoint.proxy("topic." + index, TextService.class); + } + Future pending = async(() -> endpoint.proxy("reserved", TextService.class)); + try { + await(entered); + failure(RpcException.Reason.OVERLOADED, () -> endpoint.proxy("excess", TextService.class)); + assertEquals(64, openings.get()); + endpoint.close(); + assertFalse(endpoint.isCloseComplete()); + release.countDown(); + failure(RpcException.Reason.CLOSED, pending); + assertTrue(endpoint.isCloseComplete()); + assertEquals(64, transport.subscriptionCloses.get()); + } finally { + release.countDown(); + } + } + } + /** Exchanges nullable text values. */ public interface TextService { @Nullable @@ -157,8 +386,9 @@ public Object decode(@Nullable byte[] payload, @NotNull Type type) { } } - private static final class LoopbackTransport

implements RequestReplyTransport> { - private final Map>> listeners = new ConcurrentHashMap<>(); + private static final class LoopbackTransport

implements RpcTransport

{ + private final Map>> listeners = new ConcurrentHashMap<>(); + private final Map topics = new ConcurrentHashMap<>(); private final Runnable onClose; private LoopbackTransport(@NotNull Runnable onClose) { @@ -167,19 +397,28 @@ private LoopbackTransport(@NotNull Runnable onClose) { @NotNull @Override - public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) { - listeners.put(endpointId, listener); - return () -> { - listeners.remove(endpointId, listener); - onClose.run(); - }; - } + public RequestReplyTransport> forTopic(@NotNull String topic) { + return new RequestReplyTransport<>() { + @NotNull + @Override + public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) { + listeners.put(endpointId, listener); + topics.put(endpointId, topic); + return () -> { + listeners.remove(endpointId, listener); + topics.remove(endpointId); + onClose.run(); + }; + } - @NotNull - @Override - public CompletionStage send(@NotNull RequestReplyMessage> message) { - List.copyOf(listeners.values()).forEach(listener -> listener.onMessage(message)); - return CompletableFuture.completedFuture(null); + @NotNull + @Override + public CompletionStage send(@NotNull RequestReplyMessage> message) { + List.copyOf(listeners.entrySet()).stream().filter(entry -> topic.equals(topics.get(entry.getKey()))) + .forEach(entry -> entry.getValue().onMessage(message)); + return CompletableFuture.completedFuture(null); + } + }; } } } diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java index 6ccd64a..a548c52 100644 --- a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java @@ -60,22 +60,22 @@ void servesIndependentServicesInBothDirectionsAndAcrossTopics() throws Exception var leftTransport = new MemoryTransport(); var rightTransport = new MemoryTransport(); connect(leftTransport, rightTransport); - try (var left = RpcEndpoint.open(leftTransport, new TestCodec()); - var right = RpcEndpoint.open(rightTransport, new TestCodec(), OPTIONS); - var leftService = left.register("echo", EchoService.class, value -> "left:" + value); - var rightService = right.register("echo", EchoService.class, value -> "right:" + value); - var otherService = right.register("other", EchoService.class, value -> "other:" + value)) { - EchoService fromLeft = left.proxy("echo", EchoService.class); - EchoService fromRight = right.proxy("echo", EchoService.class); + try (var left = RpcEndpoint.open(leftTransport); + var right = RpcEndpoint.open(rightTransport, OPTIONS); + var leftService = left.register("echo", EchoService.class, value -> "left:" + value, new TestCodec()); + var rightService = right.register("echo", EchoService.class, value -> "right:" + value, new TestCodec()); + var otherService = right.register("other", EchoService.class, value -> "other:" + value, new TestCodec())) { + EchoService fromLeft = left.proxy("echo", EchoService.class, new TestCodec()); + EchoService fromRight = right.proxy("echo", EchoService.class, new TestCodec()); assertEquals("right:first", fromLeft.echo("first")); assertEquals("left:second", fromRight.echo("second")); - assertEquals("other:third", left.proxy("other", EchoService.class).echo("third")); - assertEquals(1, leftTransport.listeners.size()); - assertEquals(1, rightTransport.listeners.size()); + assertEquals("other:third", left.proxy("other", EchoService.class, new TestCodec()).echo("third")); + assertEquals(2, leftTransport.listeners.size()); + assertEquals(2, rightTransport.listeners.size()); } - assertEquals(1, leftTransport.subscriptionCloses.get()); - assertEquals(1, rightTransport.subscriptionCloses.get()); + assertEquals(2, leftTransport.subscriptionCloses.get()); + assertEquals(2, rightTransport.subscriptionCloses.get()); } @Test @@ -85,11 +85,11 @@ void passesDeclaredParameterizedTypesAndNullValuesThroughEveryCodecBoundary() th var clientCodec = new TestCodec(); var serverCodec = new TestCodec(); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, clientCodec, OPTIONS); - var server = RpcEndpoint.open(serverTransport, serverCodec, OPTIONS); + try (var client = RpcEndpoint.open(clientTransport, OPTIONS); + var server = RpcEndpoint.open(serverTransport, OPTIONS); var registration = server.register("collection", CollectionService.class, - values -> values == null ? null : Map.of("size", values.size()))) { - CollectionService proxy = client.proxy("collection", CollectionService.class); + values -> values == null ? null : Map.of("size", values.size()), serverCodec)) { + CollectionService proxy = client.proxy("collection", CollectionService.class, clientCodec); var values = new ArrayList<>(List.of("one", "two")); Map expected = Map.of("size", 2); @@ -105,7 +105,7 @@ void passesDeclaredParameterizedTypesAndNullValuesThroughEveryCodecBoundary() th assertEquals(serverCodec.encoded, clientCodec.decoded); assertEquals("count(java.util.List)", clientTransport.next().payload().operation()); assertNull(clientTransport.next().payload().payload()); - assertNull(serverTransport.next().payload().topic()); + assertEquals(RpcMessage.Type.RESPONSE, serverTransport.next().payload().type()); assertEquals(RpcMessage.response(null), serverTransport.next().payload()); } } @@ -117,10 +117,10 @@ void supportsEveryPrimitiveWithoutChangingItsDeclaredCodecType() throws Exceptio var clientCodec = new TestCodec(); var serverCodec = new TestCodec(); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, clientCodec, OPTIONS); - var server = RpcEndpoint.open(serverTransport, serverCodec, OPTIONS); - var registration = server.register("primitive", PrimitiveService.class, new PrimitiveEcho())) { - PrimitiveService proxy = client.proxy("primitive", PrimitiveService.class); + try (var client = RpcEndpoint.open(clientTransport, OPTIONS); + var server = RpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("primitive", PrimitiveService.class, new PrimitiveEcho(), serverCodec)) { + PrimitiveService proxy = client.proxy("primitive", PrimitiveService.class, clientCodec); assertTrue(proxy.echo(true)); assertEquals((byte) 12, proxy.echo((byte) 12)); @@ -168,10 +168,10 @@ public String inherited(@NotNull Long value) { return "inherited:" + value; } }; - try (var client = RpcEndpoint.open(clientTransport, clientCodec, OPTIONS); - var server = RpcEndpoint.open(serverTransport, serverCodec, OPTIONS); - var registration = server.register("overload", OverloadedService.class, implementation)) { - OverloadedService proxy = client.proxy("overload", OverloadedService.class); + try (var client = RpcEndpoint.open(clientTransport, OPTIONS); + var server = RpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("overload", OverloadedService.class, implementation, serverCodec)) { + OverloadedService proxy = client.proxy("overload", OverloadedService.class, clientCodec); BaseService base = proxy; assertEquals("text:value", base.convert("value")); @@ -199,10 +199,10 @@ void invokesAnOperationInheritedFromANonPublicParentInAnotherPackage() throws Ex var clientTransport = new MemoryTransport(); var serverTransport = new MemoryTransport(); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, new TestCodec(), OPTIONS); - var server = RpcEndpoint.open(serverTransport, new TestCodec(), OPTIONS); - var registration = server.register("inherited", PublicInheritedService.class, implementation)) { - PublicInheritedService proxy = client.proxy("inherited", PublicInheritedService.class); + try (var client = RpcEndpoint.open(clientTransport, OPTIONS); + var server = RpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("inherited", PublicInheritedService.class, implementation, new TestCodec())) { + PublicInheritedService proxy = client.proxy("inherited", PublicInheritedService.class, new TestCodec()); assertEquals("inherited:value", proxy.inherited("value")); assertEquals("inherited(java.lang.String)", clientTransport.next().payload().operation()); @@ -219,16 +219,16 @@ void voidCallsWaitForHandlerCompletionAndDoNotEncodeOrDecodeAResult() throws Exc var entered = new CountDownLatch(1); var release = new CountDownLatch(1); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, clientCodec, OPTIONS); - var server = RpcEndpoint.open(serverTransport, serverCodec, OPTIONS); + try (var client = RpcEndpoint.open(clientTransport, OPTIONS); + var server = RpcEndpoint.open(serverTransport, OPTIONS); var registration = server.register("void", VoidService.class, value -> { assertEquals("work", value); entered.countDown(); await(release); - })) { + }, serverCodec)) { try { Future pending = async(() -> { - client.proxy("void", VoidService.class).accept("work"); + client.proxy("void", VoidService.class, clientCodec).accept("work"); return null; }); await(entered); @@ -255,8 +255,8 @@ void reportsHandlerFailuresWithoutLeakingPrivateDetailsOrRetrying() throws Excep var serverTransport = new MemoryTransport(); var calls = new AtomicInteger(); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, new TestCodec(), OPTIONS); - var server = RpcEndpoint.open(serverTransport, new TestCodec(), OPTIONS)) { + try (var client = RpcEndpoint.open(clientTransport, OPTIONS); + var server = RpcEndpoint.open(serverTransport, OPTIONS)) { for (Throwable cause : List.of(new Exception("private checked detail"), new IllegalStateException("private runtime detail"), new AssertionError("private error detail"))) { try (var registration = server.register("failure", FailingService.class, value -> { @@ -265,9 +265,9 @@ void reportsHandlerFailuresWithoutLeakingPrivateDetailsOrRetrying() throws Excep throw exception; } throw (Error) cause; - })) { + }, new TestCodec())) { RpcException failure = remoteFailure(RpcMessage.Error.HANDLER_FAILED, - () -> client.proxy("failure", FailingService.class).fail("secret argument")); + () -> client.proxy("failure", FailingService.class, new TestCodec()).fail("secret argument")); assertFalse(failure.toString().contains("private")); assertFalse(failure.toString().contains("secret")); @@ -301,13 +301,13 @@ void distinguishesLocalAndRemoteCodecFailuresAtAllFourBoundariesAndRecovers() th default -> throw new AssertionError("Unexpected codec boundary"); } connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, clientCodec, OPTIONS); - var server = RpcEndpoint.open(serverTransport, serverCodec, OPTIONS); + try (var client = RpcEndpoint.open(clientTransport, OPTIONS); + var server = RpcEndpoint.open(serverTransport, OPTIONS); var registration = server.register("echo", EchoService.class, value -> { calls.incrementAndGet(); return value; - })) { - EchoService proxy = client.proxy("echo", EchoService.class); + }, serverCodec)) { + EchoService proxy = client.proxy("echo", EchoService.class, clientCodec); if (boundary == 0 || boundary == 3) { RpcException failure = failure(RpcException.Reason.CODEC_FAILED, () -> proxy.echo("secret argument")); assertSame(cause, failure.getCause()); @@ -337,22 +337,23 @@ void rejectsPrimitiveNullsAndWrongDecodedArgumentTypesBeforeInvokingAService() t var codec = new TestCodec(); var calls = new AtomicInteger(); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, new TestCodec(), OPTIONS); - var server = RpcEndpoint.open(serverTransport, codec, OPTIONS); + try (var client = RpcEndpoint.open(clientTransport, OPTIONS); + var server = RpcEndpoint.open(serverTransport, OPTIONS); var references = server.register("reference", EchoService.class, value -> { calls.incrementAndGet(); return value; - }); + }, codec); var primitives = server.register("primitive", IntService.class, value -> { calls.incrementAndGet(); return value; - })) { + }, codec)) { for (Object invalid : Arrays.asList(null, "wrong primitive", 1L)) { codec.decoder = (value, type) -> invalid; - remoteFailure(RpcMessage.Error.INVALID_REQUEST, () -> client.proxy("primitive", IntService.class).echo(1)); + remoteFailure(RpcMessage.Error.INVALID_REQUEST, () -> client.proxy("primitive", IntService.class, new TestCodec()).echo(1)); } codec.decoder = (value, type) -> 42; - remoteFailure(RpcMessage.Error.INVALID_REQUEST, () -> client.proxy("reference", EchoService.class).echo("value")); + remoteFailure(RpcMessage.Error.INVALID_REQUEST, + () -> client.proxy("reference", EchoService.class, new TestCodec()).echo("value")); assertEquals(0, calls.get()); assertTrue(codec.encoded.isEmpty()); @@ -365,39 +366,37 @@ void rejectsPrimitiveNullsAndWrongDecodedResultTypesAsInvalidResponses() throws var serverTransport = new MemoryTransport(); var codec = new TestCodec(); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, codec, OPTIONS); - var server = RpcEndpoint.open(serverTransport, new TestCodec(), OPTIONS); - var references = server.register("reference", EchoService.class, value -> value); - var primitives = server.register("primitive", IntService.class, value -> value)) { + try (var client = RpcEndpoint.open(clientTransport, OPTIONS); + var server = RpcEndpoint.open(serverTransport, OPTIONS); + var references = server.register("reference", EchoService.class, value -> value, new TestCodec()); + var primitives = server.register("primitive", IntService.class, value -> value, new TestCodec())) { for (Object invalid : Arrays.asList(null, "wrong primitive", 1L)) { codec.decoder = (value, type) -> invalid; RpcException failure = failure(RpcException.Reason.INVALID_RESPONSE, - () -> client.proxy("primitive", IntService.class).echo(1)); + () -> client.proxy("primitive", IntService.class, codec).echo(1)); assertNull(failure.remoteError()); } codec.decoder = (value, type) -> 42; - failure(RpcException.Reason.INVALID_RESPONSE, () -> client.proxy("reference", EchoService.class).echo("value")); + failure(RpcException.Reason.INVALID_RESPONSE, () -> client.proxy("reference", EchoService.class, codec).echo("value")); } } @Test - void rejectsUnknownTopicsUnknownOperationsAndNonRequestEnvelopesBeforeDecoding() throws Exception { + void rejectsUnknownOperationsAndNonRequestEnvelopesBeforeDecoding() throws Exception { var clientTransport = new MemoryTransport(); var serverTransport = new MemoryTransport(); var codec = new TestCodec(); var calls = new AtomicInteger(); connect(clientTransport, serverTransport); - try (var client = RequestReplyEndpoint.open(clientTransport, OPTIONS); - var server = RpcEndpoint.open(serverTransport, codec, OPTIONS); + try (var client = RequestReplyEndpoint.open(clientTransport.forTopic("echo"), OPTIONS); + var server = RpcEndpoint.open(serverTransport, OPTIONS); var registration = server.register("echo", EchoService.class, value -> { calls.incrementAndGet(); return value; - })) { - assertEquals(RpcMessage.failure(RpcMessage.Error.UNKNOWN_SERVICE), - client.request(RpcMessage.request("missing", "echo(java.lang.String)", "value"))); + }, codec)) { for (String operation : List.of("missing(java.lang.String)", "echo(int)")) { assertEquals(RpcMessage.failure(RpcMessage.Error.UNKNOWN_OPERATION), - client.request(RpcMessage.request("echo", operation, "value"))); + client.request(RpcMessage.request(operation, "value"))); } for (RpcMessage invalid : List.of(RpcMessage.response("not a request"), RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED))) { @@ -413,9 +412,9 @@ void rejectsUnknownTopicsUnknownOperationsAndNonRequestEnvelopesBeforeDecoding() void rejectsMissingResponseEnvelopesRequestsUsedAsRepliesAndNonemptyVoidReplies() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { - EchoService proxy = endpoint.proxy("echo", EchoService.class); - List> invalid = Arrays.asList(null, RpcMessage.request("echo", "echo(java.lang.String)", "value")); + try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class, codec); + List> invalid = Arrays.asList(null, RpcMessage.request("echo(java.lang.String)", "value")); for (RpcMessage reply : invalid) { transport.sender = request -> { transport.deliver(request.response(reply)); @@ -427,7 +426,7 @@ void rejectsMissingResponseEnvelopesRequestsUsedAsRepliesAndNonemptyVoidReplies( transport.deliver(request.response(RpcMessage.response("unexpected void payload"))); return CompletableFuture.completedFuture(null); }; - failure(RpcException.Reason.INVALID_RESPONSE, () -> endpoint.proxy("void", VoidService.class).accept("value")); + failure(RpcException.Reason.INVALID_RESPONSE, () -> endpoint.proxy("void", VoidService.class, codec).accept("value")); assertTrue(codec.decoded.isEmpty()); assertEquals(3, transport.sent.size()); } @@ -439,33 +438,38 @@ void scopesRegistrationHandlesByIdentityAndLetsAcceptedCallsFinishAfterUnregiste var serverTransport = new MemoryTransport(); var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var originalCodec = new TestCodec(); + var replacementCodec = new TestCodec(); + originalCodec.encoder = (value, type) -> "old-codec:" + value; + replacementCodec.encoder = (value, type) -> "new-codec:" + value; connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, new TestCodec(), OPTIONS); - var server = RpcEndpoint.open(serverTransport, new TestCodec(), OPTIONS); - var other = server.register("other", EchoService.class, value -> "other:" + value); + try (var client = RpcEndpoint.open(clientTransport, OPTIONS); + var server = RpcEndpoint.open(serverTransport, OPTIONS); + var other = server.register("other", EchoService.class, value -> "other:" + value, new TestCodec()); var original = server.register("echo", EchoService.class, value -> { entered.countDown(); await(release); return "original:" + value; - })) { + }, originalCodec)) { try { - EchoService proxy = client.proxy("echo", EchoService.class); - assertThrows(IllegalStateException.class, () -> server.register("echo", EchoService.class, value -> value)); + EchoService proxy = client.proxy("echo", EchoService.class, new TestCodec()); + assertThrows(IllegalStateException.class, + () -> server.register("echo", EchoService.class, value -> value, new TestCodec())); Future accepted = async(() -> proxy.echo("accepted")); await(entered); original.close(); remoteFailure(RpcMessage.Error.UNKNOWN_SERVICE, () -> proxy.echo("unregistered")); - try (var replacement = server.register("echo", EchoService.class, value -> "replacement:" + value)) { + try (var replacement = server.register("echo", EchoService.class, value -> "replacement:" + value, replacementCodec)) { original.close(); - assertEquals("replacement:next", proxy.echo("next")); - assertEquals("other:next", client.proxy("other", EchoService.class).echo("next")); + assertEquals("new-codec:replacement:next", proxy.echo("next")); + assertEquals("other:next", client.proxy("other", EchoService.class, new TestCodec()).echo("next")); release.countDown(); - assertEquals("original:accepted", result(accepted)); + assertEquals("old-codec:original:accepted", result(accepted)); } remoteFailure(RpcMessage.Error.UNKNOWN_SERVICE, () -> proxy.echo("removed replacement")); - assertEquals("other:surviving", client.proxy("other", EchoService.class).echo("surviving")); - assertEquals(1, serverTransport.listeners.size()); + assertEquals("other:surviving", client.proxy("other", EchoService.class, new TestCodec()).echo("surviving")); + assertEquals(2, serverTransport.listeners.size()); assertEquals(0, serverTransport.subscriptionCloses.get()); } finally { release.countDown(); @@ -474,12 +478,11 @@ void scopesRegistrationHandlesByIdentityAndLetsAcceptedCallsFinishAfterUnregiste } @Test - void closingAnEndpointFailsPendingCallsAndClosesTheSuccessfullyBorrowedRequestEndpoint() throws Exception { + void closingAnEndpointFailsPendingCallsAndClosesItsTopicSubscriptions() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var requests = RequestReplyEndpoint.open(transport, OPTIONS); - var endpoint = RpcEndpoint.open(requests, codec)) { - EchoService proxy = endpoint.proxy("echo", EchoService.class); + try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class, codec); Future pending = async(() -> proxy.echo("pending")); transport.next(); @@ -488,11 +491,8 @@ void closingAnEndpointFailsPendingCallsAndClosesTheSuccessfullyBorrowedRequestEn RpcException failure = failure(RpcException.Reason.CLOSED, pending); assertNull(failure.remoteError()); failure(RpcException.Reason.CLOSED, () -> proxy.echo("closed")); - failure(RpcException.Reason.CLOSED, () -> endpoint.proxy("echo", EchoService.class)); - failure(RpcException.Reason.CLOSED, () -> endpoint.register("echo", EchoService.class, value -> value)); - RequestReplyException closed = assertThrows(RequestReplyException.class, - () -> requests.request(RpcMessage.request("echo", "echo(java.lang.String)", "closed"))); - assertEquals(RequestReplyException.Reason.CLOSED, closed.reason()); + failure(RpcException.Reason.CLOSED, () -> endpoint.proxy("echo", EchoService.class, codec)); + failure(RpcException.Reason.CLOSED, () -> endpoint.register("echo", EchoService.class, value -> value, codec)); assertTrue(transport.listeners.isEmpty()); assertTrue(transport.sent.isEmpty()); assertEquals(1, transport.subscriptionCloses.get()); @@ -503,27 +503,29 @@ void closingAnEndpointFailsPendingCallsAndClosesTheSuccessfullyBorrowedRequestEn } @Test - void failedBorrowedOpenPreservesTheExistingHandlerAndSubscription() throws Exception { - var clientTransport = new MemoryTransport(); - var serverTransport = new MemoryTransport(); - connect(clientTransport, serverTransport); - try (var client = RequestReplyEndpoint.open(clientTransport, OPTIONS); - var requests = RequestReplyEndpoint.open(serverTransport, OPTIONS); - var handler = requests.handle(message -> RpcMessage.response("existing handler"))) { - assertThrows(IllegalStateException.class, () -> RpcEndpoint.open(requests, new TestCodec())); - assertThrows(NullPointerException.class, () -> RpcEndpoint.open(requests, null)); - - assertEquals(RpcMessage.response("existing handler"), client.request(RpcMessage.request("echo", "echo(int)", 1))); - assertEquals(1, serverTransport.listeners.size()); - assertEquals(0, serverTransport.subscriptionCloses.get()); + void failedTopicOpeningReleasesItsReservationAndAllowsRetry() throws Exception { + var transport = new MemoryTransport(); + var attempts = new AtomicInteger(); + var expected = new RequestReplyException(RequestReplyException.Reason.DISCONNECTED); + try (var endpoint = RpcEndpoint.open(topic -> { + if (attempts.getAndIncrement() == 0) { + throw expected; + } + return transport.forTopic(topic); + }, OPTIONS)) { + assertSame(expected, + failure(RpcException.Reason.DISCONNECTED, () -> endpoint.proxy("echo", EchoService.class, new TestCodec())).getCause()); + endpoint.proxy("echo", EchoService.class, new TestCodec()); + assertEquals(2, attempts.get()); + assertEquals(1, transport.listeners.size()); } } @Test void disconnectFailsPendingCallsAndFreshCallsDoNotReplayThem() throws Exception { var transport = new MemoryTransport(); - try (var endpoint = RpcEndpoint.open(transport, new TestCodec(), OPTIONS)) { - EchoService proxy = endpoint.proxy("echo", EchoService.class); + try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class, new TestCodec()); Future pending = async(() -> proxy.echo("old")); final var oldRequest = transport.next(); @@ -550,8 +552,9 @@ void usesTheConfiguredTimeoutWithoutRetryingTheEncodedRequest() throws Exception var transport = new MemoryTransport(); var codec = new TestCodec(); var options = new RequestReplyOptions(Duration.ofMillis(100), 1, 1); - try (var endpoint = RpcEndpoint.open(transport, codec, options)) { - RpcException failure = failure(RpcException.Reason.TIMEOUT, () -> endpoint.proxy("echo", EchoService.class).echo("expire")); + try (var endpoint = RpcEndpoint.open(transport, options)) { + RpcException failure = failure(RpcException.Reason.TIMEOUT, + () -> endpoint.proxy("echo", EchoService.class, codec).echo("expire")); assertNull(failure.remoteError()); assertEquals("expire", transport.next().payload().payload()); @@ -567,8 +570,8 @@ void mapsSendFailureAndPendingOverloadWithoutSendingAnExtraRequest() throws Exce var cause = new IllegalStateException("send failed"); var options = new RequestReplyOptions(WAIT, 1, 1); transport.sender = request -> CompletableFuture.failedFuture(cause); - try (var endpoint = RpcEndpoint.open(transport, new TestCodec(), options)) { - EchoService proxy = endpoint.proxy("echo", EchoService.class); + try (var endpoint = RpcEndpoint.open(transport, options)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class, new TestCodec()); RpcException failed = failure(RpcException.Reason.SEND_FAILED, () -> proxy.echo("failed")); assertSame(cause, assertInstanceOf(RequestReplyException.class, failed.getCause()).getCause()); assertNull(failed.remoteError()); @@ -592,8 +595,9 @@ void mapsRequestReplyRemoteFailuresWithoutInventingAnRpcErrorCode() throws Excep transport.deliver(request.failure(RequestReplyMessage.Error.OVERLOADED)); return CompletableFuture.completedFuture(null); }; - try (var endpoint = RpcEndpoint.open(transport, new TestCodec(), OPTIONS)) { - RpcException failure = failure(RpcException.Reason.REMOTE_ERROR, () -> endpoint.proxy("echo", EchoService.class).echo("value")); + try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + RpcException failure = failure(RpcException.Reason.REMOTE_ERROR, + () -> endpoint.proxy("echo", EchoService.class, new TestCodec()).echo("value")); assertNull(failure.remoteError()); assertEquals(RequestReplyMessage.Error.OVERLOADED, assertInstanceOf(RequestReplyException.class, failure.getCause()).remoteError()); @@ -604,8 +608,8 @@ void mapsRequestReplyRemoteFailuresWithoutInventingAnRpcErrorCode() throws Excep @Test void restoresTheCallerInterruptFlagWhenAPendingRequestIsInterrupted() throws Exception { var transport = new MemoryTransport(); - try (var endpoint = RpcEndpoint.open(transport, new TestCodec(), OPTIONS)) { - EchoService proxy = endpoint.proxy("echo", EchoService.class); + try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class, new TestCodec()); var pending = new FutureTask<>(() -> { RpcException failure = failure(RpcException.Reason.INTERRUPTED, () -> proxy.echo("interrupt")); assertInstanceOf(InterruptedException.class, failure.getCause()); @@ -626,8 +630,8 @@ void restoresTheCallerInterruptFlagWhenAPendingRequestIsInterrupted() throws Exc void alreadyInterruptedCallersKeepTheirFlagWithoutInvokingTheCodecOrSending() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { - EchoService proxy = endpoint.proxy("echo", EchoService.class); + try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class, codec); Future flag = async(() -> { Thread.currentThread().interrupt(); try { @@ -662,9 +666,9 @@ void restoresTheCallerInterruptFlagWhenEitherLocalCodecBoundaryIsInterrupted() t transport.deliver(request.response(RpcMessage.response("value"))); return CompletableFuture.completedFuture(null); }; - try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { Future flag = async(() -> { - failure(RpcException.Reason.INTERRUPTED, () -> endpoint.proxy("echo", EchoService.class).echo("value")); + failure(RpcException.Reason.INTERRUPTED, () -> endpoint.proxy("echo", EchoService.class, codec).echo("value")); return Thread.currentThread().isInterrupted(); }); @@ -692,15 +696,15 @@ void processesParallelCallsAndConcurrentDecodesWithOutOfOrderCompletion() throws return value; }; connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, new TestCodec(), OPTIONS); - var server = RpcEndpoint.open(serverTransport, serverCodec, OPTIONS); + try (var client = RpcEndpoint.open(clientTransport, OPTIONS); + var server = RpcEndpoint.open(serverTransport, OPTIONS); var registration = server.register("echo", EchoService.class, value -> { entered.countDown(); await(release.get(value)); return "reply:" + value; - })) { + }, serverCodec)) { try { - EchoService proxy = client.proxy("echo", EchoService.class); + EchoService proxy = client.proxy("echo", EchoService.class, new TestCodec()); var results = new LinkedHashMap>(); for (String value : release.keySet()) { results.put(value, async(() -> proxy.echo(value))); @@ -734,9 +738,9 @@ void processesParallelCallsAndConcurrentDecodesWithOutOfOrderCompletion() throws void objectMethodsRemainLocalAndUseProxyIdentityEvenAfterClosing() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { - EchoService first = endpoint.proxy("echo", EchoService.class); - EchoService second = endpoint.proxy("echo", EchoService.class); + try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + EchoService first = endpoint.proxy("echo", EchoService.class, codec); + EchoService second = endpoint.proxy("echo", EchoService.class, codec); assertTrue(first.equals(first)); assertFalse(first.equals(second)); @@ -761,21 +765,23 @@ void objectMethodsRemainLocalAndUseProxyIdentityEvenAfterClosing() throws Except void rejectsInvalidTopicsNullArgumentsAndOptionsBeforeSubscribingOrSending() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); + assertThrows(NullPointerException.class, () -> RpcEndpoint.open(transport, (RequestReplyOptions) null)); assertThrows(NullPointerException.class, () -> RpcEndpoint.open(transport, null, OPTIONS)); - assertThrows(NullPointerException.class, () -> RpcEndpoint.open(transport, codec, null)); - assertThrows(NullPointerException.class, () -> RpcEndpoint.open((RequestReplyTransport>) null, codec, OPTIONS)); - assertThrows(NullPointerException.class, () -> RpcEndpoint.open((RequestReplyEndpoint>) null, codec)); + assertThrows(NullPointerException.class, () -> RpcEndpoint.open(null, OPTIONS)); assertTrue(transport.listeners.isEmpty()); - try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { for (String topic : List.of("", " ", "\n", "t".repeat(257))) { - assertThrows(IllegalArgumentException.class, () -> endpoint.proxy(topic, EchoService.class)); - assertThrows(IllegalArgumentException.class, () -> endpoint.register(topic, EchoService.class, value -> value)); + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy(topic, EchoService.class, codec)); + assertThrows(IllegalArgumentException.class, () -> endpoint.register(topic, EchoService.class, value -> value, codec)); } - assertThrows(NullPointerException.class, () -> endpoint.proxy(null, EchoService.class)); - assertThrows(NullPointerException.class, () -> endpoint.proxy("echo", null)); - assertThrows(NullPointerException.class, () -> endpoint.register(null, EchoService.class, value -> value)); - assertThrows(NullPointerException.class, () -> endpoint.register("echo", EchoService.class, null)); + assertThrows(NullPointerException.class, () -> endpoint.proxy(null, EchoService.class, codec)); + assertThrows(NullPointerException.class, () -> endpoint.proxy("echo", null, codec)); + assertThrows(NullPointerException.class, () -> endpoint.proxy("echo", EchoService.class, null)); + assertThrows(NullPointerException.class, () -> endpoint.register(null, EchoService.class, value -> value, codec)); + assertThrows(NullPointerException.class, () -> endpoint.register("echo", EchoService.class, null, codec)); + assertThrows(NullPointerException.class, () -> endpoint.register("echo", EchoService.class, value -> value, null)); + assertTrue(transport.listeners.isEmpty()); assertTrue(transport.sent.isEmpty()); assertTrue(codec.encoded.isEmpty()); } @@ -801,7 +807,10 @@ public CompletionStage send(@NotNull RequestReplyMessage RpcEndpoint.open(transport, new TestCodec(), OPTIONS))); + try (var endpoint = RpcEndpoint.open(topic -> transport, OPTIONS)) { + assertSame(expected, + failure(RpcException.Reason.DISCONNECTED, () -> endpoint.proxy("echo", EchoService.class, new TestCodec())).getCause()); + } } /** Defines nullable reference calls and redeclares local object methods. */ diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java index 6c62805..f2c132d 100644 --- a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java @@ -31,22 +31,20 @@ class RpcMessageTest { @Test void preservesOpaquePayloadsAndAllowsNullApplicationValues() { Object payload = new Object(); - RpcMessage request = RpcMessage.request("service", "echo(java.lang.String)", payload); + RpcMessage request = RpcMessage.request("echo(java.lang.String)", payload); assertEquals(RpcMessage.Type.REQUEST, request.type()); - assertEquals("service", request.topic()); assertEquals("echo(java.lang.String)", request.operation()); assertSame(payload, request.payload()); assertNull(request.error()); - assertNull(RpcMessage.request("service", "echo(java.lang.String)", null).payload()); + assertNull(RpcMessage.request("echo(java.lang.String)", null).payload()); RpcMessage response = RpcMessage.response(payload); assertEquals(RpcMessage.Type.RESPONSE, response.type()); assertSame(payload, response.payload()); - assertNull(response.topic()); assertNull(response.operation()); assertNull(response.error()); - assertEquals(new RpcMessage<>(RpcMessage.Type.RESPONSE, null, null, null, null), RpcMessage.response(null)); + assertEquals(new RpcMessage<>(RpcMessage.Type.RESPONSE, null, null, null), RpcMessage.response(null)); } @Test @@ -56,7 +54,6 @@ void failuresCarryOnlyASafeErrorCode() { assertEquals(RpcMessage.Type.ERROR, failure.type()); assertEquals(error, failure.error()); - assertNull(failure.topic()); assertNull(failure.operation()); assertNull(failure.payload()); } @@ -64,39 +61,32 @@ void failuresCarryOnlyASafeErrorCode() { } @Test - void validatesTopicAndOperationBoundsWithoutRejectingTheBoundary() { - String topic = "t".repeat(256); + void validatesOperationBoundsWithoutRejectingTheBoundary() { String operation = "o".repeat(1024); - RpcMessage request = RpcMessage.request(topic, operation, null); - assertEquals(topic, request.topic()); + RpcMessage request = RpcMessage.request(operation, null); assertEquals(operation, request.operation()); - for (String invalid : List.of("", " ", "\n", "t".repeat(257))) { - assertThrows(IllegalArgumentException.class, () -> RpcMessage.request(invalid, operation, null)); - } for (String invalid : List.of("", " ", "\n", "o".repeat(1025))) { - assertThrows(IllegalArgumentException.class, () -> RpcMessage.request(topic, invalid, null)); + assertThrows(IllegalArgumentException.class, () -> RpcMessage.request(invalid, null)); } - assertThrows(NullPointerException.class, () -> RpcMessage.request(null, operation, null)); - assertThrows(IllegalArgumentException.class, () -> RpcMessage.request(topic, null, null)); - assertThrows(NullPointerException.class, () -> new RpcMessage<>(null, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> RpcMessage.request(null, null)); + assertThrows(NullPointerException.class, () -> new RpcMessage<>(null, null, null, null)); } @Test void rejectsRoutingFieldsOnRepliesAndInconsistentPayloadOrErrorFields() { for (RpcMessage.Type type : List.of(RpcMessage.Type.RESPONSE, RpcMessage.Type.ERROR)) { RpcMessage.Error error = type == RpcMessage.Type.ERROR ? RpcMessage.Error.HANDLER_FAILED : null; - assertThrows(IllegalArgumentException.class, () -> new RpcMessage<>(type, "service", null, null, error)); - assertThrows(IllegalArgumentException.class, () -> new RpcMessage<>(type, null, "echo(int)", null, error)); + assertThrows(IllegalArgumentException.class, () -> new RpcMessage<>(type, "echo(int)", null, error)); } - assertThrows(IllegalArgumentException.class, () -> new RpcMessage<>(RpcMessage.Type.ERROR, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> new RpcMessage<>(RpcMessage.Type.ERROR, null, null, null)); for (RpcMessage.Error error : RpcMessage.Error.values()) { assertThrows(IllegalArgumentException.class, - () -> new RpcMessage<>(RpcMessage.Type.REQUEST, "service", "echo(int)", null, error)); + () -> new RpcMessage<>(RpcMessage.Type.REQUEST, "echo(int)", null, error)); assertThrows(IllegalArgumentException.class, - () -> new RpcMessage<>(RpcMessage.Type.RESPONSE, null, null, null, error)); + () -> new RpcMessage<>(RpcMessage.Type.RESPONSE, null, null, error)); assertThrows(IllegalArgumentException.class, - () -> new RpcMessage<>(RpcMessage.Type.ERROR, null, null, "private payload", error)); + () -> new RpcMessage<>(RpcMessage.Type.ERROR, null, "private payload", error)); } } } diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java index f849e3e..d95ffde 100644 --- a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java @@ -115,11 +115,11 @@ static void await(@NotNull CountDownLatch latch) throws InterruptedException { static void connect(@NotNull MemoryTransport first, @NotNull MemoryTransport second) { first.sender = message -> { - second.deliver(message); + second.deliver(first.routes.get(message.requestId()), message); return CompletableFuture.completedFuture(null); }; second.sender = message -> { - first.deliver(message); + first.deliver(second.routes.get(message.requestId()), message); return CompletableFuture.completedFuture(null); }; } @@ -154,8 +154,10 @@ public Object decode(@Nullable Object payload, @NotNull Type type) throws Except } } - static final class MemoryTransport implements RequestReplyTransport> { - final Map>> listeners = new ConcurrentHashMap<>(); + static final class MemoryTransport implements RpcTransport { + final Map>> listeners = new ConcurrentHashMap<>(); + final Map listenerTopics = new ConcurrentHashMap<>(); + final Map routes = new ConcurrentHashMap<>(); final BlockingQueue>> sent = new LinkedBlockingQueue<>(); final AtomicInteger subscriptionCloses = new AtomicInteger(); volatile Function>, CompletionStage> sender = @@ -163,28 +165,43 @@ static final class MemoryTransport implements RequestReplyTransport> listener) { - assertNull(listeners.putIfAbsent(endpointId, listener)); - return () -> { - if (listeners.remove(endpointId, listener)) { - subscriptionCloses.incrementAndGet(); + public RequestReplyTransport> forTopic(@NotNull String topic) { + return new RequestReplyTransport<>() { + @NotNull + @Override + public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) { + assertNull(listeners.putIfAbsent(endpointId, listener)); + listenerTopics.put(endpointId, topic); + return () -> { + if (listeners.remove(endpointId, listener)) { + listenerTopics.remove(endpointId); + subscriptionCloses.incrementAndGet(); + } + }; + } + + @NotNull + @Override + public CompletionStage send(@NotNull RequestReplyMessage> message) { + routes.put(message.requestId(), topic); + sent.add(message); + return sender.apply(message); } }; } - @NotNull - @Override - public CompletionStage send(@NotNull RequestReplyMessage> message) { - sent.add(message); - return sender.apply(message); + void deliver(@NotNull RequestReplyMessage> message) { + deliver(routes.get(message.requestId()), message); } - void deliver(@NotNull RequestReplyMessage> message) { - List.copyOf(listeners.values()).forEach(listener -> listener.onMessage(message)); + void deliver(@NotNull String topic, @NotNull RequestReplyMessage> message) { + routes.put(message.requestId(), topic); + List.copyOf(listeners.entrySet()).stream().filter(entry -> topic.equals(listenerTopics.get(entry.getKey()))) + .forEach(entry -> entry.getValue().onMessage(message)); } void disconnect() { - List.copyOf(listeners.values()).forEach(Listener::onDisconnect); + List.copyOf(listeners.values()).forEach(RequestReplyTransport.Listener::onDisconnect); } @NotNull From e289d469d2c3d036592f60e9c348d03b34d3e743 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Thu, 22 Oct 2026 12:34:43 +0400 Subject: [PATCH 11/30] dbeaver/pro#11108 Add dynamic subscription callback metadata --- .../README.md | 27 +++-- .../external/api/DDExternalDamApi.java | 18 ++++ .../api/client/DDExternalDamClient.java | 12 ++- .../client/DDExternalDamGraphQlClient.java | 16 ++- .../external/api/DDExternalDamApiTest.java | 31 ++++++ .../api/client/DDExternalDamClientTest.java | 100 ++++++++++++++++-- 6 files changed, 181 insertions(+), 23 deletions(-) diff --git a/apis/com.dbeaver.datadam.external.api/README.md b/apis/com.dbeaver.datadam.external.api/README.md index 94971c6..569b029 100644 --- a/apis/com.dbeaver.datadam.external.api/README.md +++ b/apis/com.dbeaver.datadam.external.api/README.md @@ -42,6 +42,8 @@ input ExternalDamSubscriptionInput { accountId: ID! connectionId: ID topic: String! + callbackUrl: String! + callbackSecret: String! } ``` @@ -67,8 +69,10 @@ input ExternalDamSubscriptionInput { The URL must use HTTP or HTTPS, have no credentials, query, fragment, or non-root path, and use a valid port. HTTP is supported for trusted private service networks; use HTTPS across untrusted networks. -The secret must contain 32–512 printable, non-space ASCII characters. It is sent only in the `X-DD-Service-Token` header, -never in the URL or GraphQL variables. Redirects are disabled to avoid forwarding credentials to another endpoint. +The broker service secret must contain 32–512 printable, non-space ASCII characters. It is sent only in the +`X-DD-Service-Token` header, never in the URL or GraphQL variables. Subscription registration carries a separate +receiver callback secret in its variables; protect that backend-only request and never log its body. +Redirects are disabled to avoid forwarding credentials to another endpoint. Connection establishment uses the shared REST client's 5-second default connect timeout; responses have a 10-second deadline. HTTP error bodies, GraphQL error details, and malformed response contents are not copied into client exceptions. @@ -90,11 +94,17 @@ Use one configured client per service endpoint instead of constructing one per r ## Backend callback subscriptions -`registerSubscription(subscriberId, subscriptionId, accountId, connectionId, topic)` returns the lease in seconds. -The receiving process generates the UUID `subscriptionId`; repeated registration with the same scope renews it. +`registerSubscription(subscriberId, subscriptionId, accountId, connectionId, topic, callbackUrl, callbackSecret)` +returns the lease in seconds. The receiving process generates the UUID `subscriptionId`; repeated registration +with the same scope and callback renews it. A null `connectionId` subscribes to the account's matching messages, including future Desktop connections. A non-null connection ID filters one physical connection and does not follow a replacement socket. -The subscriber ID selects a trusted destination configured on External DAM; registration never supplies a callback URL. +The receiver supplies its callback service root URL and independent callback secret during registration and renewal. +The broker keeps them only with its in-memory registrations; it has no configured subscriber list or separate +service-registration step. The subscriber ID identifies the receiver and does not determine business topics. +Callback URLs follow the same HTTP/HTTPS root-URL validation as the broker URL, and the callback secret must +differ from the broker service secret. Only trusted authenticated backends may register callbacks; restrict the +broker's outbound network access and use HTTPS outside a trusted private network. `DDExternalDamCallbackClient` delivers messages to the receiver's separately authenticated `POST /internal/external-dam/graphql` using its independent callback secret. Successful acknowledgement extends the @@ -105,12 +115,17 @@ Messages are not retried or replayed after callback failure. Callback queues and Spring services use the shared `com.dbeaver.datadam.external.base` module from the DataDam server repository. It supplies the callback endpoint, local dispatch, `DDSubscription.close()` cancellation, and automatic renewal -during idle periods. Select a scoped channel once with `subscriptions.forConnection(accountId, connectionId)` or +during idle periods. Configure `datadam.external-dam-client.callback-url` and `callback-secret` on the receiver; +the library sends them automatically when opening topic subscriptions, including RPC request/reply routes. +Select a scoped channel once with `subscriptions.forConnection(accountId, connectionId)` or `subscriptions.forAccount(accountId)`, then call `channel.subscribe(topic, listener)` without repeating identifiers. The low-level registration protocol retains account and connection scope to enforce account ownership. Application handlers do not renew registrations themselves. Every callback destination must reach the exact receiving process that owns the local registration. +Desktop subscriptions remain local. The broker sends to the selected authenticated physical Desktop connection; +the Desktop channel dispatches the message to its local topic listeners without announcing handlers to the broker. + ## Channel state `DDMessageChannel.getStatus()` returns an immutable `DDChannelStatus`: `DISCONNECTED`, `CONNECTING`, `CONNECTED`, diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java index f31f040..07421e0 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java @@ -18,12 +18,15 @@ import org.jkiss.code.Nullable; +import java.net.URI; + public final class DDExternalDamApi { public static final String GRAPHQL_PATH = "/internal/graphql"; public static final String CALLBACK_GRAPHQL_PATH = "/internal/external-dam/graphql"; public static final String HEADER_SERVICE_TOKEN = "X-DD-Service-Token"; public static final int MIN_SERVICE_SECRET_LENGTH = 32; public static final int MAX_SERVICE_SECRET_LENGTH = 512; + private static final int MAX_SERVICE_URL_LENGTH = 4096; private DDExternalDamApi() { } @@ -32,4 +35,19 @@ public static boolean isValidServiceSecret(@Nullable String secret) { return secret != null && secret.length() >= MIN_SERVICE_SECRET_LENGTH && secret.length() <= MAX_SERVICE_SECRET_LENGTH && secret.chars().allMatch(value -> value >= 33 && value <= 126); } + + public static boolean isValidServiceUrl(@Nullable String serviceUrl) { + if (serviceUrl == null || serviceUrl.length() > MAX_SERVICE_URL_LENGTH) { + return false; + } + try { + URI uri = URI.create(serviceUrl); + return uri.getHost() != null && uri.getUserInfo() == null && uri.getRawQuery() == null && uri.getRawFragment() == null + && ("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme())) + && (uri.getRawPath().isEmpty() || "/".equals(uri.getRawPath())) + && (uri.getPort() == -1 || uri.getPort() >= 1 && uri.getPort() <= 65535); + } catch (IllegalArgumentException e) { + return false; + } + } } diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java index 21b09f9..7f48611 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java @@ -132,19 +132,29 @@ public int registerSubscription( @NotNull UUID subscriptionId, @NotNull UUID accountId, @Nullable String connectionId, - @NotNull String topic + @NotNull String topic, + @NotNull String callbackUrl, + @NotNull String callbackSecret ) throws DDExternalDamException { requireText(subscriberId); requireText(topic); if (connectionId != null) { requireText(connectionId); } + if (!DDExternalDamApi.isValidServiceUrl(callbackUrl)) { + throw new IllegalArgumentException("Invalid External DAM callback URL"); + } + if (!DDExternalDamApi.isValidServiceSecret(callbackSecret) || hasServiceSecret(callbackSecret)) { + throw new IllegalArgumentException("Invalid External DAM callback secret"); + } Map input = new HashMap<>(); input.put("subscriberId", subscriberId); input.put("subscriptionId", Objects.requireNonNull(subscriptionId).toString()); input.put("accountId", Objects.requireNonNull(accountId).toString()); input.put("connectionId", connectionId); input.put("topic", topic); + input.put("callbackUrl", callbackUrl); + input.put("callbackSecret", callbackSecret); JsonElement result = request("RegisterExternalDamSubscription", REGISTER_MUTATION, Map.of("input", input), "registerExternalDamSubscription"); if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isNumber()) { diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java index 21633ac..d0c5874 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java @@ -50,6 +50,10 @@ abstract class DDExternalDamGraphQlClient extends AbstractRestClient { this.endpoint = endpoint; } + final boolean hasServiceSecret(@NotNull String secret) { + return serviceSecret.equals(secret); + } + @NotNull final JsonElement request( @NotNull String operationName, @@ -110,17 +114,9 @@ static DDExternalDamException invalidResponse() { @NotNull private static String validateServiceUrl(@NotNull String serviceUrl) { - try { - URI uri = URI.create(serviceUrl); - if (uri.getHost() != null && uri.getUserInfo() == null && uri.getRawQuery() == null && uri.getRawFragment() == null - && ("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme())) - && (uri.getRawPath().isEmpty() || "/".equals(uri.getRawPath())) - && (uri.getPort() == -1 || uri.getPort() >= 1 && uri.getPort() <= 65535)) { - return uri.toString(); - } - } catch (IllegalArgumentException e) { + if (!DDExternalDamApi.isValidServiceUrl(serviceUrl)) { throw new IllegalArgumentException("Invalid External DAM service URL"); } - throw new IllegalArgumentException("Invalid External DAM service URL"); + return serviceUrl; } } diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java index 6fe72b9..991211e 100644 --- a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java @@ -45,4 +45,35 @@ void rejectsSpacesControlCharactersAndNonAsciiSecrets() { Assertions.assertFalse(DDExternalDamApi.isValidServiceSecret("x".repeat(31) + invalid)); } } + + @Test + void acceptsHttpAndHttpsServiceOrigins() { + for (String url : new String[] { + "http://localhost", "http://localhost/", "https://example.com", "HTTPS://example.com/", + "http://127.0.0.1:1", "https://example.com:65535/", "http://[::1]", "https://[::1]:8443/" + }) { + Assertions.assertTrue(DDExternalDamApi.isValidServiceUrl(url)); + } + } + + @Test + void rejectsMalformedServiceOrigins() { + for (String url : new String[] { + null, "", " ", "/relative", "example.com", "//example.com", "http://", "http:///", "http://bad host", + "ftp://example.com", "ws://example.com", "wss://example.com", "https://user@example.com", + "https://user:password@example.com", "https://example.com?", "https://example.com?token=value", + "https://example.com#", "https://example.com#fragment", "https://example.com/path", "https://example.com//", + "https://example.com/%2f", "https://example.com:0", "https://example.com:65536", "https://example.com:-1", + "https://example.com:port", "http://[::1", "http://example.com/\n" + }) { + Assertions.assertFalse(DDExternalDamApi.isValidServiceUrl(url)); + } + } + + @Test + void boundsServiceUrlLength() { + String prefix = "https://"; + Assertions.assertTrue(DDExternalDamApi.isValidServiceUrl(prefix + "a".repeat(4096 - prefix.length()))); + Assertions.assertFalse(DDExternalDamApi.isValidServiceUrl(prefix + "a".repeat(4097 - prefix.length()))); + } } diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java index 200780b..cd4a980 100644 --- a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java @@ -63,6 +63,8 @@ class DDExternalDamClientTest { private static final UUID ACCOUNT_ID = UUID.fromString("58c487c0-de34-4b45-83bb-705cb733cbcd"); private static final UUID OTHER_ACCOUNT_ID = UUID.fromString("678d5732-708f-441e-bc59-719b92af1d64"); private static final String SERVICE_SECRET = "external-dam-service-secret-2026-test"; + private static final String CALLBACK_URL = "https://callback.example.com"; + private static final String CALLBACK_SECRET = "external-dam-callback-secret-2026-test"; private static final String UPSTREAM_MARKER = "private-upstream-response-marker"; private static final String CONNECTIONS_FIELD = "externalDamConnections"; private static final String CONNECTION_ACCOUNT_FIELD = "externalDamConnectionAccount"; @@ -276,33 +278,111 @@ void returnsTheSendBooleanWithoutAdditionalRequests() throws DDExternalDamExcept } @Test - void registersStableSubscriptionIdsAsVariablesAndPreservesOptionalConnectionScope() throws DDExternalDamException { + void registersDynamicCallbacksAsVariablesAndPreservesStableIdsAndOptionalConnectionScope() throws DDExternalDamException { UUID subscriptionId = UUID.randomUUID(); for (String connectionId : List.of("", "socket")) { + String callbackUrl = connectionId.isEmpty() ? CALLBACK_URL : "http://127.0.0.1:8080/"; + String callbackSecret = connectionId.isEmpty() ? CALLBACK_SECRET : CALLBACK_SECRET + "\"\\"; respond("registerExternalDamSubscription", JsonParser.parseString("60")); assertEquals(60, client.registerSubscription("account", subscriptionId, ACCOUNT_ID, - connectionId.isEmpty() ? null : connectionId, "projects")); + connectionId.isEmpty() ? null : connectionId, "projects", callbackUrl, callbackSecret)); JsonObject body = posted(); + assertEquals(Set.of("operationName", "query", "variables"), body.keySet()); assertEquals("RegisterExternalDamSubscription", body.get("operationName").getAsString()); String query = body.get("query").getAsString().replaceAll("\\s+", " "); assertTrue(query.contains("$input: ExternalDamSubscriptionInput!")); assertTrue(query.contains("registerExternalDamSubscription(input: $input)")); + assertEquals(Set.of("input"), body.getAsJsonObject("variables").keySet()); JsonObject input = body.getAsJsonObject("variables").getAsJsonObject("input"); assertEquals("account", input.get("subscriberId").getAsString()); assertEquals(subscriptionId.toString(), input.get("subscriptionId").getAsString()); assertEquals(ACCOUNT_ID.toString(), input.get("accountId").getAsString()); assertEquals("projects", input.get("topic").getAsString()); + assertEquals(callbackUrl, input.get("callbackUrl").getAsString()); + assertTrue(callbackSecret.equals(input.get("callbackSecret").getAsString())); if (connectionId.isEmpty()) { assertTrue(!input.has("connectionId") || input.get("connectionId").isJsonNull()); } else { assertEquals(connectionId, input.get("connectionId").getAsString()); } + assertEquals(input.has("connectionId") ? 7 : 6, input.size()); assertFalse(input.has("url")); assertFalse(input.has("serviceSecret")); assertFalse(query.contains(subscriptionId.toString())); + assertFalse(query.contains(callbackUrl)); + assertFalse(query.contains(callbackSecret)); + } + assertEquals(2, requestCount.get()); + } + + @Test + void acceptsCallbackRootOriginsAndPrintableSecretBoundaries() throws DDExternalDamException { + UUID subscriptionId = UUID.randomUUID(); + respond("registerExternalDamSubscription", JsonParser.parseString("60")); + for (String callbackUrl : List.of("http://127.0.0.1:8080", "https://callback.example.com/")) { + for (String callbackSecret : List.of("!".repeat(32), "~".repeat(512))) { + assertEquals(60, client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, + "projects", callbackUrl, callbackSecret)); + + JsonObject input = posted().getAsJsonObject("variables").getAsJsonObject("input"); + assertEquals(callbackUrl, input.get("callbackUrl").getAsString()); + assertTrue(callbackSecret.equals(input.get("callbackSecret").getAsString())); + } } + assertEquals(4, requestCount.get()); + } + + @Test + void rejectsMalformedSubscriptionMetadataBeforeSendingAnyRequest() { + UUID subscriptionId = UUID.randomUUID(); + for (String value : List.of("", " ", "\t\n", "s".repeat(257))) { + assertThrows(IllegalArgumentException.class, () -> client.registerSubscription(value, subscriptionId, + ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.registerSubscription("account", subscriptionId, + ACCOUNT_ID, value, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.registerSubscription("account", subscriptionId, + ACCOUNT_ID, null, value, CALLBACK_URL, CALLBACK_SECRET)); + } + assertEquals(0, requestCount.get()); + } + + @Test + void rejectsMalformedCallbackOriginsBeforeSendingAnyRequestWithoutExposingSecrets() { + UUID subscriptionId = UUID.randomUUID(); + for (String callbackUrl : new String[] { + null, "", " ", "/relative", "callback.example.com", "//callback.example.com", "http://", "http:///", + "ftp://callback.example.com", "ws://callback.example.com", "wss://callback.example.com", + CALLBACK_URL + "/internal/external-dam/graphql", CALLBACK_URL + "/prefix/", CALLBACK_URL + "//", + CALLBACK_URL + "/%2f", CALLBACK_URL + "?secret=" + CALLBACK_SECRET, CALLBACK_URL + "#fragment", + "http://user:" + CALLBACK_SECRET + "@127.0.0.1", "http://127.0.0.1:0", "http://127.0.0.1:65536", + "http://127.0.0.1:-1", "http://127.0.0.1:port", "http://[::1", "https://" + "a".repeat(4096) + }) { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, + "projects", callbackUrl, CALLBACK_SECRET)); + assertFalse(failure.toString().contains(CALLBACK_SECRET)); + assertFalse(failure.toString().contains(SERVICE_SECRET)); + } + assertEquals(0, requestCount.get()); + } + + @Test + void rejectsInvalidAndBrokerReusedCallbackSecretsBeforeSendingAnyRequest() { + UUID subscriptionId = UUID.randomUUID(); + for (String callbackSecret : new String[] { + null, "", "s".repeat(31), "s".repeat(513), " ".repeat(32), "s".repeat(31) + "\n", + "s".repeat(31) + "\t", "s".repeat(31) + "\r", "s".repeat(31) + "\0", + "s".repeat(31) + "\u007f", "s".repeat(31) + "é", SERVICE_SECRET + }) { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, + "projects", CALLBACK_URL, callbackSecret)); + assertFalse(failure.toString().contains(CALLBACK_SECRET)); + assertFalse(failure.toString().contains(SERVICE_SECRET)); + } + assertEquals(0, requestCount.get()); } @Test @@ -327,15 +407,18 @@ void rejectsInvalidLeaseValuesAndMalformedSubscriptionEnvelopesWithoutRetrying() UUID subscriptionId = UUID.randomUUID(); for (String value : List.of("null", "true", "\"60\"", "14", "301", "60.5", "1e100", "{}", "[]")) { respond("registerExternalDamSubscription", JsonParser.parseString(value)); - assertRequestFails(() -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, "projects")); + assertRequestFails(() -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); } for (String value : List.of("null", "\"true\"", "60", "{}", "[]")) { respond("unregisterExternalDamSubscription", JsonParser.parseString(value)); assertRequestFails(() -> client.unregisterSubscription("account", subscriptionId)); } - for (String body : List.of("{", "null", "{}", "{\"data\":null}", "{\"errors\":[{\"message\":\"private\"}]}")) { + for (String body : List.of("{", "null", "{}", "{\"data\":null}", + "{\"errors\":[{\"message\":\"" + UPSTREAM_MARKER + CALLBACK_SECRET + "\"}]}")) { responseJson.set(body); - assertRequestFails(() -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, "projects")); + assertRequestFails(() -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); assertRequestFails(() -> client.unregisterSubscription("account", subscriptionId)); } } @@ -422,7 +505,7 @@ void rejectsSendResultsThatAreNotJsonBooleans() { @Test void rejectsHttpFailuresWithoutExposingTheResponseOrRetrying() { JsonObject envelope = successfulEnvelope(); - envelope.addProperty("message", UPSTREAM_MARKER + SERVICE_SECRET); + envelope.addProperty("message", UPSTREAM_MARKER + SERVICE_SECRET + CALLBACK_SECRET); responseJson.set(envelope.toString()); for (int status : List.of(204, 400, 401, 403, 404, 429, 500, 503)) { responseStatus.set(status); @@ -430,6 +513,8 @@ void rejectsHttpFailuresWithoutExposingTheResponseOrRetrying() { assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); assertRequestFails(() -> client.getConnectionAccount("connection-1")); assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertRequestFails(() -> client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); } } @@ -458,6 +543,8 @@ void neverFollowsRedirectsOrForwardsTheServiceSecret() throws IOException { assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertRequestFails(() -> client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); assertEquals(0, forwarded.get()); assertNull(forwardedSecret.get()); } @@ -598,6 +685,7 @@ private void assertRequestFails(@NotNull Executable action) { StringWriter trace = new StringWriter(); failure.printStackTrace(new PrintWriter(trace)); assertFalse(trace.toString().contains(SERVICE_SECRET)); + assertFalse(trace.toString().contains(CALLBACK_SECRET)); assertFalse(trace.toString().contains(UPSTREAM_MARKER)); } From 601b5dce0e34430c2e5cf67f09ec49ac09ba4e36 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Thu, 22 Oct 2026 15:16:20 +0400 Subject: [PATCH 12/30] dbeaver/pro#11108 Preserve typed transport send failures --- .../requestreply/RequestReplyEndpoint.java | 15 +++- .../RequestReplyEndpointTest.java | 80 +++++++++++++++++++ .../messaging/rpc/RpcEndpointTest.java | 26 ++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java index 76c0247..45eb6ad 100644 --- a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java @@ -26,6 +26,7 @@ import java.util.Objects; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -161,14 +162,24 @@ private void sendRequest(@NotNull UUID requestId, @NotNull P payload, @NotNull P try { transport.send(RequestReplyMessage.request(requestId, endpointId, payload)).whenComplete((ignored, error) -> { if (error != null) { - fail(requestId, call, new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, error)); + fail(requestId, call, sendFailure(error)); } }); } catch (RuntimeException e) { - fail(requestId, call, new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, e)); + fail(requestId, call, sendFailure(e)); } } + @NotNull + private static RequestReplyException sendFailure(@NotNull Throwable error) { + Throwable cause = error; + while ((cause instanceof CompletionException || cause instanceof ExecutionException) && cause.getCause() != null) { + cause = cause.getCause(); + } + return cause instanceof RequestReplyException failure + ? failure : new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, error); + } + private void fail( @NotNull UUID requestId, @NotNull Pending expected, diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java index fd35ae7..448f6e2 100644 --- a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java @@ -34,6 +34,7 @@ import java.util.concurrent.BlockingQueue; import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; @@ -143,6 +144,85 @@ void convertsSynchronousSendFailuresWithoutRetryingOrLeakingCapacity() throws Ex } } + @Test + void preservesTypedAsynchronousSendFailuresAndReleasesCapacityBeforeLateNotifications() throws Exception { + for (RequestReplyException expected : List.of( + new RequestReplyException(RequestReplyException.Reason.OVERLOADED), + new RequestReplyException(RequestReplyException.Reason.DISCONNECTED), + new RequestReplyException(RequestReplyException.Reason.CLOSED), + new RequestReplyException(RequestReplyException.Reason.TIMEOUT), + new RequestReplyException(RequestReplyException.Reason.SEND_FAILED), + new RequestReplyException(RequestReplyMessage.Error.OVERLOADED))) { + for (Throwable error : List.of(expected, new CompletionException(expected), new ExecutionException(expected), + new CompletionException(new ExecutionException(new CompletionException(expected))))) { + var transport = new MemoryTransport(); + var acknowledgement = new CompletableFuture(); + transport.sender = message -> acknowledgement; + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { + Future pending = async(() -> endpoint.request("request")); + final RequestReplyMessage request = transport.next(); + + acknowledgement.completeExceptionally(error); + + assertSame(expected, failure(expected.reason(), pending)); + transport.disconnect(); + transport.deliver(request.response("too late")); + transport.sender = message -> { + transport.deliver(message.response("recovered")); + return CompletableFuture.completedFuture(null); + }; + assertEquals("recovered", endpoint.request("next request")); + assertEquals("next request", transport.next().payload()); + assertTrue(transport.sent.isEmpty()); + } + } + } + } + + @Test + void preservesTypedSynchronousSendFailuresWrappedByCompletionStages() throws Exception { + for (RequestReplyException expected : List.of( + new RequestReplyException(RequestReplyException.Reason.OVERLOADED), + new RequestReplyException(RequestReplyException.Reason.DISCONNECTED), + new RequestReplyException(RequestReplyException.Reason.CLOSED), + new RequestReplyException(RequestReplyMessage.Error.HANDLER_FAILED))) { + for (CompletionException error : List.of(new CompletionException(expected), + new CompletionException(new ExecutionException(expected)))) { + var transport = new MemoryTransport(); + transport.sender = message -> { + throw error; + }; + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { + assertSame(expected, failure(expected.reason(), () -> endpoint.request("first"))); + assertSame(expected, failure(expected.reason(), () -> endpoint.request("second"))); + assertEquals(2, transport.sent.size()); + } + } + } + } + + @Test + void keepsUntypedAndUnrelatedWrappedSendErrorsAsSendFailures() throws Exception { + var cause = new IllegalStateException("untyped send failure"); + for (RuntimeException error : List.of(cause, new CompletionException(cause), + new CompletionException(new ExecutionException(cause)), new CompletionException((Throwable) null), + new IllegalStateException(new RequestReplyException(RequestReplyException.Reason.DISCONNECTED)))) { + for (boolean asynchronous : List.of(false, true)) { + var transport = new MemoryTransport(); + transport.sender = message -> { + if (asynchronous) { + return CompletableFuture.failedFuture(error); + } + throw error; + }; + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { + assertSame(error, failure(RequestReplyException.Reason.SEND_FAILED, () -> endpoint.request("request")).getCause()); + assertEquals(1, transport.sent.size()); + } + } + } + } + @Test void correlatesConcurrentResponsesDeliveredInReverseOrder() throws Exception { var transport = new MemoryTransport(); diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java index a548c52..6303480 100644 --- a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java @@ -38,8 +38,10 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.FutureTask; import java.util.concurrent.atomic.AtomicInteger; @@ -588,6 +590,30 @@ void mapsSendFailureAndPendingOverloadWithoutSendingAnExtraRequest() throws Exce } } + @Test + void preservesTypedSendFailureReasonsAtTheProxyBoundary() throws Exception { + for (var mapping : Map.of( + RequestReplyException.Reason.OVERLOADED, RpcException.Reason.OVERLOADED, + RequestReplyException.Reason.DISCONNECTED, RpcException.Reason.DISCONNECTED, + RequestReplyException.Reason.CLOSED, RpcException.Reason.CLOSED).entrySet()) { + var expected = new RequestReplyException(mapping.getKey()); + var transport = new MemoryTransport(); + try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + EchoService proxy = endpoint.proxy("echo", EchoService.class, new TestCodec()); + for (Throwable error : List.of(expected, new CompletionException(new ExecutionException(expected)))) { + transport.sender = request -> CompletableFuture.failedFuture(error); + + assertSame(expected, failure(mapping.getValue(), () -> proxy.echo("asynchronous failure")).getCause()); + } + transport.sender = request -> { + throw new CompletionException(expected); + }; + assertSame(expected, failure(mapping.getValue(), () -> proxy.echo("synchronous failure")).getCause()); + assertEquals(3, transport.sent.size()); + } + } + } + @Test void mapsRequestReplyRemoteFailuresWithoutInventingAnRpcErrorCode() throws Exception { var transport = new MemoryTransport(); From 5dad7078986a1bd008ef1b474013b5d7e55e176b Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Fri, 23 Oct 2026 11:47:06 +0400 Subject: [PATCH 13/30] dbeaver/pro#11108 Allow per-proxy RPC timeouts --- .../README.md | 4 + apis/com.dbeaver.messaging/README.md | 21 +++- .../dbeaver/messaging/rpc/RpcEndpoint.java | 45 ++++++++- .../messaging/rpc/RpcEndpointFacadeTest.java | 97 +++++++++++++++++++ .../messaging/rpc/RpcEndpointTest.java | 2 +- 5 files changed, 159 insertions(+), 10 deletions(-) diff --git a/apis/com.dbeaver.datadam.external.api/README.md b/apis/com.dbeaver.datadam.external.api/README.md index 569b029..80acf04 100644 --- a/apis/com.dbeaver.datadam.external.api/README.md +++ b/apis/com.dbeaver.datadam.external.api/README.md @@ -270,6 +270,10 @@ The Desktop and backend connection owners provide an uncustomized `DDGsonRpcCode contracts can use `rpc.proxy(topic, Contract.class)` or `rpc.register(topic, Contract.class, implementation)`. The overloads accepting a codec override it only for that proxy or implementation registration, without modifying another caller's serialization or requiring a shared application codec bean. +Callers can also override the response timeout when creating a proxy with `rpc.proxy(topic, Contract.class, timeout)` +or `rpc.proxy(topic, Contract.class, codec, timeout)`, where `timeout` is a `Duration`. Other proxies and their shared +connection keep their existing settings; overloads without a timeout use the endpoint default of 30 seconds unless +the owner supplied different options. Desktop obtains the corresponding endpoint from its existing connection service: diff --git a/apis/com.dbeaver.messaging/README.md b/apis/com.dbeaver.messaging/README.md index 4041e17..113a53c 100644 --- a/apis/com.dbeaver.messaging/README.md +++ b/apis/com.dbeaver.messaging/README.md @@ -221,10 +221,23 @@ handler failures, and remote codec failures return fixed error codes, never exce Declared application exceptions are not reconstructed on the caller. A peer with no receiving endpoint can only be detected through transport failure or timeout. Local failures retain their causes. -`RpcEndpoint.open(transports, options)` accepts the existing `RequestReplyOptions` for deadlines and capacity. -The deadline covers the underlying request/reply call, not arbitrary application codec execution. Codecs should complete -promptly. Timeout or interruption leaves the remote outcome potentially unknown; the RPC layer adds no retries or -remote cancellation promises. +`RpcEndpoint.open(transports, options)` accepts the existing `RequestReplyOptions` for default deadlines and shared +capacity. Without explicit options, the default timeout is 30 seconds. Each proxy can override the request timeout +with a positive `Duration` representable in nanoseconds: + +```java +Documents documents = rpc.proxy("documents", Documents.class, Duration.ofMinutes(2)); +Documents customDocuments = rpc.proxy("documents", Documents.class, documentCodec, Duration.ofMinutes(2)); +``` + +Overloads without a timeout retain the endpoint default. A timeout is fixed for that proxy and does not change other +proxies, registrations, or shared topic scopes. Multiple proxies for the same interface and topic can use different +timeouts without creating another remote implementation or changing the business interface. Topic establishment and +capacity still use the endpoint options. + +The timeout covers each underlying request/reply call, not arbitrary application codec execution. Codecs should complete +promptly. Timeout or interruption leaves the remote outcome potentially unknown; late replies are ignored, and the RPC +layer adds no retries or remote cancellation promises. Closing a service registration removes only that local implementation. It does not close the endpoint or its proxies, and work already dispatched to the implementation can finish. Repeatedly closing an old registration cannot remove a diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java index 1aedb75..3b7ac5f 100644 --- a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java +++ b/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java @@ -26,6 +26,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Proxy; +import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -89,9 +90,24 @@ public T proxy(@NotNull String topic, @NotNull Class type) { return proxy(topic, type, state.defaultCodec()); } + @NotNull + public T proxy(@NotNull String topic, @NotNull Class type, @NotNull Duration timeout) { + return proxy(topic, type, state.defaultCodec(), timeout); + } + @NotNull public T proxy(@NotNull String topic, @NotNull Class type, @NotNull RpcCodec codec) { - return state.proxy(topic, type, codec); + return proxy(topic, type, codec, state.options.timeout()); + } + + @NotNull + public T proxy( + @NotNull String topic, + @NotNull Class type, + @NotNull RpcCodec codec, + @NotNull Duration timeout + ) { + return state.proxy(topic, type, codec, timeout); } public boolean isClosed() { @@ -159,8 +175,14 @@ private MessageSubscription register( } @NotNull - private T proxy(@NotNull String topic, @NotNull Class type, @NotNull RpcCodec codec) { + private T proxy( + @NotNull String topic, + @NotNull Class type, + @NotNull RpcCodec codec, + @NotNull Duration timeout + ) { requireTopic(topic); + requireTimeout(timeout); RpcCodec

bindingCodec = bindingCodec(codec); RpcContract contract = RpcContract.create(type); Topic selected = topic(topic); @@ -177,7 +199,7 @@ private T proxy(@NotNull String topic, @NotNull Class type, @NotNull RpcC }; } Method operation = Objects.requireNonNull(contract.operation(RpcContract.operationId(method))); - return invoke(selected, operation, Objects.requireNonNull(arguments)[0], bindingCodec); + return invoke(selected, operation, Objects.requireNonNull(arguments)[0], bindingCodec, timeout); }); return type.cast(proxy); } @@ -187,7 +209,8 @@ private Object invoke( @NotNull Topic topic, @NotNull Method operation, @Nullable Object argument, - @NotNull RpcCodec

codec + @NotNull RpcCodec

codec, + @NotNull Duration timeout ) { synchronized (lock) { requireOpen(); @@ -204,7 +227,7 @@ private Object invoke( } RpcMessage

response; try { - response = Objects.requireNonNull(topic.requests).request(request); + response = Objects.requireNonNull(topic.requests).request(request, timeout); } catch (RequestReplyException e) { throw new RpcException(e); } catch (InterruptedException e) { @@ -297,6 +320,18 @@ private static void requireTopic(@NotNull String topic) { } } + private static void requireTimeout(@NotNull Duration timeout) { + Objects.requireNonNull(timeout); + try { + if (timeout.toNanos() > 0) { + return; + } + } catch (ArithmeticException e) { + throw new IllegalArgumentException("RPC timeout is too large", e); + } + throw new IllegalArgumentException("RPC timeout must be positive"); + } + @NotNull private Topic topic(@NotNull String name) { Topic selected; diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java index ba38990..d7d58c2 100644 --- a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java @@ -19,6 +19,7 @@ import com.dbeaver.messaging.requestreply.MessageSubscription; import com.dbeaver.messaging.requestreply.RequestReplyException; import com.dbeaver.messaging.requestreply.RequestReplyMessage; +import com.dbeaver.messaging.requestreply.RequestReplyOptions; import com.dbeaver.messaging.requestreply.RequestReplyTransport; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; @@ -27,6 +28,7 @@ import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.List; import java.util.Map; import java.util.UUID; @@ -166,6 +168,100 @@ void defaultsAndIndependentBindingCodecsNeverReplaceEachOther() { } } + @Test + void shorterProxyTimeoutDoesNotChangeOtherProxiesSharingTheTopic() throws Exception { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + TextService first = endpoint.proxy("text", TextService.class); + TextService expiring = endpoint.proxy("text", TextService.class, Duration.ofMillis(100)); + TextService second = endpoint.proxy("text", TextService.class); + Future firstCall = async(() -> first.apply("first")); + var firstRequest = transport.next(); + Future secondCall = async(() -> second.apply("second")); + var secondRequest = transport.next(); + + failure(RpcException.Reason.TIMEOUT, () -> expiring.apply("expire")); + var expiredRequest = transport.next(); + + assertFalse(firstCall.isDone()); + assertFalse(secondCall.isDone()); + transport.deliver(expiredRequest.response(RpcMessage.response("late"))); + assertFalse(firstCall.isDone()); + assertFalse(secondCall.isDone()); + transport.deliver(firstRequest.response(RpcMessage.response("first reply"))); + transport.deliver(secondRequest.response(RpcMessage.response("second reply"))); + assertEquals("first reply", result(firstCall)); + assertEquals("second reply", result(secondCall)); + assertEquals(3, codec.encoded.size()); + assertEquals(2, codec.decoded.size()); + assertTrue(transport.sent.isEmpty()); + assertEquals(1, transport.listeners.size()); + } + assertEquals(1, transport.subscriptionCloses.get()); + } + + @Test + void longerProxyTimeoutAndExplicitCodecDoNotChangeTheEndpointDefaults() throws Exception { + var transport = new MemoryTransport(); + var fallback = new TestCodec(); + var custom = new TestCodec(); + custom.encoder = (value, type) -> "custom:" + value; + custom.decoder = (value, type) -> "decoded:" + value; + var options = new RequestReplyOptions(Duration.ofMillis(100), 16, 16); + try (var endpoint = RpcEndpoint.open(transport, fallback, options)) { + TextService first = endpoint.proxy("text", TextService.class); + TextService extended = endpoint.proxy("text", TextService.class, custom, WAIT); + TextService second = endpoint.proxy("text", TextService.class, fallback); + Future extendedCall = async(() -> extended.apply("extended")); + var extendedRequest = transport.next(); + assertEquals("custom:extended", extendedRequest.payload().payload()); + + failure(RpcException.Reason.TIMEOUT, () -> first.apply("first")); + var firstRequest = transport.next(); + failure(RpcException.Reason.TIMEOUT, () -> second.apply("second")); + var secondRequest = transport.next(); + + assertFalse(extendedCall.isDone()); + transport.deliver(firstRequest.response(RpcMessage.response("first late reply"))); + transport.deliver(secondRequest.response(RpcMessage.response("second late reply"))); + assertFalse(extendedCall.isDone()); + transport.deliver(extendedRequest.response(RpcMessage.response("extended reply"))); + assertEquals("decoded:extended reply", result(extendedCall)); + assertEquals(1, custom.encoded.size()); + assertEquals(1, custom.decoded.size()); + assertEquals(2, fallback.encoded.size()); + assertTrue(fallback.decoded.isEmpty()); + assertTrue(transport.sent.isEmpty()); + assertEquals(1, transport.listeners.size()); + } + assertEquals(1, transport.subscriptionCloses.get()); + } + + @Test + void validatesProxyTimeoutsBeforeOpeningAnyTopic() { + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + for (Duration timeout : List.of( + Duration.ZERO, Duration.ofNanos(-1), Duration.ofSeconds(-1), + Duration.ofSeconds(Long.MAX_VALUE), Duration.ofSeconds(Long.MIN_VALUE) + )) { + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("text", TextService.class, timeout)); + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("text", TextService.class, codec, timeout)); + } + assertThrows(NullPointerException.class, () -> endpoint.proxy("text", TextService.class, (Duration) null)); + assertThrows(NullPointerException.class, () -> endpoint.proxy("text", TextService.class, codec, null)); + assertThrows(NullPointerException.class, () -> endpoint.proxy("text", TextService.class, null, WAIT)); + assertTrue(transport.listeners.isEmpty()); + for (Duration timeout : List.of(Duration.ofNanos(1), Duration.ofNanos(Long.MAX_VALUE))) { + endpoint.proxy("text", TextService.class, timeout); + endpoint.proxy("text", TextService.class, codec, timeout); + } + assertEquals(1, transport.listeners.size()); + } + } + @Test void closesEveryTopicWhenOneSubscriptionCleanupFails() { var closes = new AtomicInteger(); @@ -195,6 +291,7 @@ void missingDefaultFailsBeforeOpeningAnyTopic() { IllegalStateException proxyFailure = assertThrows(IllegalStateException.class, () -> endpoint.proxy("text", TextService.class)); assertTrue(proxyFailure.getMessage().contains("default codec")); + assertThrows(IllegalStateException.class, () -> endpoint.proxy("text", TextService.class, WAIT)); assertThrows(IllegalStateException.class, () -> endpoint.register("text", TextService.class, value -> value)); assertEquals(0, openings.get()); } diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java index 6303480..17eb9f2 100644 --- a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java +++ b/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java @@ -803,7 +803,7 @@ void rejectsInvalidTopicsNullArgumentsAndOptionsBeforeSubscribingOrSending() thr } assertThrows(NullPointerException.class, () -> endpoint.proxy(null, EchoService.class, codec)); assertThrows(NullPointerException.class, () -> endpoint.proxy("echo", null, codec)); - assertThrows(NullPointerException.class, () -> endpoint.proxy("echo", EchoService.class, null)); + assertThrows(NullPointerException.class, () -> endpoint.proxy("echo", EchoService.class, (RpcCodec) null)); assertThrows(NullPointerException.class, () -> endpoint.register(null, EchoService.class, value -> value, codec)); assertThrows(NullPointerException.class, () -> endpoint.register("echo", EchoService.class, null, codec)); assertThrows(NullPointerException.class, () -> endpoint.register("echo", EchoService.class, value -> value, null)); From 8252e2bc26ac972ee3074be6febf0c687e53850f Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Fri, 23 Oct 2026 15:24:43 +0400 Subject: [PATCH 14/30] dbeaver/pro#11108 Centralize External DAM protocol and subscription contracts --- .../external/api/DDExternalDamApi.java | 7 +++++ .../api/client/DDExternalDamClient.java | 13 ++++++-- .../requestreply/DDRequestReplyTransport.java | 27 ++++++++++------- .../external/api/rpc/DDRpcTransport.java | 25 +++++++++------- .../external/api/DDExternalDamApiTest.java | 14 +++++++++ .../api/client/DDExternalDamClientTest.java | 30 +++++++++++++++++++ 6 files changed, 92 insertions(+), 24 deletions(-) diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java index 07421e0..a1d7ea0 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java @@ -26,11 +26,18 @@ public final class DDExternalDamApi { public static final String HEADER_SERVICE_TOKEN = "X-DD-Service-Token"; public static final int MIN_SERVICE_SECRET_LENGTH = 32; public static final int MAX_SERVICE_SECRET_LENGTH = 512; + public static final int MIN_SUBSCRIPTION_LEASE_SECONDS = 15; + public static final int MAX_SUBSCRIPTION_LEASE_SECONDS = 300; + private static final int MAX_SUBSCRIBER_ID_LENGTH = 256; private static final int MAX_SERVICE_URL_LENGTH = 4096; private DDExternalDamApi() { } + public static boolean isValidSubscriberId(@Nullable String subscriberId) { + return subscriberId != null && !subscriberId.isBlank() && subscriberId.length() <= MAX_SUBSCRIBER_ID_LENGTH; + } + public static boolean isValidServiceSecret(@Nullable String secret) { return secret != null && secret.length() >= MIN_SERVICE_SECRET_LENGTH && secret.length() <= MAX_SERVICE_SECRET_LENGTH && secret.chars().allMatch(value -> value >= 33 && value <= 126); diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java index 7f48611..6f58240 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java @@ -136,7 +136,7 @@ public int registerSubscription( @NotNull String callbackUrl, @NotNull String callbackSecret ) throws DDExternalDamException { - requireText(subscriberId); + requireSubscriberId(subscriberId); requireText(topic); if (connectionId != null) { requireText(connectionId); @@ -162,7 +162,8 @@ public int registerSubscription( } try { int leaseSeconds = result.getAsBigDecimal().intValueExact(); - if (leaseSeconds >= 15 && leaseSeconds <= 300) { + if (leaseSeconds >= DDExternalDamApi.MIN_SUBSCRIPTION_LEASE_SECONDS + && leaseSeconds <= DDExternalDamApi.MAX_SUBSCRIPTION_LEASE_SECONDS) { return leaseSeconds; } } catch (ArithmeticException | NumberFormatException e) { @@ -172,7 +173,7 @@ public int registerSubscription( } public boolean unregisterSubscription(@NotNull String subscriberId, @NotNull UUID subscriptionId) throws DDExternalDamException { - requireText(subscriberId); + requireSubscriberId(subscriberId); JsonElement result = request("UnregisterExternalDamSubscription", UNREGISTER_MUTATION, Map.of("subscriberId", subscriberId, "subscriptionId", Objects.requireNonNull(subscriptionId).toString()), "unregisterExternalDamSubscription"); @@ -191,6 +192,12 @@ private static String readString(@NotNull JsonObject object, @NotNull String fie return value.getAsString(); } + private static void requireSubscriberId(@Nullable String subscriberId) { + if (!DDExternalDamApi.isValidSubscriberId(subscriberId)) { + throw new IllegalArgumentException("Invalid subscription metadata"); + } + } + private static void requireText(@NotNull String value) { if (value.isBlank() || value.length() > 256) { throw new IllegalArgumentException("Invalid subscription metadata"); diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java index 2f6561f..9b2ff48 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java @@ -48,6 +48,11 @@ public final class DDRequestReplyTransport implements RequestReplyTransport, AutoCloseable { private static final String MESSAGE_TYPE = "datadam.requestreply.v1"; + private static final String FIELD_REQUEST_ID = "requestId"; + private static final String FIELD_REPLY_TO = "replyTo"; + private static final String FIELD_TYPE = "type"; + private static final String FIELD_ERROR = "error"; + private static final String FIELD_DATA = "data"; private static final int MAX_PENDING_SENDS = 128; private static final int MAX_PENDING_DELIVERIES = 128; private final Object lock = new Object(); @@ -163,13 +168,13 @@ public CompletionStage send(@NotNull RequestReplyMessage messa } try { JsonObject envelope = new JsonObject(); - envelope.addProperty("requestId", message.requestId().toString()); - envelope.addProperty("replyTo", message.replyTo().toString()); - envelope.addProperty("type", message.type().name()); + envelope.addProperty(FIELD_REQUEST_ID, message.requestId().toString()); + envelope.addProperty(FIELD_REPLY_TO, message.replyTo().toString()); + envelope.addProperty(FIELD_TYPE, message.type().name()); if (message.type() == RequestReplyMessage.Type.ERROR) { - envelope.addProperty("error", Objects.requireNonNull(message.error()).name()); + envelope.addProperty(FIELD_ERROR, Objects.requireNonNull(message.error()).name()); } else { - envelope.add("data", message.payload() == null ? JsonNull.INSTANCE : message.payload()); + envelope.add(FIELD_DATA, message.payload() == null ? JsonNull.INSTANCE : message.payload()); } String target = message.type() == RequestReplyMessage.Type.REQUEST ? topic : replyPrefix + message.replyTo(); CompletableFuture result = new CompletableFuture<>(); @@ -217,19 +222,19 @@ private static RequestReplyMessage decode(@NotNull DDMessage message } try { JsonObject envelope = message.payload(); - UUID requestId = uuid(envelope, "requestId"); - UUID replyTo = uuid(envelope, "replyTo"); - RequestReplyMessage.Type type = RequestReplyMessage.Type.valueOf(string(envelope, "type")); - JsonElement data = envelope.get("data"); + UUID requestId = uuid(envelope, FIELD_REQUEST_ID); + UUID replyTo = uuid(envelope, FIELD_REPLY_TO); + RequestReplyMessage.Type type = RequestReplyMessage.Type.valueOf(string(envelope, FIELD_TYPE)); + JsonElement data = envelope.get(FIELD_DATA); RequestReplyMessage.Error error = null; JsonObject payload = null; if (type == RequestReplyMessage.Type.ERROR) { if (data != null && !data.isJsonNull()) { return null; } - error = RequestReplyMessage.Error.valueOf(string(envelope, "error")); + error = RequestReplyMessage.Error.valueOf(string(envelope, FIELD_ERROR)); } else { - if (envelope.has("error") || data == null || !data.isJsonObject() && !data.isJsonNull() + if (envelope.has(FIELD_ERROR) || data == null || !data.isJsonObject() && !data.isJsonNull() || type == RequestReplyMessage.Type.REQUEST && data.isJsonNull()) { return null; } diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java index b947438..66f4bfe 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java @@ -38,6 +38,11 @@ public final class DDRpcTransport implements RequestReplyTransport> { private static final String PROTOCOL = "dbeaver.rpc.v2"; + private static final String FIELD_PROTOCOL = "protocol"; + private static final String FIELD_TYPE = "type"; + private static final String FIELD_ERROR = "error"; + private static final String FIELD_OPERATION = "operation"; + private static final String FIELD_DATA = "data"; private static final int MAX_PENDING_SENDS = 128; private final Semaphore sendBudget = new Semaphore(MAX_PENDING_SENDS); private final RequestReplyTransport delegate; @@ -111,15 +116,15 @@ public CompletionStage send(@NotNull RequestReplyMessage message) { JsonObject envelope = new JsonObject(); - envelope.addProperty("protocol", PROTOCOL); - envelope.addProperty("type", message.type().name()); + envelope.addProperty(FIELD_PROTOCOL, PROTOCOL); + envelope.addProperty(FIELD_TYPE, message.type().name()); if (message.type() == RpcMessage.Type.ERROR) { - envelope.addProperty("error", Objects.requireNonNull(message.error()).name()); + envelope.addProperty(FIELD_ERROR, Objects.requireNonNull(message.error()).name()); } else { if (message.type() == RpcMessage.Type.REQUEST) { - envelope.addProperty("operation", message.operation()); + envelope.addProperty(FIELD_OPERATION, message.operation()); } - envelope.add("data", message.payload() == null ? JsonNull.INSTANCE : message.payload().deepCopy()); + envelope.add(FIELD_DATA, message.payload() == null ? JsonNull.INSTANCE : message.payload().deepCopy()); } return envelope; } @@ -131,25 +136,25 @@ private static RequestReplyMessage> decode(@NotNull Requ } try { JsonObject envelope = message.payload(); - if (!PROTOCOL.equals(string(envelope, "protocol"))) { + if (!PROTOCOL.equals(string(envelope, FIELD_PROTOCOL))) { return null; } - RpcMessage.Type type = RpcMessage.Type.valueOf(string(envelope, "type")); + RpcMessage.Type type = RpcMessage.Type.valueOf(string(envelope, FIELD_TYPE)); requireType(message.type(), type); RpcMessage payload; if (type == RpcMessage.Type.ERROR) { if (envelope.size() != 3) { return null; } - payload = RpcMessage.failure(RpcMessage.Error.valueOf(string(envelope, "error"))); + payload = RpcMessage.failure(RpcMessage.Error.valueOf(string(envelope, FIELD_ERROR))); } else { - JsonElement data = envelope.get("data"); + JsonElement data = envelope.get(FIELD_DATA); if (data == null || envelope.size() != (type == RpcMessage.Type.REQUEST ? 4 : 3)) { return null; } JsonElement snapshot = data.isJsonNull() ? null : data.deepCopy(); payload = type == RpcMessage.Type.REQUEST - ? RpcMessage.request(string(envelope, "operation"), snapshot) + ? RpcMessage.request(string(envelope, FIELD_OPERATION), snapshot) : RpcMessage.response(snapshot); } return new RequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), payload, message.error()); diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java index 991211e..c075402 100644 --- a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java @@ -20,6 +20,20 @@ import org.junit.jupiter.api.Test; class DDExternalDamApiTest { + @Test + void acceptsSubscriberIdsWithSubscriptionMetadataCharactersAndLengthBoundaries() { + for (String subscriberId : new String[] {"s", "service:account", "service account", "s".repeat(129), "s".repeat(256)}) { + Assertions.assertTrue(DDExternalDamApi.isValidSubscriberId(subscriberId)); + } + } + + @Test + void rejectsMissingBlankAndOversizedSubscriberIds() { + for (String subscriberId : new String[] {null, "", " ", "\t\n", "s".repeat(257)}) { + Assertions.assertFalse(DDExternalDamApi.isValidSubscriberId(subscriberId)); + } + } + @Test void acceptsPrintableAsciiSecretsAtBothLengthBoundaries() { Assertions.assertTrue(DDExternalDamApi.isValidServiceSecret("!".repeat(32))); diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java index cd4a980..0acb567 100644 --- a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java @@ -334,12 +334,33 @@ void acceptsCallbackRootOriginsAndPrintableSecretBoundaries() throws DDExternalD assertEquals(4, requestCount.get()); } + @Test + void preservesSubscriberIdsAcrossRegistrationAndCancellation() throws DDExternalDamException { + for (String subscriberId : List.of("service:account", "service account", "s".repeat(129), "s".repeat(256))) { + UUID subscriptionId = UUID.randomUUID(); + respond("registerExternalDamSubscription", JsonParser.parseString("60")); + assertEquals(60, client.registerSubscription(subscriberId, subscriptionId, ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertEquals(subscriberId, posted().getAsJsonObject("variables").getAsJsonObject("input") + .get("subscriberId").getAsString()); + + respond("unregisterExternalDamSubscription", JsonParser.parseString("true")); + assertTrue(client.unregisterSubscription(subscriberId, subscriptionId)); + assertEquals(subscriberId, posted().getAsJsonObject("variables").get("subscriberId").getAsString()); + } + assertEquals(8, requestCount.get()); + } + @Test void rejectsMalformedSubscriptionMetadataBeforeSendingAnyRequest() { UUID subscriptionId = UUID.randomUUID(); + assertThrows(IllegalArgumentException.class, () -> client.registerSubscription(null, subscriptionId, + ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.unregisterSubscription(null, subscriptionId)); for (String value : List.of("", " ", "\t\n", "s".repeat(257))) { assertThrows(IllegalArgumentException.class, () -> client.registerSubscription(value, subscriptionId, ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.unregisterSubscription(value, subscriptionId)); assertThrows(IllegalArgumentException.class, () -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, value, "projects", CALLBACK_URL, CALLBACK_SECRET)); assertThrows(IllegalArgumentException.class, () -> client.registerSubscription("account", subscriptionId, @@ -402,6 +423,15 @@ void unregistersOnlyTheStableSubscriberAndSubscriptionId() throws DDExternalDamE } } + @Test + void acceptsBothSubscriptionLeaseBoundaries() throws DDExternalDamException { + for (int lease : List.of(15, 300)) { + respond("registerExternalDamSubscription", JsonParser.parseString(Integer.toString(lease))); + assertEquals(lease, client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); + } + } + @Test void rejectsInvalidLeaseValuesAndMalformedSubscriptionEnvelopesWithoutRetrying() { UUID subscriptionId = UUID.randomUUID(); From 2e9d4d2cb873ee8f058af1981c053313789647ed Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Fri, 23 Oct 2026 16:31:54 +0400 Subject: [PATCH 15/30] dbeaver/pro#11108 Allow shared service secret for subscription callbacks --- .../README.md | 27 +++++++++++-------- .../api/client/DDExternalDamClient.java | 2 +- .../client/DDExternalDamGraphQlClient.java | 4 --- .../api/client/DDExternalDamClientTest.java | 21 +++++++++++++-- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/apis/com.dbeaver.datadam.external.api/README.md b/apis/com.dbeaver.datadam.external.api/README.md index 80acf04..cad6850 100644 --- a/apis/com.dbeaver.datadam.external.api/README.md +++ b/apis/com.dbeaver.datadam.external.api/README.md @@ -69,9 +69,10 @@ input ExternalDamSubscriptionInput { The URL must use HTTP or HTTPS, have no credentials, query, fragment, or non-root path, and use a valid port. HTTP is supported for trusted private service networks; use HTTPS across untrusted networks. -The broker service secret must contain 32–512 printable, non-space ASCII characters. It is sent only in the -`X-DD-Service-Token` header, never in the URL or GraphQL variables. Subscription registration carries a separate -receiver callback secret in its variables; protect that backend-only request and never log its body. +The External DAM service secret must contain 32–512 printable, non-space ASCII characters. It authenticates +both backend-to-broker requests and broker-to-backend callbacks through the `X-DD-Service-Token` header. +Subscription registration also carries this same secret in its `callbackSecret` variable for protocol compatibility; +protect that backend-only request and never log its body. The secret must never appear in URLs or GraphQL query text. Redirects are disabled to avoid forwarding credentials to another endpoint. Connection establishment uses the shared REST client's 5-second default connect timeout; responses have a 10-second deadline. HTTP error bodies, GraphQL error details, and malformed response contents are not copied into client exceptions. @@ -99,15 +100,16 @@ returns the lease in seconds. The receiving process generates the UUID `subscrip with the same scope and callback renews it. A null `connectionId` subscribes to the account's matching messages, including future Desktop connections. A non-null connection ID filters one physical connection and does not follow a replacement socket. -The receiver supplies its callback service root URL and independent callback secret during registration and renewal. +The receiver supplies its callback service root URL and the External DAM service secret as `callbackSecret` during +registration and renewal. The `callbackSecret` parameter and wire field remain part of the registration protocol. The broker keeps them only with its in-memory registrations; it has no configured subscriber list or separate service-registration step. The subscriber ID identifies the receiver and does not determine business topics. -Callback URLs follow the same HTTP/HTTPS root-URL validation as the broker URL, and the callback secret must -differ from the broker service secret. Only trusted authenticated backends may register callbacks; restrict the -broker's outbound network access and use HTTPS outside a trusted private network. +Callback URLs follow the same HTTP/HTTPS root-URL validation as the broker URL, and callback secrets follow the same +32–512 printable, non-space ASCII validation as the service secret. Only trusted authenticated backends may register +callbacks; restrict the broker's outbound network access and use HTTPS outside a trusted private network. -`DDExternalDamCallbackClient` delivers messages to the receiver's separately authenticated -`POST /internal/external-dam/graphql` using its independent callback secret. Successful acknowledgement extends the +`DDExternalDamCallbackClient` delivers messages to the receiver's authenticated +`POST /internal/external-dam/graphql` using the same External DAM service secret. Successful acknowledgement extends the lease. The acknowledgement does not describe local subscriber presence or business processing. Local cancellation uses `unregisterSubscription`, including cleanup when the receiver no longer recognizes an incoming registration. External DAM discards queued work on cancellation or expiry; an already claimed callback may finish. @@ -115,8 +117,11 @@ Messages are not retried or replayed after callback failure. Callback queues and Spring services use the shared `com.dbeaver.datadam.external.base` module from the DataDam server repository. It supplies the callback endpoint, local dispatch, `DDSubscription.close()` cancellation, and automatic renewal -during idle periods. Configure `datadam.external-dam-client.callback-url` and `callback-secret` on the receiver; -the library sends them automatically when opening topic subscriptions, including RPC request/reply routes. +during idle periods. Configure `datadam.external-dam-client.service-url`, `datadam.external-dam-client.service-secret`, and +`datadam.external-dam-client.callback-url` on Account and Storage. Set `service-secret` to the External DAM service +secret; the library uses it for outgoing requests, incoming callback authentication, and the registration's +`callbackSecret` field automatically when opening topic subscriptions, including RPC request/reply routes. +There is no separate `datadam.external-dam-client.callback-secret` configuration. Select a scoped channel once with `subscriptions.forConnection(accountId, connectionId)` or `subscriptions.forAccount(accountId)`, then call `channel.subscribe(topic, listener)` without repeating identifiers. The low-level registration protocol retains account and connection scope to enforce account ownership. diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java index 6f58240..5a481c3 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java @@ -144,7 +144,7 @@ public int registerSubscription( if (!DDExternalDamApi.isValidServiceUrl(callbackUrl)) { throw new IllegalArgumentException("Invalid External DAM callback URL"); } - if (!DDExternalDamApi.isValidServiceSecret(callbackSecret) || hasServiceSecret(callbackSecret)) { + if (!DDExternalDamApi.isValidServiceSecret(callbackSecret)) { throw new IllegalArgumentException("Invalid External DAM callback secret"); } Map input = new HashMap<>(); diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java index d0c5874..9bb6947 100644 --- a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java +++ b/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java @@ -50,10 +50,6 @@ abstract class DDExternalDamGraphQlClient extends AbstractRestClient { this.endpoint = endpoint; } - final boolean hasServiceSecret(@NotNull String secret) { - return serviceSecret.equals(secret); - } - @NotNull final JsonElement request( @NotNull String operationName, diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java index 0acb567..104ad9c 100644 --- a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java +++ b/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java @@ -317,6 +317,23 @@ void registersDynamicCallbacksAsVariablesAndPreservesStableIdsAndOptionalConnect assertEquals(2, requestCount.get()); } + @Test + void registersTheServiceSecretAsTheCallbackSecret() throws DDExternalDamException { + respond("registerExternalDamSubscription", JsonParser.parseString("60")); + + assertEquals(60, client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, + "projects", CALLBACK_URL, SERVICE_SECRET)); + + CapturedRequest request = latestRequest.get(); + assertNotNull(request); + assertEquals(SERVICE_SECRET, request.header(DDExternalDamApi.HEADER_SERVICE_TOKEN)); + JsonObject body = JsonParser.parseString(request.body()).getAsJsonObject(); + assertEquals(SERVICE_SECRET, body.getAsJsonObject("variables").getAsJsonObject("input") + .get("callbackSecret").getAsString()); + assertFalse(body.get("query").getAsString().contains(SERVICE_SECRET)); + assertEquals(1, requestCount.get()); + } + @Test void acceptsCallbackRootOriginsAndPrintableSecretBoundaries() throws DDExternalDamException { UUID subscriptionId = UUID.randomUUID(); @@ -390,12 +407,12 @@ void rejectsMalformedCallbackOriginsBeforeSendingAnyRequestWithoutExposingSecret } @Test - void rejectsInvalidAndBrokerReusedCallbackSecretsBeforeSendingAnyRequest() { + void rejectsInvalidCallbackSecretsBeforeSendingAnyRequest() { UUID subscriptionId = UUID.randomUUID(); for (String callbackSecret : new String[] { null, "", "s".repeat(31), "s".repeat(513), " ".repeat(32), "s".repeat(31) + "\n", "s".repeat(31) + "\t", "s".repeat(31) + "\r", "s".repeat(31) + "\0", - "s".repeat(31) + "\u007f", "s".repeat(31) + "é", SERVICE_SECRET + "s".repeat(31) + "\u007f", "s".repeat(31) + "é" }) { IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, From e028dfbc2a98fea9b7a62f8bb0f944e9c11f56ca Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Fri, 23 Oct 2026 23:34:15 +0400 Subject: [PATCH 16/30] dbeaver/pro#11108 Merge messaging into renamed External DAM API module --- README.md | 6 +++ .../MESSAGING.md} | 7 +-- .../META-INF/MANIFEST.MF | 9 ++-- .../README.md | 12 +++-- .../build.properties | 0 .../pom.xml | 7 +-- .../external/api/DDExternalDamApi.java | 0 .../external/api/DDExternalDamProtocol.java | 0 .../client/DDExternalDamCallbackClient.java | 0 .../api/client/DDExternalDamClient.java | 0 .../client/DDExternalDamGraphQlClient.java | 0 .../api/exception/DDExternalDamException.java | 0 .../external/api/model/DDConnectionInfo.java | 0 .../api/model/DDExternalDamConnection.java | 0 .../datadam/external/api/model/DDHello.java | 0 .../datadam/external/api/model/DDMessage.java | 0 .../datadam/external/api/model/DDReady.java | 0 .../requestreply/DDRequestReplyTransport.java | 0 .../external/api/rpc/DDGsonRpcCodec.java | 0 .../external/api/rpc/DDRpcTransport.java | 0 .../api/transport/DDChannelState.java | 0 .../api/transport/DDChannelStatus.java | 0 .../api/transport/DDConnectionListener.java | 0 .../api/transport/DDMessageChannel.java | 0 .../api/transport/DDMessageListener.java | 0 .../api/transport/DDSubscription.java | 0 .../requestreply/MessageSubscription.java | 0 .../requestreply/RequestHandler.java | 0 .../requestreply/RequestReplyEndpoint.java | 0 .../requestreply/RequestReplyException.java | 0 .../requestreply/RequestReplyMessage.java | 0 .../requestreply/RequestReplyOptions.java | 0 .../requestreply/RequestReplyTransport.java | 0 .../com/dbeaver/messaging/rpc/RpcCodec.java | 0 .../dbeaver/messaging/rpc/RpcContract.java | 0 .../dbeaver/messaging/rpc/RpcEndpoint.java | 0 .../dbeaver/messaging/rpc/RpcException.java | 0 .../com/dbeaver/messaging/rpc/RpcMessage.java | 0 .../dbeaver/messaging/rpc/RpcTransport.java | 0 .../external/api/DDExternalDamApiTest.java | 0 .../api/DDExternalDamProtocolTest.java | 0 .../DDExternalDamCallbackClientTest.java | 0 .../api/client/DDExternalDamClientTest.java | 0 .../model/DDExternalDamConnectionTest.java | 0 .../external/api/model/DDMessageTest.java | 0 .../DDRequestReplyTransportTest.java | 0 .../external/api/rpc/DDGsonRpcCodecTest.java | 0 .../api/rpc/DDRpcTopicsIntegrationTest.java | 0 .../external/api/rpc/DDRpcTransportTest.java | 0 .../api/transport/DDChannelStatusTest.java | 0 .../RequestReplyDeadlineTest.java | 0 .../RequestReplyEndpointTest.java | 0 .../requestreply/RequestReplyMessageTest.java | 0 .../requestreply/RequestReplyOptionsTest.java | 0 .../messaging/rpc/RpcContractTest.java | 0 .../messaging/rpc/RpcEndpointFacadeTest.java | 0 .../messaging/rpc/RpcEndpointTest.java | 0 .../dbeaver/messaging/rpc/RpcMessageTest.java | 0 .../dbeaver/messaging/rpc/RpcTestSupport.java | 0 .../rpc/fixtures/HiddenInheritedService.java | 0 .../rpc/fixtures/PublicInheritedService.java | 0 .../build.properties | 4 -- .../META-INF/MANIFEST.MF | 12 ----- apis/com.dbeaver.messaging/pom.xml | 51 ------------------- apis/pom.xml | 3 +- 65 files changed, 25 insertions(+), 86 deletions(-) rename apis/{com.dbeaver.messaging/README.md => com.dbeaver.datadam.external-dam.api/MESSAGING.md} (96%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/META-INF/MANIFEST.MF (72%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/README.md (97%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/build.properties (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/pom.xml (89%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/messaging/rpc/RpcException.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java (100%) rename apis/{com.dbeaver.datadam.external.api => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java (100%) rename apis/{com.dbeaver.messaging => com.dbeaver.datadam.external-dam.api}/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java (100%) delete mode 100644 apis/com.dbeaver.datadam.external.api/build.properties delete mode 100644 apis/com.dbeaver.messaging/META-INF/MANIFEST.MF delete mode 100644 apis/com.dbeaver.messaging/pom.xml diff --git a/README.md b/README.md index d3ecb3e..6c504cf 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ This repository contains API for global DBeaver ecosystem. OSGI APIs and Service for DataDam integration Used by DBeaver, dbvr, CloudBeaver and by DataDam itself +## External DAM API + +[`apis/com.dbeaver.datadam.external-dam.api`](apis/com.dbeaver.datadam.external-dam.api/README.md) contains the External DAM +contracts, clients and transport adapters, together with the messaging implementation. Java packages remain +`com.dbeaver.datadam.external.api.*` and `com.dbeaver.messaging.*`; messaging is not a separate Maven or OSGi module. + ## SSO API `apis/com.dbeaver.datadam.sso.api` exports the shared SSO contracts in diff --git a/apis/com.dbeaver.messaging/README.md b/apis/com.dbeaver.datadam.external-dam.api/MESSAGING.md similarity index 96% rename from apis/com.dbeaver.messaging/README.md rename to apis/com.dbeaver.datadam.external-dam.api/MESSAGING.md index 113a53c..cd92714 100644 --- a/apis/com.dbeaver.messaging/README.md +++ b/apis/com.dbeaver.datadam.external-dam.api/MESSAGING.md @@ -1,8 +1,9 @@ # Messaging: request/reply and typed RPC -This module supplies a transport-independent request/reply endpoint for Java 21. It has no dependency on DataDam, -HTTP, WebSocket, JSON, Spring, or Eclipse runtime services. The only non-JDK production dependency supplies nullability -annotations. The `rpc` package adds ordinary Java interface calls over the same request/reply mechanism. +The `com.dbeaver.messaging.requestreply` and `com.dbeaver.messaging.rpc` packages live in the +`com.dbeaver.datadam.external-dam.api` module. They supply transport-independent request/reply and typed RPC for Java 21. +These packages do not depend on DataDam, HTTP, WebSocket, JSON, Spring, or Eclipse runtime services; their only non-JDK +production dependency supplies nullability annotations. DataDam adapters and codecs live in separate packages in the same module. ## Usage diff --git a/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF b/apis/com.dbeaver.datadam.external-dam.api/META-INF/MANIFEST.MF similarity index 72% rename from apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF rename to apis/com.dbeaver.datadam.external-dam.api/META-INF/MANIFEST.MF index cc45454..be5e7b9 100644 --- a/apis/com.dbeaver.datadam.external.api/META-INF/MANIFEST.MF +++ b/apis/com.dbeaver.datadam.external-dam.api/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Vendor: DBeaver Corp Bundle-Name: DataDam External DAM API -Bundle-SymbolicName: com.dbeaver.datadam.external.api +Bundle-SymbolicName: com.dbeaver.datadam.external-dam.api Bundle-Version: 0.0.17.qualifier Bundle-RequiredExecutionEnvironment: JavaSE-21 Export-Package: com.dbeaver.datadam.external.api, @@ -11,10 +11,11 @@ Export-Package: com.dbeaver.datadam.external.api, com.dbeaver.datadam.external.api.model, com.dbeaver.datadam.external.api.requestreply, com.dbeaver.datadam.external.api.rpc, - com.dbeaver.datadam.external.api.transport + com.dbeaver.datadam.external.api.transport, + com.dbeaver.messaging.requestreply, + com.dbeaver.messaging.rpc Bundle-ActivationPolicy: lazy Require-Bundle: org.jkiss.utils, - com.dbeaver.messaging;visibility:=reexport, com.dbeaver.rest.client;visibility:=reexport Import-Package: com.google.gson -Automatic-Module-Name: com.dbeaver.datadam.external.api +Automatic-Module-Name: com.dbeaver.datadam.external.dam.api diff --git a/apis/com.dbeaver.datadam.external.api/README.md b/apis/com.dbeaver.datadam.external-dam.api/README.md similarity index 97% rename from apis/com.dbeaver.datadam.external.api/README.md rename to apis/com.dbeaver.datadam.external-dam.api/README.md index cad6850..a79a7bf 100644 --- a/apis/com.dbeaver.datadam.external.api/README.md +++ b/apis/com.dbeaver.datadam.external-dam.api/README.md @@ -1,8 +1,12 @@ # External DAM API -This module contains the Desktop WebSocket contracts and a backend-only client for the External DAM internal GraphQL API. +The `com.dbeaver.datadam.external-dam.api` module contains the Desktop WebSocket contracts, a backend-only client for +the External DAM internal GraphQL API, and the transport-independent messaging packages. Neither the models nor the WebSocket protocol depend on the HTTP client, Spring, or Eclipse runtime services. +Java package names remain `com.dbeaver.datadam.external.api.*` and `com.dbeaver.messaging.*`. +See [Messaging](MESSAGING.md) for the request/reply and typed RPC contracts. + ## Internal GraphQL contract The backend client posts JSON requests to `/internal/graphql` on the configured External DAM service root URL. @@ -141,7 +145,7 @@ subsequent callbacks run on the notifying thread. Existing `addConnectionListene ## Request/reply -The independent `com.dbeaver.messaging` module supplies `RequestReplyEndpoint

`. It knows nothing about DataDam +The `com.dbeaver.messaging.requestreply` package supplies `RequestReplyEndpoint

`. It knows nothing about DataDam accounts, deployment IDs, GraphQL, or JSON. `DDRequestReplyTransport` adapts an existing scoped DataDam channel to `RequestReplyEndpoint` without changing the channel's asynchronous message API. @@ -246,7 +250,7 @@ the same topic are not service discovery. Replica routing and exact callback-pro `DDGsonRpcCodec` retains the supplied `Gson` instance, including application adapters and naming policies, and preserves the declared generic `Type` passed by RPC. Encoded JSON null is represented as a nullable application payload. The -neutral messaging module remains independent of Gson, and each proxy or registration can supply another compatible codec. +neutral messaging packages remain independent of Gson, and each proxy or registration can supply another compatible codec. The resulting `RpcEndpoint` is not parameterized by JSON or by any other payload representation. ### Ready endpoints in Account, Storage, and Desktop @@ -360,5 +364,5 @@ Cancellation of a returned send future does not free its slot until the underlyi Interfaces declare ordinary one-argument methods returning values or `void`; applications do not implement transport listeners. A `void` call waits for handler completion. Local serialization errors and transport failures become unchecked -`RpcException`; remote handler exceptions are represented only by safe error codes. See the messaging module's README +`RpcException`; remote handler exceptions are represented only by safe error codes. See [Messaging](MESSAGING.md) for supported contract shapes, ownership, concurrent codec/handler requirements, and timeout semantics. diff --git a/apis/com.dbeaver.messaging/build.properties b/apis/com.dbeaver.datadam.external-dam.api/build.properties similarity index 100% rename from apis/com.dbeaver.messaging/build.properties rename to apis/com.dbeaver.datadam.external-dam.api/build.properties diff --git a/apis/com.dbeaver.datadam.external.api/pom.xml b/apis/com.dbeaver.datadam.external-dam.api/pom.xml similarity index 89% rename from apis/com.dbeaver.datadam.external.api/pom.xml rename to apis/com.dbeaver.datadam.external-dam.api/pom.xml index e08fd3f..2681ff1 100644 --- a/apis/com.dbeaver.datadam.external.api/pom.xml +++ b/apis/com.dbeaver.datadam.external-dam.api/pom.xml @@ -10,16 +10,11 @@ ../pom.xml - com.dbeaver.datadam.external.api + com.dbeaver.datadam.external-dam.api eclipse-plugin DataDam External DAM API - - com.dbeaver.datadam - com.dbeaver.messaging - ${project.version} - com.dbeaver.common org.jkiss.utils diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java diff --git a/apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java similarity index 100% rename from apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java similarity index 100% rename from apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java similarity index 100% rename from apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java similarity index 100% rename from apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java similarity index 100% rename from apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java similarity index 100% rename from apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java similarity index 100% rename from apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java similarity index 100% rename from apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java similarity index 100% rename from apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java similarity index 100% rename from apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcException.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcException.java similarity index 100% rename from apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcException.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcException.java diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java similarity index 100% rename from apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java diff --git a/apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java similarity index 100% rename from apis/com.dbeaver.messaging/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java rename to apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java diff --git a/apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java similarity index 100% rename from apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java similarity index 100% rename from apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java similarity index 100% rename from apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java similarity index 100% rename from apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java similarity index 100% rename from apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java similarity index 100% rename from apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java similarity index 100% rename from apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java similarity index 100% rename from apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java similarity index 100% rename from apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java similarity index 100% rename from apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java diff --git a/apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java similarity index 100% rename from apis/com.dbeaver.messaging/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java rename to apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java diff --git a/apis/com.dbeaver.datadam.external.api/build.properties b/apis/com.dbeaver.datadam.external.api/build.properties deleted file mode 100644 index f0239d7..0000000 --- a/apis/com.dbeaver.datadam.external.api/build.properties +++ /dev/null @@ -1,4 +0,0 @@ -source.. = src/main/java -output.. = target/classes/ -bin.includes = ., - META-INF/ diff --git a/apis/com.dbeaver.messaging/META-INF/MANIFEST.MF b/apis/com.dbeaver.messaging/META-INF/MANIFEST.MF deleted file mode 100644 index 885d6fd..0000000 --- a/apis/com.dbeaver.messaging/META-INF/MANIFEST.MF +++ /dev/null @@ -1,12 +0,0 @@ -Manifest-Version: 1.0 -Bundle-ManifestVersion: 2 -Bundle-Vendor: DBeaver Corp -Bundle-Name: DBeaver Messaging -Bundle-SymbolicName: com.dbeaver.messaging -Bundle-Version: 0.0.17.qualifier -Bundle-RequiredExecutionEnvironment: JavaSE-21 -Export-Package: com.dbeaver.messaging.requestreply, - com.dbeaver.messaging.rpc -Bundle-ActivationPolicy: lazy -Require-Bundle: org.jkiss.utils -Automatic-Module-Name: com.dbeaver.messaging diff --git a/apis/com.dbeaver.messaging/pom.xml b/apis/com.dbeaver.messaging/pom.xml deleted file mode 100644 index ad55cc5..0000000 --- a/apis/com.dbeaver.messaging/pom.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - 4.0.0 - - com.dbeaver.datadam - api - 0.0.17-SNAPSHOT - ../pom.xml - - - com.dbeaver.messaging - eclipse-plugin - DBeaver Messaging - - - - com.dbeaver.common - org.jkiss.utils - - - org.junit.jupiter - junit-jupiter-api - test - - - org.junit.jupiter - junit-jupiter-engine - test - - - - - - - org.apache.maven.plugins - maven-surefire-plugin - - - test - test - - test - - - - - - - diff --git a/apis/pom.xml b/apis/pom.xml index 17217d8..c37b331 100644 --- a/apis/pom.xml +++ b/apis/pom.xml @@ -19,9 +19,8 @@ DataDam API - com.dbeaver.messaging com.dbeaver.datadam.dm.api - com.dbeaver.datadam.external.api + com.dbeaver.datadam.external-dam.api com.dbeaver.datadam.share.api com.dbeaver.datadam.lm.api com.dbeaver.datadam.sso.api From aaf1ad7557fd9c964b88bb6f682777948da7b59c Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Sat, 24 Oct 2026 00:08:51 +0400 Subject: [PATCH 17/30] dbeaver/pro#11108 Remove RPC topic subscription quota --- .../dbeaver/messaging/rpc/RpcEndpoint.java | 4 -- .../messaging/rpc/RpcEndpointFacadeTest.java | 40 ++++++++++++------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java index 3b7ac5f..ebc110f 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java @@ -124,7 +124,6 @@ public void close() { } private static final class State

{ - private static final int MAX_TOPICS = 64; private final Object lock = new Object(); private final RpcTransport

transport; @Nullable @@ -340,9 +339,6 @@ private Topic topic(@NotNull String name) { requireOpen(); selected = topics.get(name); if (selected == null) { - if (topics.size() >= MAX_TOPICS) { - throw new RpcException(RpcException.Reason.OVERLOADED); - } selected = new Topic(name); topics.put(name, selected); opening = true; diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java index d7d58c2..f8a42cb 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java @@ -393,25 +393,29 @@ void closeCompletionWaitsForLateOpeningAndItsSubscriptionRollback() throws Excep } @Test - void boundsCachedTopicsWithoutReservingDefaultServiceNames() { + void supportsMoreThanSixtyFourReceiverTopicsAndReusesSharedSubscriptions() { var transport = new MemoryTransport(); try (var endpoint = RpcEndpoint.open(transport, new TestCodec())) { - for (int index = 0; index < 64; index++) { - try (var registration = endpoint.register("topic." + index, TextService.class, value -> value)) { - endpoint.proxy("topic." + index, TextService.class); - } + for (int index = 0; index < 128; index++) { + endpoint.register("topic." + index, TextService.class, value -> value); + endpoint.proxy("topic." + index, TextService.class); } - assertEquals(64, transport.listeners.size()); - failure(RpcException.Reason.OVERLOADED, () -> endpoint.proxy("account", TextService.class)); - failure(RpcException.Reason.OVERLOADED, () -> endpoint.register("storage", TextService.class, value -> value)); + assertEquals(128, transport.listeners.size()); + endpoint.proxy("account", TextService.class); + try (var registration = endpoint.register("storage", TextService.class, value -> value)) { + endpoint.proxy("storage", TextService.class); + assertEquals(130, transport.listeners.size()); + } + endpoint.proxy("storage", TextService.class); endpoint.proxy("topic.0", TextService.class); - assertEquals(64, transport.listeners.size()); + assertEquals(130, transport.listeners.size()); } - assertEquals(64, transport.subscriptionCloses.get()); + assertEquals(130, transport.subscriptionCloses.get()); + assertTrue(transport.listeners.isEmpty()); } @Test - void topicCapacityIncludesOpeningsUntilTheirLateRollbackCompletes() throws Exception { + void supportsMoreThanSixtyFourProxyTopicsAndWaitsForLateOpeningRollback() throws Exception { var transport = new MemoryTransport(); var entered = new CountDownLatch(1); var release = new CountDownLatch(1); @@ -429,20 +433,26 @@ void topicCapacityIncludesOpeningsUntilTheirLateRollbackCompletes() throws Excep } return transport.forTopic(topic); }, new TestCodec(), OPTIONS)) { - for (int index = 0; index < 63; index++) { + for (int index = 0; index < 128; index++) { endpoint.proxy("topic." + index, TextService.class); } Future pending = async(() -> endpoint.proxy("reserved", TextService.class)); try { await(entered); - failure(RpcException.Reason.OVERLOADED, () -> endpoint.proxy("excess", TextService.class)); - assertEquals(64, openings.get()); + endpoint.proxy("independent", TextService.class); + endpoint.proxy("topic.0", TextService.class); + assertEquals(130, openings.get()); + assertEquals(129, transport.listeners.size()); endpoint.close(); + assertTrue(endpoint.isClosed()); assertFalse(endpoint.isCloseComplete()); + assertEquals(129, transport.subscriptionCloses.get()); + assertTrue(transport.listeners.isEmpty()); release.countDown(); failure(RpcException.Reason.CLOSED, pending); assertTrue(endpoint.isCloseComplete()); - assertEquals(64, transport.subscriptionCloses.get()); + assertEquals(130, transport.subscriptionCloses.get()); + assertTrue(transport.listeners.isEmpty()); } finally { release.countDown(); } From 79dc9c85d1b1cc2665058fa218ac92c457a3c4f7 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Sun, 25 Oct 2026 02:18:49 +0400 Subject: [PATCH 18/30] dbeaver/pro#11108 Decouple RPC execution and use nonblocking HTTP --- .../MESSAGING.md | 13 +- .../README.md | 41 +++- .../client/DDExternalDamCallbackClient.java | 41 +++- .../api/client/DDExternalDamClient.java | 157 ++++++++---- .../client/DDExternalDamGraphQlClient.java | 138 ++++++++++- .../requestreply/DDRequestReplyTransport.java | 15 +- .../external/api/rpc/DDRpcTransport.java | 26 ++ .../requestreply/RequestReplyEndpoint.java | 59 ++--- .../requestreply/RequestReplyTransport.java | 4 + .../DDExternalDamCallbackClientTest.java | 191 ++++++++++++++- .../api/client/DDExternalDamClientTest.java | 229 +++++++++++++++++- .../external/api/rpc/DDRpcTransportTest.java | 116 +++++++++ .../RequestReplyEndpointTest.java | 174 ++++++++++--- .../messaging/rpc/RpcEndpointTest.java | 25 ++ .../dbeaver/messaging/rpc/RpcTestSupport.java | 9 +- 15 files changed, 1085 insertions(+), 153 deletions(-) diff --git a/apis/com.dbeaver.datadam.external-dam.api/MESSAGING.md b/apis/com.dbeaver.datadam.external-dam.api/MESSAGING.md index cd92714..d9061af 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/MESSAGING.md +++ b/apis/com.dbeaver.datadam.external-dam.api/MESSAGING.md @@ -25,8 +25,9 @@ try (var requests = RequestReplyEndpoint.open(transport); ``` An endpoint can initiate and handle requests concurrently. `request` blocks the calling thread and returns the response -payload, or null for successful completion without a value. Use a worker or virtual thread. Incoming handlers run on -the endpoint's virtual threads, outside the transport listener. Future objects are internal. +payload, or null for successful completion without a value. Use a worker or virtual thread. Incoming handlers run +on the receiving thread by default. The endpoint owns no executor; the transport adapter chooses an execution +boundary when its receiving loop must remain nonblocking. Future objects are internal. `RequestReplyEndpoint

` leaves the payload representation to the application and transport adapter. For example, `P` can be a string, an immutable application value, or an encoded JSON object. The core does not copy arbitrary `P` @@ -79,6 +80,11 @@ Implement `RequestReplyTransport

` to integrate a message transport: destination. Correlation UUIDs do not replace authentication or account/peer authorization. - Disconnection is reported through `listener.onDisconnect()`. Incoming callbacks can be concurrent and can occur synchronously during subscription establishment or sending. +- `executeRequest(request)` runs admitted incoming requests inline by default, outside the endpoint lock. An adapter + can override it to use a borrowed executor or platform job service. It must execute the supplied task or throw + `RejectedExecutionException`; the core maps rejection to `OVERLOADED`. The core never closes that execution service. + Responses and disconnects do not use this dispatch hook. A serialized receiver must dispatch requests elsewhere + if handlers can wait synchronously for replies arriving through that same receiver. - Closing the returned `MessageSubscription` removes receiving routes and releases adapter-owned resources. It must leave a caller-owned physical connection available to other consumers. - The adapter owns wire validation, payload snapshots, message size limits, and bounded transport send work. It must @@ -135,7 +141,8 @@ subscriptions belong to the application. Proxy construction establishes local re the remote implementation or prove service presence. Opening a topic can establish subscriptions synchronously; use a worker thread when the adapter performs network I/O. -Proxy methods block the calling thread. Call them from worker or virtual threads, not a transport listener or event loop. +Proxy methods block the calling thread. They may run in concurrent HTTP request handlers or worker/virtual threads, +but not on an event loop or serialized receiver needed to deliver their replies. `RpcEndpoint` has no payload type parameter. Its transport factory supplies topic-scoped `RequestReplyTransport>` adapters. Each proxy and registration supplies a codec compatible with that diff --git a/apis/com.dbeaver.datadam.external-dam.api/README.md b/apis/com.dbeaver.datadam.external-dam.api/README.md index a79a7bf..2ff9a7e 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/README.md +++ b/apis/com.dbeaver.datadam.external-dam.api/README.md @@ -96,6 +96,11 @@ boolean written = client.sendMessage(accountId, connectionId, message); ``` Use one configured client per service endpoint instead of constructing one per request. No deployment-specific URL or secret is packaged here. +The client also exposes `getConnectionsAsync`, `getConnectionAccountAsync`, `sendMessageAsync`, +`registerSubscriptionAsync`, and `unregisterSubscriptionAsync`. These use the JDK HTTP client's nonblocking API and +return completion stages without a wrapper executor; synchronous methods remain available to ordinary callers. +The deadline covers the full HTTP exchange, including the response body. Cancelling a stage attempts to stop the +HTTP exchange, but does not promise cancellation of an operation already running on the receiving service. ## Backend callback subscriptions @@ -114,10 +119,15 @@ callbacks; restrict the broker's outbound network access and use HTTPS outside a `DDExternalDamCallbackClient` delivers messages to the receiver's authenticated `POST /internal/external-dam/graphql` using the same External DAM service secret. Successful acknowledgement extends the -lease. The acknowledgement does not describe local subscriber presence or business processing. Local cancellation +lease. The receiver calls its listener directly on the HTTP request thread and acknowledges after it returns. +Concurrent callbacks may invoke the same listener concurrently and have no ordering guarantee; implementations must +be thread-safe. An RPC reply, not the HTTP acknowledgement, carries the business result. Local cancellation uses `unregisterSubscription`, including cleanup when the receiver no longer recognizes an incoming registration. -External DAM discards queued work on cancellation or expiry; an already claimed callback may finish. -Messages are not retried or replayed after callback failure. Callback queues and workers have configured bounds. +External DAM stops starting callbacks on cancellation or expiry; an already started callback may finish. +Messages are not retried or replayed after callback failure. The broker bounds in-flight callbacks per subscription; +neither side creates a delivery executor or per-subscription mailbox. `deliverAsync` sends callbacks using nonblocking +HTTP. Its default deadline is 30 seconds, including listener execution, and an explicit duration remains supported. +Timeout does not promise cancellation of the remote handler. Spring services use the shared `com.dbeaver.datadam.external.base` module from the DataDam server repository. It supplies the callback endpoint, local dispatch, `DDSubscription.close()` cancellation, and automatic renewal @@ -183,14 +193,14 @@ The bridge uses message type `datadam.requestreply.v1`. Its envelope contains `r for a maximum-length request topic. Closing the endpoint cancels both topic subscriptions. `request` blocks a worker or virtual thread. The request/reply result acknowledges remote handler completion; -ordinary channel sends and callback GraphQL acknowledgements retain their existing transport-only meaning. The backend -adapter uses bounded asynchronous sending around its blocking HTTP client. It keeps the existing in-memory callback -subscriptions and lease renewal, and exposes request/reply only for `forConnection`, with no implicit connection +ordinary channel sends and callback GraphQL acknowledgements have transport-only meaning. The backend +adapter uses nonblocking HTTP with at most 128 sends in flight, without a send pool or waiting queue. It keeps the existing +in-memory callback subscriptions and lease renewal, and exposes request/reply only for `forConnection`, with no implicit connection selection from account-wide subscriptions. Closing the request/reply endpoint cancels its receiving subscriptions and owned work, without closing the shared -channel or subscription facade. Closing the subscription facade rejects new backend sends and prevents queued sends -from starting; an already claimed HTTP send can finish. Physical channels report disconnects immediately; backend +channel or subscription facade. Closing the subscription facade rejects new backend sends and invalidates later send +completions; an already started HTTP send can finish. Physical channels report disconnects immediately; backend callbacks have no physical connection-state feed, so pending calls use their deadlines when the peer disappears. Timeout or interruption can leave the remote outcome unknown. No request or response is automatically retried. @@ -200,12 +210,15 @@ If a new physical source is visible before its state observer runs, the adapter that source's first message. Delayed observers read the current channel status instead of reverting to an old source. The mailbox holds at most 128 queued callbacks and adds no executor. Excess frames are dropped; a disconnection takes priority over a full queued batch so pending calls are still invalidated. Listener failure closes the receiving routes. +Backend callback adapters have no serialized receiving mailbox and invoke listeners directly on concurrent HTTP threads. +The neutral request/reply endpoint owns no executor. A low-level physical-channel user must keep blocking handlers +off a serialized receiver when they can make synchronous calls whose replies need that same receiver. ## Typed RPC `com.dbeaver.messaging.rpc` adds typed Java proxies and registered implementations without depending on DataDam or WebSocket. `DDRpcTransport` adapts the existing request/reply transport to the neutral `RpcMessage` envelope. -It contains only protocol framing; application serialization is selected by the developer through `RpcCodec`. +It owns no execution service or application serializer; the developer selects serialization through `RpcCodec`. For example, a plugin can supply its own Gson instance and adapters for its proxies and implementations: @@ -217,13 +230,18 @@ RpcCodec codec = new DDGsonRpcCodec(gson); The Desktop side installs an implementation and can independently obtain proxies for backend services: ```java -try (var rpc = RpcEndpoint.open(topic -> DDRpcTransport.forChannel(channel, topic)); +try (var rpc = RpcEndpoint.open(topic -> DDRpcTransport.forChannel(channel, topic, requestExecutor)); var registration = rpc.register("desktop.projects", DesktopProjects.class, implementation, codec)) { ServerDocuments documents = rpc.proxy("server.documents", ServerDocuments.class, codec); serveApplication(documents); } ``` +`requestExecutor` is a borrowed application/platform execution service, used only for admitted incoming requests. +Without that argument, the adapter uses its receiving thread. Responses and disconnects are never dispatched through +the request executor. Prefer the ready Desktop connection service below; it uses Eclipse system Jobs so blocking +handlers do not stall WebSocket reply delivery, without creating RPC-owned thread pools. + The backend selects its physical account/connection scope once and uses the same endpoint API: ```java @@ -349,6 +367,9 @@ caller. Desktop retains endpoints and registrations across transient reconnects, authentication identity change and on shutdown. Backend owners close missing physical connections after a successful registry reconciliation; failed registry reads preserve existing endpoints. Applications can explicitly release a backend scope with `rpcConnections.closeConnection(selected.connectionId())`. +Backend owners share asynchronous opening results while callers wait with a timeout. Idle reconciliation and +subscription renewal run through Spring scheduling, not owner-created executors. Desktop authorization expiry and +transport lifecycle jobs remain active even without business requests. Replacing a closed endpoint does not transfer its old registrations. After an identity change or explicit closure, obtain the new endpoint and register the appropriate implementations again. Backend connection owners and caches are diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java index a79b6de..cc07c09 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java @@ -22,11 +22,13 @@ import com.dbeaver.datadam.external.api.model.DDMessage; import com.google.gson.JsonElement; import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; import java.time.Duration; import java.util.Map; import java.util.Objects; import java.util.UUID; +import java.util.concurrent.CompletionStage; public final class DDExternalDamCallbackClient extends DDExternalDamGraphQlClient { private static final String DELIVER_MUTATION = """ @@ -37,7 +39,7 @@ mutation DeliverExternalDamMessage($subscriptionId: ID!, $accountId: ID!, """; public DDExternalDamCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret) { - this(serviceUrl, serviceSecret, Duration.ofSeconds(5)); + this(serviceUrl, serviceSecret, Duration.ofSeconds(30)); } public DDExternalDamCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull Duration deliveryTimeout) { @@ -50,17 +52,42 @@ public void deliver( @NotNull DDConnectionInfo connection, @NotNull DDMessage message ) throws DDExternalDamException { - JsonElement result = request("DeliverExternalDamMessage", DELIVER_MUTATION, Map.of( + readDeliveryResult(request("DeliverExternalDamMessage", DELIVER_MUTATION, + deliveryVariables(subscriptionId, accountId, connection, message), "deliverExternalDamMessage")); + } + + @NotNull + public CompletionStage deliverAsync( + @NotNull UUID subscriptionId, + @NotNull UUID accountId, + @NotNull DDConnectionInfo connection, + @NotNull DDMessage message + ) { + return requestAsync("DeliverExternalDamMessage", DELIVER_MUTATION, + deliveryVariables(subscriptionId, accountId, connection, message), "deliverExternalDamMessage", + DDExternalDamCallbackClient::readDeliveryResult); + } + + @NotNull + private static Map deliveryVariables( + @NotNull UUID subscriptionId, + @NotNull UUID accountId, + @NotNull DDConnectionInfo connection, + @NotNull DDMessage message + ) { + return Map.of( "subscriptionId", Objects.requireNonNull(subscriptionId).toString(), "accountId", Objects.requireNonNull(accountId).toString(), "connection", Objects.requireNonNull(connection), "message", Objects.requireNonNull(message) - ), "deliverExternalDamMessage"); - if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isBoolean()) { - throw invalidResponse(); - } - if (!result.getAsBoolean()) { + ); + } + + @Nullable + private static Void readDeliveryResult(@NotNull JsonElement result) throws DDExternalDamException { + if (!readBoolean(result)) { throw new DDExternalDamException("External DAM callback was not accepted"); } + return null; } private static int timeoutMillis(@NotNull Duration timeout) { diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java index 5a481c3..284c8b3 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java @@ -31,6 +31,7 @@ import java.util.Map; import java.util.Objects; import java.util.UUID; +import java.util.concurrent.CompletionStage; public final class DDExternalDamClient extends DDExternalDamGraphQlClient { private static final int READ_TIMEOUT_MILLIS = 10_000; @@ -74,8 +75,85 @@ public DDExternalDamClient(@NotNull String serviceUrl, @NotNull String serviceSe @NotNull public List getConnections(@NotNull UUID accountId) throws DDExternalDamException { - JsonElement result = request(CONNECTIONS_OPERATION, CONNECTIONS_QUERY, - Map.of("accountId", Objects.requireNonNull(accountId).toString()), CONNECTIONS_FIELD); + return readConnections(request(CONNECTIONS_OPERATION, CONNECTIONS_QUERY, + Map.of("accountId", Objects.requireNonNull(accountId).toString()), CONNECTIONS_FIELD)); + } + + @NotNull + public CompletionStage> getConnectionsAsync(@NotNull UUID accountId) { + return requestAsync(CONNECTIONS_OPERATION, CONNECTIONS_QUERY, + Map.of("accountId", Objects.requireNonNull(accountId).toString()), CONNECTIONS_FIELD, DDExternalDamClient::readConnections); + } + + @NotNull + public UUID getConnectionAccount(@NotNull String connectionId) throws DDExternalDamException { + return readConnectionAccount(request(CONNECTION_ACCOUNT_OPERATION, CONNECTION_ACCOUNT_QUERY, + connectionAccountVariables(connectionId), CONNECTION_ACCOUNT_FIELD)); + } + + @NotNull + public CompletionStage getConnectionAccountAsync(@NotNull String connectionId) { + return requestAsync(CONNECTION_ACCOUNT_OPERATION, CONNECTION_ACCOUNT_QUERY, + connectionAccountVariables(connectionId), CONNECTION_ACCOUNT_FIELD, DDExternalDamClient::readConnectionAccount); + } + + public boolean sendMessage(@NotNull UUID accountId, @NotNull String connectionId, @NotNull DDMessage message) + throws DDExternalDamException { + return readBoolean(request(SEND_OPERATION, SEND_MUTATION, messageVariables(accountId, connectionId, message), SEND_FIELD)); + } + + @NotNull + public CompletionStage sendMessageAsync( + @NotNull UUID accountId, + @NotNull String connectionId, + @NotNull DDMessage message + ) { + return requestAsync(SEND_OPERATION, SEND_MUTATION, messageVariables(accountId, connectionId, message), SEND_FIELD, + DDExternalDamGraphQlClient::readBoolean); + } + + public int registerSubscription( + @NotNull String subscriberId, + @NotNull UUID subscriptionId, + @NotNull UUID accountId, + @Nullable String connectionId, + @NotNull String topic, + @NotNull String callbackUrl, + @NotNull String callbackSecret + ) throws DDExternalDamException { + return readLease(request("RegisterExternalDamSubscription", REGISTER_MUTATION, + subscriptionVariables(subscriberId, subscriptionId, accountId, connectionId, topic, callbackUrl, callbackSecret), + "registerExternalDamSubscription")); + } + + @NotNull + public CompletionStage registerSubscriptionAsync( + @NotNull String subscriberId, + @NotNull UUID subscriptionId, + @NotNull UUID accountId, + @Nullable String connectionId, + @NotNull String topic, + @NotNull String callbackUrl, + @NotNull String callbackSecret + ) { + return requestAsync("RegisterExternalDamSubscription", REGISTER_MUTATION, + subscriptionVariables(subscriberId, subscriptionId, accountId, connectionId, topic, callbackUrl, callbackSecret), + "registerExternalDamSubscription", DDExternalDamClient::readLease); + } + + public boolean unregisterSubscription(@NotNull String subscriberId, @NotNull UUID subscriptionId) throws DDExternalDamException { + return readBoolean(request("UnregisterExternalDamSubscription", UNREGISTER_MUTATION, + unsubscriptionVariables(subscriberId, subscriptionId), "unregisterExternalDamSubscription")); + } + + @NotNull + public CompletionStage unregisterSubscriptionAsync(@NotNull String subscriberId, @NotNull UUID subscriptionId) { + return requestAsync("UnregisterExternalDamSubscription", UNREGISTER_MUTATION, + unsubscriptionVariables(subscriberId, subscriptionId), "unregisterExternalDamSubscription", DDExternalDamGraphQlClient::readBoolean); + } + + @NotNull + private static List readConnections(@NotNull JsonElement result) throws DDExternalDamException { if (!result.isJsonArray()) { throw invalidResponse(); } @@ -91,12 +169,7 @@ public List getConnections(@NotNull UUID accountId) thr } @NotNull - public UUID getConnectionAccount(@NotNull String connectionId) throws DDExternalDamException { - if (connectionId.isBlank() || connectionId.length() > 256) { - throw new IllegalArgumentException("Invalid connection ID"); - } - JsonElement result = request(CONNECTION_ACCOUNT_OPERATION, CONNECTION_ACCOUNT_QUERY, - Map.of("connectionId", connectionId), CONNECTION_ACCOUNT_FIELD); + private static UUID readConnectionAccount(@NotNull JsonElement result) throws DDExternalDamException { if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isString()) { throw invalidResponse(); } @@ -112,22 +185,43 @@ public UUID getConnectionAccount(@NotNull String connectionId) throws DDExternal return accountId; } - public boolean sendMessage(@NotNull UUID accountId, @NotNull String connectionId, @NotNull DDMessage message) - throws DDExternalDamException { + private static int readLease(@NotNull JsonElement result) throws DDExternalDamException { + if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isNumber()) { + throw invalidResponse(); + } + try { + int leaseSeconds = result.getAsBigDecimal().intValueExact(); + if (leaseSeconds >= DDExternalDamApi.MIN_SUBSCRIPTION_LEASE_SECONDS + && leaseSeconds <= DDExternalDamApi.MAX_SUBSCRIPTION_LEASE_SECONDS) { + return leaseSeconds; + } + } catch (ArithmeticException | NumberFormatException e) { + throw invalidResponse(); + } + throw invalidResponse(); + } + + @NotNull + private static Map connectionAccountVariables(@NotNull String connectionId) { + if (connectionId.isBlank() || connectionId.length() > 256) { + throw new IllegalArgumentException("Invalid connection ID"); + } + return Map.of("connectionId", connectionId); + } + + @NotNull + private static Map messageVariables(@NotNull UUID accountId, @NotNull String connectionId, @NotNull DDMessage message) { if (connectionId.isBlank()) { throw new IllegalArgumentException("A connection ID is required"); } - JsonElement result = request(SEND_OPERATION, SEND_MUTATION, Map.of( + return Map.of( "accountId", Objects.requireNonNull(accountId).toString(), "connectionId", connectionId, "message", Objects.requireNonNull(message) - ), SEND_FIELD); - if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isBoolean()) { - throw invalidResponse(); - } - return result.getAsBoolean(); + ); } - public int registerSubscription( + @NotNull + private static Map subscriptionVariables( @NotNull String subscriberId, @NotNull UUID subscriptionId, @NotNull UUID accountId, @@ -135,7 +229,7 @@ public int registerSubscription( @NotNull String topic, @NotNull String callbackUrl, @NotNull String callbackSecret - ) throws DDExternalDamException { + ) { requireSubscriberId(subscriberId); requireText(topic); if (connectionId != null) { @@ -155,32 +249,13 @@ public int registerSubscription( input.put("topic", topic); input.put("callbackUrl", callbackUrl); input.put("callbackSecret", callbackSecret); - JsonElement result = request("RegisterExternalDamSubscription", REGISTER_MUTATION, - Map.of("input", input), "registerExternalDamSubscription"); - if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isNumber()) { - throw invalidResponse(); - } - try { - int leaseSeconds = result.getAsBigDecimal().intValueExact(); - if (leaseSeconds >= DDExternalDamApi.MIN_SUBSCRIPTION_LEASE_SECONDS - && leaseSeconds <= DDExternalDamApi.MAX_SUBSCRIPTION_LEASE_SECONDS) { - return leaseSeconds; - } - } catch (ArithmeticException | NumberFormatException e) { - throw invalidResponse(); - } - throw invalidResponse(); + return Map.of("input", input); } - public boolean unregisterSubscription(@NotNull String subscriberId, @NotNull UUID subscriptionId) throws DDExternalDamException { + @NotNull + private static Map unsubscriptionVariables(@NotNull String subscriberId, @NotNull UUID subscriptionId) { requireSubscriberId(subscriberId); - JsonElement result = request("UnregisterExternalDamSubscription", UNREGISTER_MUTATION, - Map.of("subscriberId", subscriberId, "subscriptionId", Objects.requireNonNull(subscriptionId).toString()), - "unregisterExternalDamSubscription"); - if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isBoolean()) { - throw invalidResponse(); - } - return result.getAsBoolean(); + return Map.of("subscriberId", subscriberId, "subscriptionId", Objects.requireNonNull(subscriptionId).toString()); } @NotNull diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java index 9bb6947..5fccae5 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java @@ -33,12 +33,22 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; abstract class DDExternalDamGraphQlClient extends AbstractRestClient { private final String serviceSecret; private final String endpoint; + private final Duration requestTimeout; DDExternalDamGraphQlClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull String endpoint, int timeoutMillis) { super(validateServiceUrl(serviceUrl), Math.min(DEFAULT_CONNECT_TIMEOUT, timeoutMillis), timeoutMillis, @@ -48,6 +58,7 @@ abstract class DDExternalDamGraphQlClient extends AbstractRestClient { } this.serviceSecret = serviceSecret; this.endpoint = endpoint; + this.requestTimeout = Duration.ofMillis(timeoutMillis > 0 ? timeoutMillis : DEFAULT_READ_TIMEOUT); } @NotNull @@ -59,19 +70,96 @@ final JsonElement request( ) throws DDExternalDamException { String response; try { - HttpRequest.Builder request = HttpRequest.newBuilder(buildUri(CommonUtils.removeLeadingSlash(endpoint), Map.of())) - .header(DDExternalDamApi.HEADER_SERVICE_TOKEN, serviceSecret) - .header(HttpConstants.HEADER_CONTENT_TYPE, MediaType.JSON.toString()) - .header(HttpConstants.HEADER_ACCEPT, MediaType.JSON.toString()) - .header(HttpConstants.HEADER_CACHE_CONTROL, HttpConstants.CACHE_CONTROL_NO_STORE) - .POST(createBodyPublisher(Map.of("operationName", operationName, "query", query, "variables", variables), MediaType.JSON)); - response = execute(request, String.class); + response = execute(createRequest(operationName, query, variables), String.class); } catch (DBException e) { - if (e instanceof DDExternalDamException failure) { - throw failure; - } - throw new DDExternalDamException("External DAM service request failed", e); + throw requestFailure(e); } + return readResult(response, field); + } + + @NotNull + final CompletionStage requestAsync( + @NotNull String operationName, + @NotNull String query, + @NotNull Map variables, + @NotNull String field + ) { + try { + HttpRequest request = createRequest(operationName, query, variables).build(); + var exchange = getHttpClient().sendAsync(request, BodyHandlers.ofString(StandardCharsets.UTF_8)); + var bounded = exchange.copy().orTimeout(requestTimeout.toMillis(), TimeUnit.MILLISECONDS); + var completed = bounded.whenComplete((response, error) -> { + if (error instanceof TimeoutException || bounded.isCancelled()) { + exchange.cancel(true); + } + }); + return propagateCancellation(completed.handle((response, error) -> { + if (error != null) { + throw new CompletionException(requestFailure(error)); + } + try { + int code = response.statusCode(); + if (code < 200 || code >= 300) { + throw mapErrorResponse(code, "", request.uri()); + } + return readResult(response.body(), field); + } catch (DDExternalDamException e) { + throw new CompletionException(e); + } + }), bounded); + } catch (DBException | RuntimeException e) { + return CompletableFuture.failedFuture(requestFailure(e)); + } + } + + @NotNull + final CompletionStage requestAsync( + @NotNull String operationName, + @NotNull String query, + @NotNull Map variables, + @NotNull String field, + @NotNull ResultReader reader + ) { + CompletableFuture response = requestAsync(operationName, query, variables, field).toCompletableFuture(); + return propagateCancellation(response.thenApply(result -> { + try { + return reader.read(result); + } catch (DDExternalDamException e) { + throw new CompletionException(e); + } + }), response); + } + + @NotNull + private static CompletionStage propagateCancellation( + @NotNull CompletableFuture result, + @NotNull CompletableFuture source + ) { + result.whenComplete((value, error) -> { + if (result.isCancelled()) { + source.cancel(true); + } + }); + return result; + } + + @NotNull + private HttpRequest.Builder createRequest( + @NotNull String operationName, + @NotNull String query, + @NotNull Map variables + ) throws DBException { + return HttpRequest.newBuilder(buildUri(CommonUtils.removeLeadingSlash(endpoint), Map.of())) + .timeout(requestTimeout) + .header(DDExternalDamApi.HEADER_SERVICE_TOKEN, serviceSecret) + .header(HttpConstants.HEADER_CONTENT_TYPE, MediaType.JSON.toString()) + .header(HttpConstants.HEADER_ACCEPT, MediaType.JSON.toString()) + .header(HttpConstants.HEADER_CACHE_CONTROL, HttpConstants.CACHE_CONTROL_NO_STORE) + .POST(createBodyPublisher(Map.of("operationName", operationName, "query", query, "variables", variables), MediaType.JSON)); + } + + @NotNull + private static JsonElement readResult(@NotNull String response, @NotNull String field) throws DDExternalDamException { JsonObject envelope; try { envelope = readObject(JsonParser.parseString(response)); @@ -91,10 +179,17 @@ final JsonElement request( @Override @NotNull - protected DBException mapErrorResponse(int code, @NotNull String message, @NotNull URI uri) { + protected DDExternalDamException mapErrorResponse(int code, @NotNull String message, @NotNull URI uri) { return new DDExternalDamException("External DAM request failed: HTTP " + code); } + static boolean readBoolean(@NotNull JsonElement value) throws DDExternalDamException { + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) { + throw invalidResponse(); + } + return value.getAsBoolean(); + } + @NotNull static JsonObject readObject(@Nullable JsonElement value) throws DDExternalDamException { if (value == null || !value.isJsonObject()) { @@ -108,6 +203,20 @@ static DDExternalDamException invalidResponse() { return new DDExternalDamException("Invalid External DAM response"); } + @NotNull + private static DDExternalDamException requestFailure(@NotNull Throwable error) { + while (error instanceof CompletionException && error.getCause() != null) { + error = error.getCause(); + } + if (error instanceof TimeoutException) { + HttpTimeoutException timeout = new HttpTimeoutException("External DAM service request timed out"); + timeout.initCause(error); + error = timeout; + } + return error instanceof DDExternalDamException failure + ? failure : new DDExternalDamException("External DAM service request failed", error); + } + @NotNull private static String validateServiceUrl(@NotNull String serviceUrl) { if (!DDExternalDamApi.isValidServiceUrl(serviceUrl)) { @@ -115,4 +224,9 @@ private static String validateServiceUrl(@NotNull String serviceUrl) { } return serviceUrl; } + + @FunctionalInterface + interface ResultReader { + T read(@NotNull JsonElement result) throws DDExternalDamException; + } } diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java index 9b2ff48..f662a10 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java @@ -347,10 +347,21 @@ private void receive(@NotNull DDConnectionInfo connection, @NotNull DDMessage me return; } drain = updateState(current); + drain |= enqueue(frame); } - drain |= enqueue(frame); } - if (drain) { + if (channel == null) { + try { + listener.onMessage(frame); + } catch (RuntimeException | Error e) { + try { + close(); + } catch (RuntimeException closeFailure) { + e.addSuppressed(closeFailure); + } + throw e; + } + } else if (drain) { drain(); } } diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java index 66f4bfe..9c6271c 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java @@ -34,6 +34,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; import java.util.concurrent.Semaphore; public final class DDRpcTransport implements RequestReplyTransport> { @@ -46,9 +47,16 @@ public final class DDRpcTransport implements RequestReplyTransport delegate; + @Nullable + private final Executor requestExecutor; public DDRpcTransport(@NotNull RequestReplyTransport delegate) { + this(delegate, null); + } + + public DDRpcTransport(@NotNull RequestReplyTransport delegate, @Nullable Executor requestExecutor) { this.delegate = Objects.requireNonNull(delegate); + this.requestExecutor = requestExecutor; } @NotNull @@ -62,6 +70,24 @@ public static DDRpcTransport forChannel(@NotNull DDMessageChannel channel, @NotN return new DDRpcTransport(DDRequestReplyTransport.forChannel(channel, topic)); } + @NotNull + public static DDRpcTransport forChannel( + @NotNull DDMessageChannel channel, + @NotNull String topic, + @NotNull Executor requestExecutor + ) { + return new DDRpcTransport(DDRequestReplyTransport.forChannel(channel, topic), Objects.requireNonNull(requestExecutor)); + } + + @Override + public void executeRequest(@NotNull Runnable request) { + if (requestExecutor == null) { + delegate.executeRequest(request); + } else { + requestExecutor.execute(request); + } + } + @Override @NotNull public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java index 45eb6ad..2aabe44 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java @@ -28,8 +28,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.FutureTask; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; @@ -47,7 +45,6 @@ public final class RequestReplyEndpoint

implements AutoCloseable { private final LongSupplier nanoTime; private final Map pending = new HashMap<>(); private final Map incoming = new HashMap<>(); - private final ExecutorService handlers = Executors.newVirtualThreadPerTaskExecutor(); private final MessageSubscription subscription; @Nullable private HandlerRegistration handler; @@ -62,22 +59,17 @@ public final class RequestReplyEndpoint

implements AutoCloseable { this.transport = Objects.requireNonNull(transport); this.options = Objects.requireNonNull(options); this.nanoTime = Objects.requireNonNull(nanoTime); - try { - this.subscription = Objects.requireNonNull(transport.subscribe(endpointId, new RequestReplyTransport.Listener<>() { - @Override - public void onMessage(@NotNull RequestReplyMessage

message) { - receive(message); - } + this.subscription = Objects.requireNonNull(transport.subscribe(endpointId, new RequestReplyTransport.Listener<>() { + @Override + public void onMessage(@NotNull RequestReplyMessage

message) { + receive(message); + } - @Override - public void onDisconnect() { - disconnect(); - } - })); - } catch (RequestReplyException | RuntimeException e) { - handlers.shutdownNow(); - throw e; - } + @Override + public void onDisconnect() { + disconnect(); + } + })); } @NotNull @@ -214,7 +206,8 @@ private void receive(@NotNull RequestReplyMessage

message) { } private void accept(@NotNull RequestReplyMessage

message) { - RequestReplyMessage.Error rejection; + Incoming accepted = null; + RequestReplyMessage.Error rejection = RequestReplyMessage.Error.OVERLOADED; synchronized (lock) { if (closed) { return; @@ -225,17 +218,19 @@ private void accept(@NotNull RequestReplyMessage

message) { } if (handler == null) { rejection = RequestReplyMessage.Error.NO_HANDLER; - } else if (incoming.size() >= options.maxConcurrentHandlers()) { - rejection = RequestReplyMessage.Error.OVERLOADED; - } else { - Incoming accepted = new Incoming(key, message, handler.requestHandler, generation); + } else if (incoming.size() < options.maxConcurrentHandlers()) { + accepted = new Incoming(key, message, handler.requestHandler, generation); incoming.put(key, accepted); - try { - handlers.execute(accepted); - return; - } catch (RejectedExecutionException e) { - incoming.remove(key, accepted); - rejection = RequestReplyMessage.Error.OVERLOADED; + } + } + if (accepted != null) { + try { + transport.executeRequest(accepted); + return; + } catch (RejectedExecutionException e) { + accepted.task.cancel(false); + synchronized (lock) { + incoming.remove(accepted.key, accepted); } } } @@ -282,11 +277,7 @@ public void close() { stopped = List.copyOf(incoming.values()); } stopped.forEach(request -> request.task.cancel(true)); - try { - subscription.close(); - } finally { - handlers.shutdownNow(); - } + subscription.close(); } private record RequestKey(@NotNull UUID replyTo, @NotNull UUID requestId) { diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java index b162362..88cb6cd 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java @@ -29,6 +29,10 @@ public interface RequestReplyTransport

{ @NotNull CompletionStage send(@NotNull RequestReplyMessage

message); + default void executeRequest(@NotNull Runnable request) { + request.run(); + } + /** Receives endpoint messages and disconnection notifications. */ interface Listener

{ void onMessage(@NotNull RequestReplyMessage

message); diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java index 85644c5..ee50441 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java @@ -32,17 +32,29 @@ import org.junit.jupiter.api.Timeout; import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; import java.net.InetSocketAddress; +import java.net.http.HttpTimeoutException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -53,12 +65,17 @@ class DDExternalDamCallbackClientTest { private static final UUID ACCOUNT_ID = UUID.randomUUID(); private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("deployment", "run", "socket"); private static final DDMessage MESSAGE = new DDMessage("projects", "updated", - JsonParser.parseString("{\"nested\":{\"value\":42}}").getAsJsonObject()); + JsonParser.parseString(""" + {"nested":{"value":42},"nothing":null,"integer":9007199254740993, + "decimal":1.2345678901234567890123456789,"text":"Привет"} + """).getAsJsonObject()); private final AtomicReference response = new AtomicReference<>("{\"data\":{\"deliverExternalDamMessage\":true}}"); private final AtomicInteger status = new AtomicInteger(200); private final AtomicInteger requestCount = new AtomicInteger(); private final AtomicInteger responseDelay = new AtomicInteger(); private final AtomicReference request = new AtomicReference<>(); + private final CountDownLatch requestReceived = new CountDownLatch(1); + private final AtomicReference responseGate = new AtomicReference<>(); private HttpServer server; private DDExternalDamCallbackClient client; @@ -76,11 +93,14 @@ void stop() { } @Test - void deliversTheExactCallbackWireWithIsolatedCredentialsAndAnOpaquePayload() throws DDExternalDamException { + void deliversTheExactCallbackWireWithIsolatedCredentialsAndAnOpaquePayload() throws Exception { for (String suffix : List.of("", "/")) { client = new DDExternalDamCallbackClient(url() + suffix, SECRET); deliver(); + CapturedRequest synchronous = request.get(); + assertNull(deliverAsync().toCompletableFuture().get(5, TimeUnit.SECONDS)); CapturedRequest captured = request.get(); + assertEquals(synchronous, captured); assertEquals("POST", captured.method()); assertEquals("/internal/external-dam/graphql", captured.path()); assertEquals(SECRET, captured.secret()); @@ -100,24 +120,73 @@ void deliversTheExactCallbackWireWithIsolatedCredentialsAndAnOpaquePayload() thr assertEquals(Set.of("deploymentId", "applicationRunId", "connectionId"), variables.getAsJsonObject("connection").keySet()); assertEquals("socket", variables.getAsJsonObject("connection").get("connectionId").getAsString()); assertEquals(MESSAGE.payload(), variables.getAsJsonObject("message").get("payload")); + JsonObject payload = variables.getAsJsonObject("message").getAsJsonObject("payload"); + assertEquals("9007199254740993", payload.get("integer").getAsString()); + assertEquals("1.2345678901234567890123456789", payload.get("decimal").getAsString()); + assertTrue(payload.get("nothing").isJsonNull()); assertFalse(body.toString().contains(SECRET)); } + assertEquals(4, requestCount.get()); + } + + @Test + void returnsBeforeTheCallbackHandlerCompletesAndWaitsForItsResponse() throws Exception { + CountDownLatch release = new CountDownLatch(1); + CountDownLatch returned = new CountDownLatch(1); + AtomicReference> stage = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + responseGate.set(release); + Thread caller = new Thread(() -> { + try { + stage.set(deliverAsync()); + } catch (Throwable e) { + failure.set(e); + } finally { + returned.countDown(); + } + }); + caller.start(); + try { + assertTrue(requestReceived.await(5, TimeUnit.SECONDS)); + assertTrue(returned.await(1, TimeUnit.SECONDS)); + assertNull(failure.get()); + assertNotNull(stage.get()); + assertFalse(stage.get().toCompletableFuture().isDone()); + release.countDown(); + assertNull(stage.get().toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertEquals(1, requestCount.get()); + } finally { + release.countDown(); + caller.join(5000); + } + } + + @Test + void allowsDefaultAsyncDeliveryToWaitLongerThanFiveSecondsForTheHandler() throws Exception { + responseDelay.set(5500); + + assertNull(deliverAsync().toCompletableFuture().get(8, TimeUnit.SECONDS)); + + assertEquals(1, requestCount.get()); } @Test void rejectsFalseAcknowledgementsAsProtocolFailures() { response.set("{\"data\":{\"deliverExternalDamMessage\":false}}"); assertFailureOnce(); + DDExternalDamException failure = assertAsyncFailureOnce(); + assertEquals("External DAM callback was not accepted", failure.getMessage()); } @Test void rejectsMalformedEnvelopesPartialErrorsAndNonBooleanResultsWithoutRetries() { for (String body : List.of("", "{", "null", "[]", "{}", "{\"data\":null}", "{\"data\":{}}", "{\"data\":{\"deliverExternalDamMessage\":null}}", "{\"data\":{\"deliverExternalDamMessage\":\"true\"}}", - "{\"data\":{\"deliverExternalDamMessage\":1}}", "{\"errors\":null}", + "{\"data\":{\"deliverExternalDamMessage\":1}}", "{\"errors\":null}", "{\"" + SECRET + "\":[", "{\"data\":{\"deliverExternalDamMessage\":true},\"errors\":[{\"message\":\"" + SECRET + "\"}]}")) { response.set(body); assertFailureOnce(); + assertAsyncFailureOnce(); } } @@ -126,6 +195,8 @@ void rejectsHttpFailuresAndNeverFollowsRedirects() { for (int code : List.of(301, 302, 303, 307, 308, 400, 401, 403, 429, 500, 503)) { status.set(code); assertFailureOnce(); + DDExternalDamException failure = assertAsyncFailureOnce(); + assertEquals("External DAM request failed: HTTP " + code, failure.getMessage()); } } @@ -136,6 +207,36 @@ void boundsDeliveryTimeAndDoesNotRetryTimedOutMessages() { assertFailureOnce(); } + @Test + void preservesTheExplicitAsyncDeliveryTimeoutAndItsNetworkCause() { + responseDelay.set(1000); + client = new DDExternalDamCallbackClient(url(), SECRET, Duration.ofMillis(250)); + + DDExternalDamException failure = assertAsyncFailureOnce(); + + assertEquals("External DAM service request failed", failure.getMessage()); + assertInstanceOf(HttpTimeoutException.class, failure.getCause()); + } + + @Test + void boundsTheFullAsyncExchangeWhenHeadersArriveButTheBodyStalls() throws Exception { + assertStalledBodyIsCancelled(false); + } + + @Test + void cancelsTheUnderlyingBodyReadWhenTheCallerCancelsDelivery() throws Exception { + assertStalledBodyIsCancelled(true); + } + + @Test + void rejectsNullAsyncDeliveryArgumentsBeforeNetworkIo() { + assertThrows(NullPointerException.class, () -> client.deliverAsync(null, ACCOUNT_ID, CONNECTION, MESSAGE)); + assertThrows(NullPointerException.class, () -> client.deliverAsync(SUBSCRIPTION_ID, null, CONNECTION, MESSAGE)); + assertThrows(NullPointerException.class, () -> client.deliverAsync(SUBSCRIPTION_ID, ACCOUNT_ID, null, MESSAGE)); + assertThrows(NullPointerException.class, () -> client.deliverAsync(SUBSCRIPTION_ID, ACCOUNT_ID, CONNECTION, null)); + assertEquals(0, requestCount.get()); + } + @Test void rejectsUntrustedOriginFormsAndInvalidSecretsBeforeNetworkIo() { for (String origin : List.of("", "/relative", url() + "/prefix", url() + "?query=1", url() + "#fragment", @@ -153,6 +254,11 @@ private void deliver() throws DDExternalDamException { client.deliver(SUBSCRIPTION_ID, ACCOUNT_ID, CONNECTION, MESSAGE); } + @NotNull + private CompletionStage deliverAsync() { + return client.deliverAsync(SUBSCRIPTION_ID, ACCOUNT_ID, CONNECTION, MESSAGE); + } + private void assertFailureOnce() { int before = requestCount.get(); DDExternalDamException failure = assertThrows(DDExternalDamException.class, this::deliver); @@ -160,6 +266,80 @@ private void assertFailureOnce() { assertEquals(before + 1, requestCount.get()); } + @NotNull + private DDExternalDamException assertAsyncFailureOnce() { + int before = requestCount.get(); + CompletionStage stage = deliverAsync(); + ExecutionException completion = assertThrows(ExecutionException.class, + () -> stage.toCompletableFuture().get(5, TimeUnit.SECONDS)); + DDExternalDamException failure = assertInstanceOf(DDExternalDamException.class, completion.getCause()); + StringWriter trace = new StringWriter(); + failure.printStackTrace(new PrintWriter(trace)); + assertFalse(trace.toString().contains(SECRET)); + assertEquals(before + 1, requestCount.get()); + return failure; + } + + private void assertStalledBodyIsCancelled(boolean cancelDelivery) throws Exception { + CountDownLatch headersSent = new CountDownLatch(1); + CountDownLatch resumeBody = new CountDownLatch(1); + CountDownLatch probeFinished = new CountDownLatch(1); + AtomicReference bodyFailure = new AtomicReference<>(); + server.createContext(DDExternalDamApi.CALLBACK_GRAPHQL_PATH, exchange -> { + try { + exchange.getRequestBody().readAllBytes(); + requestCount.incrementAndGet(); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().write("{\"data\":{\"deliverExternalDamMessage\":".getBytes(StandardCharsets.UTF_8)); + exchange.getResponseBody().flush(); + headersSent.countDown(); + if (!resumeBody.await(5, TimeUnit.SECONDS)) { + return; + } + byte[] padding = " ".repeat(8192).getBytes(StandardCharsets.UTF_8); + for (int i = 0; i < 128; i++) { + exchange.getResponseBody().write(padding); + exchange.getResponseBody().flush(); + Thread.sleep(10); + } + } catch (IOException e) { + bodyFailure.set(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + probeFinished.countDown(); + } + }); + client = new DDExternalDamCallbackClient(url(), SECRET, + cancelDelivery ? Duration.ofSeconds(5) : Duration.ofSeconds(1)); + CompletableFuture delivery = deliverAsync().toCompletableFuture(); + try { + assertTrue(headersSent.await(3, TimeUnit.SECONDS)); + if (cancelDelivery) { + assertTrue(delivery.cancel(true)); + assertThrows(CancellationException.class, () -> delivery.get(1, TimeUnit.SECONDS)); + } else { + ExecutionException completion = assertThrows(ExecutionException.class, + () -> delivery.get(3, TimeUnit.SECONDS)); + DDExternalDamException failure = assertInstanceOf(DDExternalDamException.class, completion.getCause()); + assertEquals("External DAM service request failed", failure.getMessage()); + assertInstanceOf(HttpTimeoutException.class, failure.getCause()); + StringWriter trace = new StringWriter(); + failure.printStackTrace(new PrintWriter(trace)); + assertFalse(trace.toString().contains(SECRET)); + } + resumeBody.countDown(); + assertTrue(probeFinished.await(5, TimeUnit.SECONDS)); + assertNotNull(bodyFailure.get()); + assertEquals(1, requestCount.get()); + } finally { + resumeBody.countDown(); + delivery.cancel(true); + } + } + @NotNull private String url() { return "http://127.0.0.1:" + server.getAddress().getPort(); @@ -172,6 +352,11 @@ private void respond(@NotNull HttpExchange exchange) throws IOException { exchange.getRequestHeaders().getFirst("Content-Type"), exchange.getRequestHeaders().getFirst("Cache-Control"), JsonParser.parseString(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject())); requestCount.incrementAndGet(); + requestReceived.countDown(); + CountDownLatch gate = responseGate.get(); + if (gate != null && !gate.await(5, TimeUnit.SECONDS)) { + throw new IOException("Timed out waiting to release the response"); + } if (responseDelay.get() > 0) { Thread.sleep(responseDelay.get()); } diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java index 104ad9c..574a174 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java @@ -46,13 +46,19 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -76,6 +82,8 @@ class DDExternalDamClientTest { private final AtomicReference latestRequest = new AtomicReference<>(); private final AtomicInteger requestCount = new AtomicInteger(); private final AtomicReference handlerFailure = new AtomicReference<>(); + private final AtomicReference requestReceived = new AtomicReference<>(); + private final AtomicReference responseGate = new AtomicReference<>(); private HttpServer server; private DDExternalDamClient client; @@ -119,6 +127,75 @@ void postsConnectionQueriesToTheInternalEndpointWithEitherRootUrlForm() throws D assertEquals(2, requestCount.get()); } + @Test + void returnsAllAsyncOperationsBeforeDelayedResponsesAndParsesTheirResults() throws Exception { + JsonArray connections = new JsonArray(); + connections.add(connection("connection-1")); + respond(CONNECTIONS_FIELD, connections); + + List result = awaitDelayedResponse(() -> client.getConnectionsAsync(ACCOUNT_ID)); + + assertEquals(List.of(new DDExternalDamConnection("DI_desktop", "run-1", "connection-1", "DBeaver", "26.2.4")), result); + assertThrows(UnsupportedOperationException.class, () -> result.add(result.get(0))); + assertEquals("ExternalDamConnections", posted().get("operationName").getAsString()); + assertEquals(ACCOUNT_ID.toString(), posted().getAsJsonObject("variables").get("accountId").getAsString()); + + respond(CONNECTION_ACCOUNT_FIELD, JsonParser.parseString("\"" + ACCOUNT_ID + "\"")); + assertEquals(ACCOUNT_ID, awaitDelayedResponse(() -> client.getConnectionAccountAsync("connection-1"))); + assertEquals("ExternalDamConnectionAccount", posted().get("operationName").getAsString()); + assertEquals("connection-1", posted().getAsJsonObject("variables").get("connectionId").getAsString()); + + respond(SEND_FIELD, JsonParser.parseString("false")); + assertFalse(awaitDelayedResponse(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message()))); + assertEquals("SendExternalDamMessage", posted().get("operationName").getAsString()); + assertEquals(ACCOUNT_ID.toString(), posted().getAsJsonObject("variables").get("accountId").getAsString()); + assertEquals("connection-1", posted().getAsJsonObject("variables").get("connectionId").getAsString()); + + UUID subscriptionId = UUID.randomUUID(); + respond("registerExternalDamSubscription", JsonParser.parseString("60")); + assertEquals(60, awaitDelayedResponse(() -> client.registerSubscriptionAsync("service account", subscriptionId, + ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)).intValue()); + JsonObject input = posted().getAsJsonObject("variables").getAsJsonObject("input"); + assertEquals("RegisterExternalDamSubscription", posted().get("operationName").getAsString()); + assertEquals("service account", input.get("subscriberId").getAsString()); + assertEquals(subscriptionId.toString(), input.get("subscriptionId").getAsString()); + assertEquals(ACCOUNT_ID.toString(), input.get("accountId").getAsString()); + assertEquals("projects", input.get("topic").getAsString()); + assertEquals(CALLBACK_URL, input.get("callbackUrl").getAsString()); + assertEquals(CALLBACK_SECRET, input.get("callbackSecret").getAsString()); + assertTrue(!input.has("connectionId") || input.get("connectionId").isJsonNull()); + + respond("unregisterExternalDamSubscription", JsonParser.parseString("false")); + assertFalse(awaitDelayedResponse(() -> client.unregisterSubscriptionAsync("service account", subscriptionId))); + assertEquals("UnregisterExternalDamSubscription", posted().get("operationName").getAsString()); + assertEquals(Set.of("subscriberId", "subscriptionId"), posted().getAsJsonObject("variables").keySet()); + assertEquals("service account", posted().getAsJsonObject("variables").get("subscriberId").getAsString()); + assertEquals(subscriptionId.toString(), posted().getAsJsonObject("variables").get("subscriptionId").getAsString()); + assertEquals(5, requestCount.get()); + } + + @Test + void sendsAsyncMessagesWithExactJsonNumbersNullsAndUtf8() throws Exception { + JsonObject payload = JsonParser.parseString(""" + {"integer":9007199254740993,"decimal":1.2345678901234567890123456789, + "nothing":null,"nested":[null,{"text":"Привет"}]} + """).getAsJsonObject(); + DDMessage message = new DDMessage("topic-\"\\\n", "type-\"\\\n", payload); + respond(SEND_FIELD, JsonParser.parseString("true")); + + assertTrue(client.sendMessageAsync(ACCOUNT_ID, "connection-\"\\\n", message).toCompletableFuture().get(5, TimeUnit.SECONDS)); + + JsonObject variables = posted().getAsJsonObject("variables"); + assertEquals("connection-\"\\\n", variables.get("connectionId").getAsString()); + JsonObject sent = variables.getAsJsonObject("message"); + assertEquals(message.topic(), sent.get("topic").getAsString()); + assertEquals(message.type(), sent.get("type").getAsString()); + assertEquals(payload, sent.getAsJsonObject("payload")); + assertEquals("9007199254740993", sent.getAsJsonObject("payload").get("integer").getAsString()); + assertEquals("1.2345678901234567890123456789", sent.getAsJsonObject("payload").get("decimal").getAsString()); + assertTrue(sent.getAsJsonObject("payload").get("nothing").isJsonNull()); + } + @Test void resolvesTheConnectionOwnerWithOneAuthenticatedReadAndNoDiscovery() throws DDExternalDamException { String connectionId = "connection-\"\\\n) { injectedConnection }"; @@ -146,15 +223,18 @@ void rejectsUnavailableNonStringAndMalformedConnectionOwnersWithoutRetrying() { respond(CONNECTION_ACCOUNT_FIELD, JsonParser.parseString(value)); assertRequestFails(() -> client.getConnectionAccount("connection-1")); + assertAsyncRequestFails(() -> client.getConnectionAccountAsync("connection-1")); } responseJson.set("{\"data\":{}}"); assertRequestFails(() -> client.getConnectionAccount("connection-1")); + assertAsyncRequestFails(() -> client.getConnectionAccountAsync("connection-1")); } @Test void rejectsBlankAndOversizedConnectionOwnerLookupIdsBeforeSendingAnyRequest() { for (String connectionId : List.of("", " ", "\t\n", "s".repeat(257))) { assertThrows(IllegalArgumentException.class, () -> client.getConnectionAccount(connectionId)); + assertThrows(IllegalArgumentException.class, () -> client.getConnectionAccountAsync(connectionId)); } assertEquals(0, requestCount.get()); } @@ -374,6 +454,9 @@ void rejectsMalformedSubscriptionMetadataBeforeSendingAnyRequest() { assertThrows(IllegalArgumentException.class, () -> client.registerSubscription(null, subscriptionId, ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); assertThrows(IllegalArgumentException.class, () -> client.unregisterSubscription(null, subscriptionId)); + assertThrows(IllegalArgumentException.class, () -> client.registerSubscriptionAsync(null, subscriptionId, + ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.unregisterSubscriptionAsync(null, subscriptionId)); for (String value : List.of("", " ", "\t\n", "s".repeat(257))) { assertThrows(IllegalArgumentException.class, () -> client.registerSubscription(value, subscriptionId, ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); @@ -382,6 +465,13 @@ void rejectsMalformedSubscriptionMetadataBeforeSendingAnyRequest() { ACCOUNT_ID, value, "projects", CALLBACK_URL, CALLBACK_SECRET)); assertThrows(IllegalArgumentException.class, () -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, value, CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.registerSubscriptionAsync(value, subscriptionId, + ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.unregisterSubscriptionAsync(value, subscriptionId)); + assertThrows(IllegalArgumentException.class, () -> client.registerSubscriptionAsync("account", subscriptionId, + ACCOUNT_ID, value, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertThrows(IllegalArgumentException.class, () -> client.registerSubscriptionAsync("account", subscriptionId, + ACCOUNT_ID, null, value, CALLBACK_URL, CALLBACK_SECRET)); } assertEquals(0, requestCount.get()); } @@ -402,6 +492,11 @@ void rejectsMalformedCallbackOriginsBeforeSendingAnyRequestWithoutExposingSecret "projects", callbackUrl, CALLBACK_SECRET)); assertFalse(failure.toString().contains(CALLBACK_SECRET)); assertFalse(failure.toString().contains(SERVICE_SECRET)); + IllegalArgumentException asyncFailure = assertThrows(IllegalArgumentException.class, + () -> client.registerSubscriptionAsync("account", subscriptionId, ACCOUNT_ID, null, + "projects", callbackUrl, CALLBACK_SECRET)); + assertFalse(asyncFailure.toString().contains(CALLBACK_SECRET)); + assertFalse(asyncFailure.toString().contains(SERVICE_SECRET)); } assertEquals(0, requestCount.get()); } @@ -419,6 +514,11 @@ void rejectsInvalidCallbackSecretsBeforeSendingAnyRequest() { "projects", CALLBACK_URL, callbackSecret)); assertFalse(failure.toString().contains(CALLBACK_SECRET)); assertFalse(failure.toString().contains(SERVICE_SECRET)); + IllegalArgumentException asyncFailure = assertThrows(IllegalArgumentException.class, + () -> client.registerSubscriptionAsync("account", subscriptionId, ACCOUNT_ID, null, + "projects", CALLBACK_URL, callbackSecret)); + assertFalse(asyncFailure.toString().contains(CALLBACK_SECRET)); + assertFalse(asyncFailure.toString().contains(SERVICE_SECRET)); } assertEquals(0, requestCount.get()); } @@ -441,11 +541,16 @@ void unregistersOnlyTheStableSubscriberAndSubscriptionId() throws DDExternalDamE } @Test - void acceptsBothSubscriptionLeaseBoundaries() throws DDExternalDamException { + void acceptsBothSubscriptionLeaseBoundaries() throws Exception { for (int lease : List.of(15, 300)) { respond("registerExternalDamSubscription", JsonParser.parseString(Integer.toString(lease))); assertEquals(lease, client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertEquals(lease, client.registerSubscriptionAsync("s".repeat(256), UUID.randomUUID(), ACCOUNT_ID, "socket", + "projects", CALLBACK_URL, CALLBACK_SECRET).toCompletableFuture().get(5, TimeUnit.SECONDS).intValue()); + JsonObject input = posted().getAsJsonObject("variables").getAsJsonObject("input"); + assertEquals("s".repeat(256), input.get("subscriberId").getAsString()); + assertEquals("socket", input.get("connectionId").getAsString()); } } @@ -456,10 +561,13 @@ void rejectsInvalidLeaseValuesAndMalformedSubscriptionEnvelopesWithoutRetrying() respond("registerExternalDamSubscription", JsonParser.parseString(value)); assertRequestFails(() -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertAsyncRequestFails(() -> client.registerSubscriptionAsync("account", subscriptionId, ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); } for (String value : List.of("null", "\"true\"", "60", "{}", "[]")) { respond("unregisterExternalDamSubscription", JsonParser.parseString(value)); assertRequestFails(() -> client.unregisterSubscription("account", subscriptionId)); + assertAsyncRequestFails(() -> client.unregisterSubscriptionAsync("account", subscriptionId)); } for (String body : List.of("{", "null", "{}", "{\"data\":null}", "{\"errors\":[{\"message\":\"" + UPSTREAM_MARKER + CALLBACK_SECRET + "\"}]}")) { @@ -467,6 +575,9 @@ void rejectsInvalidLeaseValuesAndMalformedSubscriptionEnvelopesWithoutRetrying() assertRequestFails(() -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); assertRequestFails(() -> client.unregisterSubscription("account", subscriptionId)); + assertAsyncRequestFails(() -> client.registerSubscriptionAsync("account", subscriptionId, ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertAsyncRequestFails(() -> client.unregisterSubscriptionAsync("account", subscriptionId)); } } @@ -492,6 +603,7 @@ void rejectsNonArrayConnectionResultsAndNonObjectEntries() { respond(CONNECTIONS_FIELD, JsonParser.parseString(value)); assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); + assertAsyncRequestFails(() -> client.getConnectionsAsync(ACCOUNT_ID)); } } @@ -507,6 +619,8 @@ void rejectsMalformedJsonRootsAndMissingDataForBothOperations() { assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertAsyncRequestFails(() -> client.getConnectionsAsync(ACCOUNT_ID)); + assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); } } @@ -526,18 +640,23 @@ JsonNull.INSTANCE, new JsonObject(), JsonParser.parseString("true"), JsonParser. assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertAsyncRequestFails(() -> client.getConnectionsAsync(ACCOUNT_ID)); + DDExternalDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); + assertEquals("External DAM GraphQL request failed", failure.getMessage()); } } @Test - void acceptsAnEmptyGraphqlErrorsArray() throws DDExternalDamException { + void acceptsAnEmptyGraphqlErrorsArray() throws Exception { JsonObject envelope = successfulEnvelope(); envelope.add("errors", new JsonArray()); responseJson.set(envelope.toString()); assertEquals(List.of(), client.getConnections(ACCOUNT_ID)); assertTrue(client.sendMessage(ACCOUNT_ID, "connection-1", message())); - assertEquals(2, requestCount.get()); + assertEquals(List.of(), client.getConnectionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertTrue(client.sendMessageAsync(ACCOUNT_ID, "connection-1", message()).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertEquals(4, requestCount.get()); } @Test @@ -546,6 +665,7 @@ void rejectsSendResultsThatAreNotJsonBooleans() { respond(SEND_FIELD, JsonParser.parseString(value)); assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); } } @@ -562,6 +682,9 @@ void rejectsHttpFailuresWithoutExposingTheResponseOrRetrying() { assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); assertRequestFails(() -> client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); + DDExternalDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); + assertEquals(status == 204 ? "Invalid External DAM response" : "External DAM request failed: HTTP " + status, + failure.getMessage()); } } @@ -592,6 +715,10 @@ void neverFollowsRedirectsOrForwardsTheServiceSecret() throws IOException { assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); assertRequestFails(() -> client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); + assertAsyncRequestFails(() -> client.getConnectionAccountAsync("connection-1")); + assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); + assertAsyncRequestFails(() -> client.registerSubscriptionAsync("account", UUID.randomUUID(), ACCOUNT_ID, null, + "projects", CALLBACK_URL, CALLBACK_SECRET)); assertEquals(0, forwarded.get()); assertNull(forwardedSecret.get()); } @@ -618,6 +745,35 @@ void neverRetriesOrReplaysFailedMutationsAfterRecovery() throws DDExternalDamExc assertEquals(4, requestCount.get()); } + @Test + void preservesAsyncNetworkFailureCausesAndAllowsSubsequentRequests() throws Exception { + responseJson.set(successfulEnvelope().toString()); + truncateResponse.set(true); + + DDExternalDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); + + assertEquals("External DAM service request failed", failure.getMessage()); + assertInstanceOf(IOException.class, failure.getCause()); + truncateResponse.set(false); + assertTrue(client.sendMessageAsync(ACCOUNT_ID, "connection-1", message()).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertEquals(2, requestCount.get()); + } + + @Test + void reportsAsyncConnectionFailuresThroughTheStageWithTheirCause() { + server.stop(0); + server = null; + + ExecutionException completion = assertThrows(ExecutionException.class, + () -> client.getConnectionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS)); + + DDExternalDamException failure = assertInstanceOf(DDExternalDamException.class, completion.getCause()); + assertEquals("External DAM service request failed", failure.getMessage()); + assertInstanceOf(IOException.class, failure.getCause()); + assertSafeFailure(failure); + assertEquals(0, requestCount.get()); + } + @Test void rejectsInvalidServiceOriginsBeforeSendingAnyRequest() { for (String url : List.of( @@ -652,10 +808,49 @@ void rejectsInvalidSecretsAndBlankConnectionIdsBeforeSendingAnyRequest() { } for (String connectionId : List.of("", " ", "\t\n")) { assertThrows(IllegalArgumentException.class, () -> client.sendMessage(ACCOUNT_ID, connectionId, message())); + assertThrows(IllegalArgumentException.class, () -> client.sendMessageAsync(ACCOUNT_ID, connectionId, message())); } + assertThrows(NullPointerException.class, () -> client.getConnectionsAsync(null)); + assertThrows(NullPointerException.class, () -> client.getConnectionAccountAsync(null)); + assertThrows(NullPointerException.class, () -> client.sendMessageAsync(null, "connection-1", message())); + assertThrows(NullPointerException.class, () -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", null)); assertEquals(0, requestCount.get()); } + private T awaitDelayedResponse(@NotNull Supplier> action) throws Exception { + CountDownLatch received = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch returned = new CountDownLatch(1); + AtomicReference> stage = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + requestReceived.set(received); + responseGate.set(release); + Thread caller = new Thread(() -> { + try { + stage.set(action.get()); + } catch (Throwable e) { + failure.set(e); + } finally { + returned.countDown(); + } + }); + caller.start(); + try { + assertTrue(received.await(5, TimeUnit.SECONDS)); + assertTrue(returned.await(1, TimeUnit.SECONDS)); + assertNull(failure.get()); + assertNotNull(stage.get()); + assertFalse(stage.get().toCompletableFuture().isDone()); + release.countDown(); + return stage.get().toCompletableFuture().get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + caller.join(5000); + requestReceived.set(null); + responseGate.set(null); + } + } + private void handleRequest(@NotNull HttpExchange exchange) { try { Map> headers = new HashMap<>(); @@ -663,6 +858,14 @@ private void handleRequest(@NotNull HttpExchange exchange) { latestRequest.set(new CapturedRequest(exchange.getRequestMethod(), exchange.getRequestURI(), Map.copyOf(headers), new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8))); requestCount.incrementAndGet(); + CountDownLatch received = requestReceived.get(); + CountDownLatch gate = responseGate.get(); + if (received != null) { + received.countDown(); + } + if (gate != null && !gate.await(5, TimeUnit.SECONDS)) { + throw new IOException("Timed out waiting to release the response"); + } String location = redirect.get(); if (location != null) { exchange.getResponseHeaders().set("Location", location); @@ -682,6 +885,9 @@ private void handleRequest(@NotNull HttpExchange exchange) { } } catch (IOException e) { handlerFailure.compareAndSet(null, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + handlerFailure.compareAndSet(null, new IOException(e)); } finally { exchange.close(); } @@ -723,12 +929,29 @@ private void assertInvalidConnection(@NotNull JsonObject invalid) { connections.add(invalid); respond(CONNECTIONS_FIELD, connections); assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); + assertAsyncRequestFails(() -> client.getConnectionsAsync(ACCOUNT_ID)); } private void assertRequestFails(@NotNull Executable action) { int before = requestCount.get(); DDExternalDamException failure = assertThrows(DDExternalDamException.class, action); assertEquals(before + 1, requestCount.get()); + assertSafeFailure(failure); + } + + @NotNull + private DDExternalDamException assertAsyncRequestFails(@NotNull Supplier> action) { + int before = requestCount.get(); + CompletionStage stage = action.get(); + ExecutionException completion = assertThrows(ExecutionException.class, + () -> stage.toCompletableFuture().get(5, TimeUnit.SECONDS)); + DDExternalDamException failure = assertInstanceOf(DDExternalDamException.class, completion.getCause()); + assertEquals(before + 1, requestCount.get()); + assertSafeFailure(failure); + return failure; + } + + private static void assertSafeFailure(@NotNull DDExternalDamException failure) { StringWriter trace = new StringWriter(); failure.printStackTrace(new PrintWriter(trace)); assertFalse(trace.toString().contains(SERVICE_SECRET)); diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java index 9bd9dc8..3da2d48 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java @@ -58,7 +58,11 @@ import java.util.concurrent.CompletionStage; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -70,6 +74,112 @@ public class DDRpcTransportTest { private static final RequestReplyOptions OPTIONS = new RequestReplyOptions(Duration.ofSeconds(2), 16, 8); private static final RpcCodec CODEC = new GsonCodec(); + @Test + void delegatesRequestExecutionUnlessAnExplicitExecutorIsSupplied() { + MemoryTransport delegate = new MemoryTransport(); + List queued = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(); + delegate.requestExecutor = queued::add; + DDRpcTransport adapter = new DDRpcTransport(delegate); + adapter.executeRequest(calls::incrementAndGet); + Assertions.assertEquals(1, queued.size()); + Assertions.assertEquals(0, calls.get()); + queued.removeFirst().run(); + Assertions.assertEquals(1, calls.get()); + + RejectedExecutionException unavailable = new RejectedExecutionException("Request execution unavailable"); + delegate.requestExecutor = request -> { + throw unavailable; + }; + Assertions.assertSame(unavailable, + Assertions.assertThrows(RejectedExecutionException.class, () -> adapter.executeRequest(calls::incrementAndGet))); + + DDRpcTransport inline = new DDRpcTransport(delegate, Runnable::run); + inline.executeRequest(calls::incrementAndGet); + Assertions.assertEquals(2, calls.get()); + } + + @Test + void channelRequestsRunInlineByDefaultAndCanUseABorrowedExecutor() throws Exception { + try (var executor = Executors.newSingleThreadExecutor()) { + for (boolean supplied : List.of(false, true)) { + FakeChannel channel = new FakeChannel(); + channel.forward = channel::deliver; + AtomicReference handlerThread = new AtomicReference<>(); + try (var endpoint = RpcEndpoint.open(topic -> supplied + ? DDRpcTransport.forChannel(channel, topic, executor) + : DDRpcTransport.forChannel(channel, topic), CODEC, OPTIONS); + var service = endpoint.register(TOPIC, Text.class, value -> { + handlerThread.set(Thread.currentThread()); + return "reply:" + value; + })) { + Assertions.assertEquals("reply:value", endpoint.proxy(TOPIC, Text.class).apply("value")); + if (supplied) { + Assertions.assertNotEquals(Thread.currentThread(), handlerThread.get()); + } else { + Assertions.assertSame(Thread.currentThread(), handlerThread.get()); + } + } + Assertions.assertFalse(executor.isShutdown()); + Assertions.assertEquals("still available", executor.submit(() -> "still available").get(2, TimeUnit.SECONDS)); + Assertions.assertFalse(channel.closed); + } + } + } + + @Test + void mapsSuppliedExecutorRejectionToRemoteOverloadAndAllowsFreshCalls() { + FakeChannel channel = new FakeChannel(); + channel.forward = channel::deliver; + AtomicBoolean rejecting = new AtomicBoolean(true); + AtomicInteger calls = new AtomicInteger(); + Executor executor = request -> { + if (rejecting.get()) { + throw new RejectedExecutionException("Request execution unavailable"); + } + request.run(); + }; + var options = new RequestReplyOptions(Duration.ofSeconds(2), 1, 1); + try (var endpoint = RpcEndpoint.open(topic -> DDRpcTransport.forChannel(channel, topic, executor), CODEC, options); + var service = endpoint.register(TOPIC, Text.class, value -> { + calls.incrementAndGet(); + return value; + })) { + Text proxy = endpoint.proxy(TOPIC, Text.class); + RpcException failure = Assertions.assertThrows(RpcException.class, () -> proxy.apply("rejected")); + Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, failure.reason()); + Assertions.assertNull(failure.remoteError()); + RequestReplyException outer = Assertions.assertInstanceOf(RequestReplyException.class, failure.getCause()); + Assertions.assertEquals(RequestReplyMessage.Error.OVERLOADED, outer.remoteError()); + Assertions.assertEquals(0, calls.get()); + + rejecting.set(false); + Assertions.assertEquals("recovered", proxy.apply("recovered")); + Assertions.assertEquals(1, calls.get()); + } + } + + @Test + void supportsSynchronousCallbacksWithBorrowedRequestExecutorsAndIndependentChannelDelivery() throws Exception { + try (var deliveries = Executors.newCachedThreadPool(); + var requests = Executors.newCachedThreadPool()) { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = message -> deliveries.execute(() -> right.deliver(message)); + right.forward = message -> deliveries.execute(() -> left.deliver(message)); + try (var caller = RpcEndpoint.open(topic -> DDRpcTransport.forChannel(left, topic, requests), CODEC, OPTIONS); + var handler = RpcEndpoint.open(topic -> DDRpcTransport.forChannel(right, topic, requests), CODEC, OPTIONS); + var callback = caller.register(TOPIC, Text.class, value -> "callback:" + value); + var service = handler.register(TOPIC, Text.class, value -> handler.proxy(TOPIC, Text.class).apply(value))) { + Assertions.assertEquals("callback:value", caller.proxy(TOPIC, Text.class).apply("value")); + } + Assertions.assertFalse(requests.isShutdown()); + Assertions.assertFalse(deliveries.isShutdown()); + Assertions.assertFalse(left.closed); + Assertions.assertFalse(right.closed); + } + } + @Test void servesIndependentBidirectionalTopicsWithoutClosingTheBorrowedChannel() throws Exception { FakeChannel left = new FakeChannel(); @@ -788,6 +898,7 @@ private static final class MemoryTransport implements RequestReplyTransport> sent = new ArrayList<>(); private final MessageSubscription subscription = () -> this.closed = true; private CompletionStage sendResult = CompletableFuture.completedFuture(null); + private Executor requestExecutor = Runnable::run; @Nullable private UUID endpoint; @Nullable @@ -825,6 +936,11 @@ public CompletionStage send(@NotNull RequestReplyMessage messa return sendResult; } + @Override + public void executeRequest(@NotNull Runnable request) { + requestExecutor.execute(request); + } + private void deliver(@NotNull RequestReplyMessage message) { Objects.requireNonNull(listener).onMessage(message); } diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java index 448f6e2..ce715ba 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java @@ -37,11 +37,14 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; import java.util.concurrent.Future; import java.util.concurrent.FutureTask; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; @@ -63,7 +66,7 @@ @Timeout(30) class RequestReplyEndpointTest { private static final Duration WAIT = Duration.ofSeconds(5); - private final List callers = new ArrayList<>(); + private final List callers = new CopyOnWriteArrayList<>(); @AfterEach void stopCallers() throws InterruptedException { @@ -256,27 +259,22 @@ void correlatesConcurrentResponsesDeliveredInReverseOrder() throws Exception { } @Test - void invokesTheRemoteHandlerOnAVirtualThreadAndReturnsANullResult() throws Exception { - var clientTransport = new MemoryTransport(); - var serverTransport = new MemoryTransport(); - connect(clientTransport, serverTransport); + void invokesTheHandlerInlineOnTheReceivingThreadAndReturnsANullResult() throws Exception { + var transport = new MemoryTransport(); var handlerThread = new AtomicReference(); var handled = new AtomicReference(); - try (var client = RequestReplyEndpoint.open(clientTransport); - var server = RequestReplyEndpoint.open(serverTransport); - var registration = server.handle(payload -> { + try (var endpoint = RequestReplyEndpoint.open(transport); + var registration = endpoint.handle(payload -> { handlerThread.set(Thread.currentThread()); handled.set(payload); return null; })) { - assertNull(client.request("nullable result")); + var request = request("nullable result"); + transport.deliver(request); assertEquals("nullable result", handled.get()); - assertTrue(handlerThread.get().isVirtual()); - assertNotEquals(Thread.currentThread(), handlerThread.get()); - RequestReplyMessage request = clientTransport.next(); - assertEquals(request.response(null), serverTransport.next()); - join(handlerThread.get()); + assertSame(Thread.currentThread(), handlerThread.get()); + assertEquals(request.response(null), transport.next()); } } @@ -309,12 +307,8 @@ void reportsMissingAndFailingHandlersWithoutExposingHandlerDetails() throws Exce @Test void releasesHandlerCapacityWhenReplySendingFails() throws Exception { var transport = new MemoryTransport(); - var handlerThread = new AtomicReference(); try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1)); - var registration = endpoint.handle(payload -> { - handlerThread.set(Thread.currentThread()); - return "reply:" + payload; - })) { + var registration = endpoint.handle(payload -> "reply:" + payload)) { for (boolean asynchronous : List.of(false, true)) { transport.sender = message -> { var failure = new IllegalStateException("reply send failure"); @@ -327,13 +321,81 @@ void releasesHandlerCapacityWhenReplySendingFails() throws Exception { assertDoesNotThrow(() -> transport.deliver(request)); assertEquals(request.response("reply:request"), transport.next()); - join(handlerThread.get()); } transport.sender = message -> CompletableFuture.completedFuture(null); var request = request("recovered"); transport.deliver(request); assertEquals(request.response("reply:recovered"), transport.next()); - join(handlerThread.get()); + } + } + + @Test + void mapsTransportExecutionRejectionToOverloadAndReleasesAdmission() throws Exception { + var transport = new MemoryTransport(); + var rejected = new AtomicReference(); + var calls = new AtomicInteger(); + transport.requestExecutor = request -> { + rejected.set(request); + throw new RejectedExecutionException("Request execution unavailable"); + }; + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1)); + var registration = endpoint.handle(payload -> { + calls.incrementAndGet(); + return "reply:" + payload; + })) { + var request = request("rejected"); + assertDoesNotThrow(() -> transport.deliver(request)); + assertEquals(request.failure(RequestReplyMessage.Error.OVERLOADED), transport.next()); + assertEquals(0, calls.get()); + rejected.get().run(); + assertEquals(0, calls.get()); + assertTrue(transport.sent.isEmpty()); + + transport.requestExecutor = null; + transport.deliver(request); + assertEquals(request.response("reply:rejected"), transport.next()); + assertEquals(1, calls.get()); + } + } + + @Test + void countsQueuedRequestsAndCancelsThemBeforeExecutionOnDisconnectOrClose() throws Exception { + for (boolean closing : List.of(false, true)) { + var transport = new MemoryTransport(); + var queued = new LinkedBlockingQueue(); + var calls = new AtomicInteger(); + transport.requestExecutor = queued::add; + try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1)); + var registration = endpoint.handle(payload -> { + calls.incrementAndGet(); + return "reply:" + payload; + })) { + var first = request("queued"); + transport.deliver(first); + transport.deliver(first); + assertEquals(1, queued.size()); + var excess = request("excess"); + transport.deliver(excess); + assertEquals(excess.failure(RequestReplyMessage.Error.OVERLOADED), transport.next()); + assertEquals(0, calls.get()); + + if (closing) { + endpoint.close(); + } else { + transport.disconnect(); + } + queued.remove().run(); + assertEquals(0, calls.get()); + assertTrue(transport.sent.isEmpty()); + + if (!closing) { + var fresh = request("fresh"); + transport.deliver(fresh); + queued.remove().run(); + assertEquals(fresh.response("reply:fresh"), transport.next()); + assertEquals(1, calls.get()); + } + } } } @@ -390,6 +452,38 @@ void usesTheDefaultTimeoutAndReleasesCapacityBeforeIgnoringLateReplies() throws } } + @Test + void timesOutWhileAnIndependentReceivingThreadIsStillHandlingTheRequest() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var handlerThread = new AtomicReference(); + connect(clientTransport, serverTransport); + try (var client = RequestReplyEndpoint.open(clientTransport, options(1, 1)); + var server = RequestReplyEndpoint.open(serverTransport, options(1, 1)); + var registration = server.handle(payload -> { + handlerThread.set(Thread.currentThread()); + entered.countDown(); + await(release); + return "reply:" + payload; + })) { + try { + Future expired = async(() -> client.request("expired", Duration.ofMillis(100))); + await(entered); + failure(RequestReplyException.Reason.TIMEOUT, expired); + assertTrue(handlerThread.get().isAlive()); + release.countDown(); + join(handlerThread.get()); + assertEquals(clientTransport.next().response("reply:expired"), serverTransport.next()); + assertEquals("reply:fresh", client.request("fresh")); + assertEquals(clientTransport.next().response("reply:fresh"), serverTransport.next()); + } finally { + release.countDown(); + } + } + } + @Test void rejectsRepliesAtTheDeadlineEvenWhenTheyArriveBeforeSendReturns() throws Exception { var transport = new MemoryTransport(); @@ -500,9 +594,9 @@ void rejectsExcessHandlersWithoutQueueingAndReusesTheSlotAfterExit() throws Exce })) { try { var first = request("first"); - transport.deliver(first); + Thread firstDelivery = start(() -> transport.deliver(first)); await(entered); - assertTrue(handlerThread.get().isVirtual()); + assertSame(firstDelivery, handlerThread.get()); var excess = request("excess"); transport.deliver(excess); @@ -513,11 +607,11 @@ void rejectsExcessHandlersWithoutQueueingAndReusesTheSlotAfterExit() throws Exce release.countDown(); assertEquals(first.response("reply:first"), transport.next()); - join(handlerThread.get()); + join(firstDelivery); var next = request("next"); transport.deliver(next); assertEquals(next.response("reply:next"), transport.next()); - join(handlerThread.get()); + assertSame(Thread.currentThread(), handlerThread.get()); assertEquals(2, calls.get()); assertTrue(transport.sent.isEmpty()); } finally { @@ -545,9 +639,11 @@ void treatsIncomingRequestIdentityAsThePairOfOriginAndRequestId() throws Excepti UUID requestId = UUID.randomUUID(); var first = RequestReplyMessage.request(requestId, UUID.randomUUID(), "first origin"); var second = RequestReplyMessage.request(requestId, UUID.randomUUID(), "second origin"); - transport.deliver(first); - transport.deliver(second); + Thread firstDelivery = start(() -> transport.deliver(first)); + Thread secondDelivery = start(() -> transport.deliver(second)); await(entered); + assertSame(firstDelivery, handlerThreads.get("first origin")); + assertSame(secondDelivery, handlerThreads.get("second origin")); transport.deliver(first); transport.deliver(second); assertEquals(2, calls.get()); @@ -582,7 +678,7 @@ void closingAnOldRegistrationCannotRemoveItsReplacementOrCancelAcceptedWork() th try { assertThrows(IllegalStateException.class, () -> endpoint.handle(payload -> "duplicate handler")); var accepted = request("accepted"); - transport.deliver(accepted); + Thread delivery = start(() -> transport.deliver(accepted)); await(entered); original.close(); try (var replacement = endpoint.handle(payload -> "replacement reply")) { @@ -592,6 +688,7 @@ void closingAnOldRegistrationCannotRemoveItsReplacementOrCancelAcceptedWork() th assertEquals(next.response("replacement reply"), transport.next()); release.countDown(); assertEquals(accepted.response("original reply"), transport.next()); + join(delivery); assertEquals(1, transport.listeners.size()); assertEquals(0, transport.subscriptionCloses.get()); } @@ -629,7 +726,7 @@ void closingFailsPendingWorkCancelsHandlersAndOnlyClosesItsOwnSubscription() thr RequestReplyMessage outgoing = transport.next(); RequestReplyTransport.Listener oldListener = transport.listeners.get(outgoing.replyTo()); assertNotNull(oldListener); - oldListener.onMessage(request("incoming")); + start(() -> oldListener.onMessage(request("incoming"))); await(entered); endpoint.close(); @@ -695,7 +792,7 @@ void disconnectFailsAllPendingRequestsAndAllowsFreshWorkWithoutReplayingOldReque void disconnectKeepsInterruptIgnoringHandlersCountedAndInvalidatesTheirReplies() throws Exception { var transport = new MemoryTransport(); transport.sender = message -> { - transport.deliver(message); + start(() -> transport.deliver(message)); return CompletableFuture.completedFuture(null); }; var entered = new CountDownLatch(1); @@ -855,13 +952,13 @@ private static void awaitIgnoringInterrupts(@NotNull CountDownLatch release, @No } } - private static void connect(@NotNull MemoryTransport first, @NotNull MemoryTransport second) { + private void connect(@NotNull MemoryTransport first, @NotNull MemoryTransport second) { first.sender = message -> { - second.deliver(message); + start(() -> second.deliver(message)); return CompletableFuture.completedFuture(null); }; second.sender = message -> { - first.deliver(message); + start(() -> first.deliver(message)); return CompletableFuture.completedFuture(null); }; } @@ -872,6 +969,8 @@ private static final class MemoryTransport implements RequestReplyTransport, CompletionStage> sender = message -> CompletableFuture.completedFuture(null); + @Nullable + private Executor requestExecutor; private boolean closed; @NotNull @@ -894,6 +993,15 @@ public CompletionStage send(@NotNull RequestReplyMessage message) return sender.apply(message); } + @Override + public void executeRequest(@NotNull Runnable request) { + if (requestExecutor == null) { + RequestReplyTransport.super.executeRequest(request); + } else { + requestExecutor.execute(request); + } + } + private void deliver(@NotNull RequestReplyMessage message) { List.copyOf(listeners.values()).forEach(listener -> listener.onMessage(message)); } diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java index 17eb9f2..5df51a7 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java @@ -45,6 +45,7 @@ import java.util.concurrent.Future; import java.util.concurrent.FutureTask; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -57,6 +58,30 @@ @Timeout(30) public class RpcEndpointTest extends RpcTestSupport { + @Test + void supportsSynchronousReentrantCallbacksOnIndependentReceivingThreads() throws Exception { + var leftTransport = new MemoryTransport(); + var rightTransport = new MemoryTransport(); + var serviceThread = new AtomicReference(); + var callbackThread = new AtomicReference(); + connect(leftTransport, rightTransport); + try (var left = RpcEndpoint.open(leftTransport, OPTIONS); + var right = RpcEndpoint.open(rightTransport, OPTIONS); + var callback = left.register("echo", EchoService.class, value -> { + callbackThread.set(Thread.currentThread()); + return "callback:" + value; + }, new TestCodec()); + var service = right.register("echo", EchoService.class, value -> { + serviceThread.set(Thread.currentThread()); + return right.proxy("echo", EchoService.class, new TestCodec()).echo(value); + }, new TestCodec())) { + assertEquals("callback:value", left.proxy("echo", EchoService.class, new TestCodec()).echo("value")); + assertNotEquals(Thread.currentThread(), serviceThread.get()); + assertNotEquals(serviceThread.get(), callbackThread.get()); + assertNotEquals(Thread.currentThread(), callbackThread.get()); + } + } + @Test void servesIndependentServicesInBothDirectionsAndAcrossTopics() throws Exception { var leftTransport = new MemoryTransport(); diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java index d95ffde..5e6e8f3 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java @@ -27,7 +27,6 @@ import java.lang.reflect.Type; import java.time.Duration; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; @@ -57,7 +56,7 @@ class RpcTestSupport { static final Duration WAIT = Duration.ofSeconds(5); static final RequestReplyOptions OPTIONS = new RequestReplyOptions(WAIT, 16, 16); - private final List callers = new ArrayList<>(); + private final List callers = new CopyOnWriteArrayList<>(); @AfterEach void stopCallers() throws InterruptedException { @@ -113,13 +112,13 @@ static void await(@NotNull CountDownLatch latch) throws InterruptedException { assertTrue(latch.await(WAIT.toMillis(), TimeUnit.MILLISECONDS), "Latch was not released"); } - static void connect(@NotNull MemoryTransport first, @NotNull MemoryTransport second) { + final void connect(@NotNull MemoryTransport first, @NotNull MemoryTransport second) { first.sender = message -> { - second.deliver(first.routes.get(message.requestId()), message); + start(() -> second.deliver(first.routes.get(message.requestId()), message)); return CompletableFuture.completedFuture(null); }; second.sender = message -> { - first.deliver(second.routes.get(message.requestId()), message); + start(() -> first.deliver(second.routes.get(message.requestId()), message)); return CompletableFuture.completedFuture(null); }; } From cb1a626cf70c11bea288660a5b9f792a7606a1ea Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Sun, 25 Oct 2026 07:12:57 +0400 Subject: [PATCH 19/30] dbeaver/pro#11108 Preserve remote RPC exception diagnostics --- .../MESSAGING.md | 26 +- .../README.md | 17 +- .../external/api/rpc/DDRpcTransport.java | 42 ++- .../dbeaver/messaging/rpc/RpcEndpoint.java | 11 +- .../dbeaver/messaging/rpc/RpcException.java | 25 +- .../messaging/rpc/RpcExceptionDetails.java | 124 +++++++ .../com/dbeaver/messaging/rpc/RpcMessage.java | 17 +- .../external/api/rpc/DDRpcTransportTest.java | 332 +++++++++++++++++- .../messaging/rpc/RpcEndpointTest.java | 24 +- .../rpc/RpcExceptionDetailsTest.java | 145 ++++++++ .../messaging/rpc/RpcExceptionTest.java | 67 ++++ .../dbeaver/messaging/rpc/RpcMessageTest.java | 31 +- .../dbeaver/messaging/rpc/RpcTestSupport.java | 9 +- 13 files changed, 830 insertions(+), 40 deletions(-) create mode 100644 apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcExceptionDetails.java create mode 100644 apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java create mode 100644 apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/MESSAGING.md b/apis/com.dbeaver.datadam.external-dam.api/MESSAGING.md index d9061af..70c7490 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/MESSAGING.md +++ b/apis/com.dbeaver.datadam.external-dam.api/MESSAGING.md @@ -47,8 +47,9 @@ its send stage completes. Request IDs and reply endpoint IDs are independent UUI Unknown, late, and duplicate responses do not complete another request. `RequestReplyException.reason()` distinguishes local closure, disconnection, capacity rejection, send failure, timeout, -and remote errors. `remoteError()` identifies `NO_HANDLER`, `OVERLOADED`, or `HANDLER_FAILED` for remote errors. Handler -exception classes, messages, and stack traces are not sent to the peer. Local send failures retain their causes. +and remote errors. `remoteError()` identifies `NO_HANDLER`, `OVERLOADED`, or `HANDLER_FAILED` for remote errors. Raw +request/reply handler exception classes, messages, and stack traces are not sent to the peer; the typed RPC layer below +adds its own diagnostic error payload. Local send failures retain their causes. Interruption propagates as `InterruptedException` and releases the pending slot. A thread already interrupted when calling `request` does not send. Timeout, interruption, or a send error can leave the remote outcome unknown: none @@ -225,9 +226,26 @@ exception declaration. Its reason distinguishes timeout, interruption, disconnec codec failure, invalid response, and remote failure. Caller interruption restores the interrupt flag. An absent logical service returns `UNKNOWN_SERVICE`; an absent operation returns `UNKNOWN_OPERATION`. Invalid arguments, -handler failures, and remote codec failures return fixed error codes, never exception types, messages, or stack traces. +handler failures, and remote codec failures retain their fixed error codes. Handler and remote codec exceptions also +carry immutable `RpcExceptionDetails`: the exception class name (`type`), nullable original `message`, formatted +`stackTrace`, and a `truncated` flag. The receiving side formats the trace where the failure occurs, including causes, +suppressed exceptions, and circular-reference markers. Reflection's `InvocationTargetException` is unwrapped before capture. + +The caller receives `RpcException` with `reason() == REMOTE_ERROR`, the existing `remoteError()` code, and optional +`remoteException()` details. Its message includes the remote type and message. Its cause is a local diagnostic wrapper +with the remote text and no invented local frames, so ordinary exception logging includes both the local call stack and +the remote trace. Neither the original `Throwable` nor its object graph crosses the transport. The caller never loads or +instantiates the named remote class. Application codecs handle neither exception details nor diagnostic text. + +Type names are limited to 256 UTF-16 code units, messages to 1,024, and stack traces to 8,192. Formatting stops when the +trace budget is exhausted rather than accumulating an unlimited string first. Truncation of any field, or incomplete +formatting, sets `truncated`; valid surrogate pairs are not split at a truncation boundary. These text bounds leave room +for JSON escaping and the surrounding DataDam frame under its default 64 KiB message limit. Diagnostic contents are not +redacted or replaced with a generic message. A code-only error has null `remoteException()`. + Declared application exceptions are not reconstructed on the caller. A peer with no receiving endpoint can only be -detected through transport failure or timeout. Local failures retain their causes. +detected through transport failure or timeout; an error reply lost in transport cannot expose its remote diagnostics. +Local failures retain their original causes. `RpcEndpoint.open(transports, options)` accepts the existing `RequestReplyOptions` for default deadlines and shared capacity. Without explicit options, the default timeout is 30 seconds. Each proxy can override the request timeout diff --git a/apis/com.dbeaver.datadam.external-dam.api/README.md b/apis/com.dbeaver.datadam.external-dam.api/README.md index 2ff9a7e..a802a8b 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/README.md +++ b/apis/com.dbeaver.datadam.external-dam.api/README.md @@ -185,7 +185,8 @@ The peer registers `requests.handle(payload -> process(payload))` on its own end one logical receiving endpoint for the selected peer; use distinct topics for independently handled services. Each endpoint establishes its private reply subscription before accepting calls, so replies return to the originating backend process. Protocol envelopes and payloads remain JSON objects. A null response represents successful completion -without a result. Handler failures are returned as safe protocol errors, without application exception details. +without a result. At this lower request/reply layer, handler failures are returned as protocol error codes without +application exception details. The typed RPC layer below supplies diagnostic error responses separately. The bridge uses message type `datadam.requestreply.v1`. Its envelope contains `requestId`, `replyTo`, `type`, and either `data` or `error`. `replyTo` is an endpoint UUID, not a backend address. Private reply topics use @@ -376,8 +377,11 @@ obtain the new endpoint and register the appropriate implementations again. Back process-local; the existing exact-owner callback routing requirement still applies. The inner JSON envelope uses `protocol: "dbeaver.rpc.v2"`, `type`, and `data`, plus `operation` for requests -or a fixed `error` code for failures. Application `data` can be any JSON value, including null; the outer DataDam message -remains an object and retains its existing request/reply correlation and source validation. Malformed inner envelopes +or a fixed `error` code for failures. Error envelopes contain `exception`: either null or an object with string `type`, +nullable string `message`, string `stackTrace`, and boolean `truncated`. The receiver formats causes and suppressed +exceptions locally; no `Throwable` object is serialized and the application codec is not involved. Application `data` +can be any JSON value, including null; the outer DataDam message remains an object and retains its existing +request/reply correlation and source validation. Malformed inner envelopes are ignored without exposing raw contents. Transport validation and RPC validation do not replace authentication. The adapter bounds framing, snapshot copying, and outstanding sends to 128 operations without a waiting queue. @@ -385,5 +389,8 @@ Cancellation of a returned send future does not free its slot until the underlyi Interfaces declare ordinary one-argument methods returning values or `void`; applications do not implement transport listeners. A `void` call waits for handler completion. Local serialization errors and transport failures become unchecked -`RpcException`; remote handler exceptions are represented only by safe error codes. See [Messaging](MESSAGING.md) -for supported contract shapes, ownership, concurrent codec/handler requirements, and timeout semantics. +`RpcException`. Remote handler and codec failures preserve their codes and expose `remoteException()` with bounded +type, message, and stack-trace text; ordinary exception logging includes the remote trace through a local diagnostic +cause. Limits are 256 UTF-16 code units for the type, 1,024 for the message, and 8,192 for the trace, with `truncated` +indicating incomplete diagnostics. Errors without exception details have null `remoteException()`. +See [Messaging](MESSAGING.md) for supported contract shapes, ownership, concurrent codec/handler requirements, and timeout semantics. diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java index 9c6271c..10e664f 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java @@ -22,6 +22,7 @@ import com.dbeaver.messaging.requestreply.RequestReplyException; import com.dbeaver.messaging.requestreply.RequestReplyMessage; import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import com.dbeaver.messaging.rpc.RpcExceptionDetails; import com.dbeaver.messaging.rpc.RpcMessage; import com.dbeaver.messaging.rpc.RpcTransport; import com.google.gson.JsonElement; @@ -44,6 +45,10 @@ public final class DDRpcTransport implements RequestReplyTransport delegate; @@ -146,6 +151,7 @@ private static JsonObject encode(@NotNull RpcMessage message) { envelope.addProperty(FIELD_TYPE, message.type().name()); if (message.type() == RpcMessage.Type.ERROR) { envelope.addProperty(FIELD_ERROR, Objects.requireNonNull(message.error()).name()); + envelope.add(FIELD_EXCEPTION, message.exception() == null ? JsonNull.INSTANCE : encodeException(message.exception())); } else { if (message.type() == RpcMessage.Type.REQUEST) { envelope.addProperty(FIELD_OPERATION, message.operation()); @@ -155,6 +161,16 @@ private static JsonObject encode(@NotNull RpcMessage message) { return envelope; } + @NotNull + private static JsonObject encodeException(@NotNull RpcExceptionDetails details) { + JsonObject exception = new JsonObject(); + exception.addProperty(FIELD_TYPE, details.type()); + exception.addProperty(FIELD_MESSAGE, details.message()); + exception.addProperty(FIELD_STACK_TRACE, details.stackTrace()); + exception.addProperty(FIELD_TRUNCATED, details.truncated()); + return exception; + } + @Nullable private static RequestReplyMessage> decode(@NotNull RequestReplyMessage message) { if (message.type() == RequestReplyMessage.Type.ERROR || message.payload() == null) { @@ -169,10 +185,12 @@ private static RequestReplyMessage> decode(@NotNull Requ requireType(message.type(), type); RpcMessage payload; if (type == RpcMessage.Type.ERROR) { - if (envelope.size() != 3) { + JsonElement exception = envelope.get(FIELD_EXCEPTION); + if (envelope.size() != 4 || exception == null) { return null; } - payload = RpcMessage.failure(RpcMessage.Error.valueOf(string(envelope, FIELD_ERROR))); + payload = RpcMessage.failure(RpcMessage.Error.valueOf(string(envelope, FIELD_ERROR)), + decodeException(exception)); } else { JsonElement data = envelope.get(FIELD_DATA); if (data == null || envelope.size() != (type == RpcMessage.Type.REQUEST ? 4 : 3)) { @@ -189,6 +207,26 @@ private static RequestReplyMessage> decode(@NotNull Requ } } + @Nullable + private static RpcExceptionDetails decodeException(@NotNull JsonElement value) { + if (value.isJsonNull()) { + return null; + } + if (!value.isJsonObject()) { + throw new IllegalArgumentException("Expected RPC exception details"); + } + JsonObject exception = value.getAsJsonObject(); + JsonElement message = exception.get(FIELD_MESSAGE); + JsonElement truncated = exception.get(FIELD_TRUNCATED); + if (exception.size() != 4 || message == null || truncated == null || !truncated.isJsonPrimitive() + || !truncated.getAsJsonPrimitive().isBoolean()) { + throw new IllegalArgumentException("Invalid RPC exception details"); + } + return new RpcExceptionDetails(string(exception, FIELD_TYPE), + message.isJsonNull() ? null : string(exception, FIELD_MESSAGE), string(exception, FIELD_STACK_TRACE), + truncated.getAsBoolean()); + } + private static void requireType(@NotNull RequestReplyMessage.Type outer, @NotNull RpcMessage.Type inner) { if (outer == RequestReplyMessage.Type.REQUEST ? inner != RpcMessage.Type.REQUEST : outer != RequestReplyMessage.Type.RESPONSE || inner == RpcMessage.Type.REQUEST) { diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java index ebc110f..729d2f8 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java @@ -237,7 +237,7 @@ private Object invoke( throw new RpcException(RpcException.Reason.INVALID_RESPONSE); } if (response.type() == RpcMessage.Type.ERROR) { - throw new RpcException(Objects.requireNonNull(response.error())); + throw new RpcException(Objects.requireNonNull(response.error()), response.exception()); } if (operation.getReturnType() == void.class) { if (response.payload() != null) { @@ -281,7 +281,7 @@ private RpcMessage

dispatch(@NotNull Topic topic, @NotNull RpcMessage

requ argument = registration.codec.decode(request.payload(), operation.getGenericParameterTypes()[0]); } catch (Exception e) { restoreInterruption(e); - return RpcMessage.failure(RpcMessage.Error.CODEC_FAILED); + return RpcMessage.failure(RpcMessage.Error.CODEC_FAILED, RpcExceptionDetails.capture(e)); } if (!RpcContract.accepts(operation.getParameterTypes()[0], argument)) { return RpcMessage.failure(RpcMessage.Error.INVALID_REQUEST); @@ -291,9 +291,10 @@ private RpcMessage

dispatch(@NotNull Topic topic, @NotNull RpcMessage

requ result = operation.invoke(registration.implementation, argument); } catch (InvocationTargetException e) { restoreInterruption(e.getCause()); - return RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED); + return RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED, + RpcExceptionDetails.capture(Objects.requireNonNullElse(e.getCause(), e))); } catch (ReflectiveOperationException | RuntimeException e) { - return RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED); + return RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED, RpcExceptionDetails.capture(e)); } if (operation.getReturnType() == void.class) { return RpcMessage.response(null); @@ -302,7 +303,7 @@ private RpcMessage

dispatch(@NotNull Topic topic, @NotNull RpcMessage

requ return RpcMessage.response(registration.codec.encode(result, operation.getGenericReturnType())); } catch (Exception e) { restoreInterruption(e); - return RpcMessage.failure(RpcMessage.Error.CODEC_FAILED); + return RpcMessage.failure(RpcMessage.Error.CODEC_FAILED, RpcExceptionDetails.capture(e)); } } diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcException.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcException.java index ee16de1..bc8a965 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcException.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcException.java @@ -30,6 +30,8 @@ public enum Reason { private final Reason reason; @Nullable private final RpcMessage.Error remoteError; + @Nullable + private final RpcExceptionDetails remoteException; public RpcException(@NotNull Reason reason) { this(reason, null); @@ -39,12 +41,21 @@ public RpcException(@NotNull Reason reason, @Nullable Throwable cause) { super("RPC call failed: " + Objects.requireNonNull(reason), cause); this.reason = reason; this.remoteError = null; + this.remoteException = null; } public RpcException(@NotNull RpcMessage.Error remoteError) { - super("Remote RPC call failed: " + Objects.requireNonNull(remoteError)); + this(remoteError, null); + } + + public RpcException(@NotNull RpcMessage.Error remoteError, @Nullable RpcExceptionDetails remoteException) { + super("Remote RPC call failed: " + Objects.requireNonNull(remoteError) + + (remoteException == null ? "" : ": " + remoteException.type() + + (remoteException.message() == null ? "" : ": " + remoteException.message())), + remoteException == null ? null : new RemoteException(remoteException)); this.reason = Reason.REMOTE_ERROR; this.remoteError = remoteError; + this.remoteException = remoteException; } RpcException(@NotNull RequestReplyException cause) { @@ -67,4 +78,16 @@ public Reason reason() { public RpcMessage.Error remoteError() { return remoteError; } + + @Nullable + public RpcExceptionDetails remoteException() { + return remoteException; + } + + private static final class RemoteException extends Exception { + private RemoteException(@NotNull RpcExceptionDetails details) { + super("Remote stack trace" + (details.truncated() ? " (truncated)" : "") + ":\n" + details.stackTrace(), + null, false, false); + } + } } diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcExceptionDetails.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcExceptionDetails.java new file mode 100644 index 0000000..5c2fecb --- /dev/null +++ b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcExceptionDetails.java @@ -0,0 +1,124 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.io.PrintWriter; +import java.io.Writer; +import java.util.Objects; + +public record RpcExceptionDetails( + @NotNull String type, + @Nullable String message, + @NotNull String stackTrace, + boolean truncated +) { + public static final int MAX_TYPE_LENGTH = 256; + public static final int MAX_MESSAGE_LENGTH = 1024; + public static final int MAX_STACK_TRACE_LENGTH = 8192; + + public RpcExceptionDetails { + Objects.requireNonNull(type); + Objects.requireNonNull(stackTrace); + if (type.isBlank() || type.length() > MAX_TYPE_LENGTH || message != null && message.length() > MAX_MESSAGE_LENGTH + || stackTrace.length() > MAX_STACK_TRACE_LENGTH) { + throw new IllegalArgumentException("Invalid RPC exception details"); + } + } + + @NotNull + static RpcExceptionDetails capture(@NotNull Throwable failure) { + String originalType = Objects.requireNonNull(failure).getClass().getName(); + String originalMessage; + boolean incomplete = false; + try { + originalMessage = failure.getMessage(); + } catch (RuntimeException e) { + originalMessage = null; + incomplete = true; + } + String type = prefix(originalType, MAX_TYPE_LENGTH); + String message = originalMessage == null ? null : prefix(originalMessage, MAX_MESSAGE_LENGTH); + TraceWriter trace = new TraceWriter(); + try { + failure.printStackTrace(new PrintWriter(trace)); + } catch (TraceLimit e) { + incomplete = true; + } catch (RuntimeException e) { + incomplete = true; + } + String stackTrace = trace.text.toString(); + if (incomplete && !stackTrace.isEmpty() && Character.isHighSurrogate(stackTrace.charAt(stackTrace.length() - 1))) { + stackTrace = stackTrace.substring(0, stackTrace.length() - 1); + } + if (stackTrace.isEmpty()) { + stackTrace = type + (message == null ? "" : ": " + message); + } + return new RpcExceptionDetails(type, message, stackTrace, + incomplete || !type.equals(originalType) || !Objects.equals(message, originalMessage)); + } + + @NotNull + private static String prefix(@NotNull String value, int limit) { + if (value.length() <= limit) { + return value; + } + int end = Character.isHighSurrogate(value.charAt(limit - 1)) && Character.isLowSurrogate(value.charAt(limit)) + ? limit - 1 : limit; + return value.substring(0, end); + } + + private static final class TraceWriter extends Writer { + private final StringBuilder text = new StringBuilder(); + + @Override + public void write(@NotNull char[] value, int offset, int length) { + int count = Math.min(length, MAX_STACK_TRACE_LENGTH - text.length()); + text.append(value, offset, count); + requireComplete(length, count); + } + + @Override + public void write(@NotNull String value, int offset, int length) { + int count = Math.min(length, MAX_STACK_TRACE_LENGTH - text.length()); + text.append(value, offset, offset + count); + requireComplete(length, count); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + + private static void requireComplete(int length, int count) { + if (length != count) { + throw new TraceLimit(); + } + } + } + + private static final class TraceLimit extends RuntimeException { + private TraceLimit() { + super(null, null, false, false); + } + } +} diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java index a001991..6040884 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java @@ -25,7 +25,8 @@ public record RpcMessage

( @NotNull Type type, @Nullable String operation, @Nullable P payload, - @Nullable Error error + @Nullable Error error, + @Nullable RpcExceptionDetails exception ) { public enum Type { REQUEST, RESPONSE, ERROR @@ -37,6 +38,9 @@ public enum Error { public RpcMessage { Objects.requireNonNull(type); + if (exception != null && type != Type.ERROR) { + throw new IllegalArgumentException("RPC exception details require an error response"); + } if (type == Type.REQUEST) { if (operation == null || operation.isBlank() || operation.length() > 1024 || error != null) { throw new IllegalArgumentException("Invalid RPC request"); @@ -49,16 +53,21 @@ public enum Error { @NotNull public static

RpcMessage

request(@NotNull String operation, @Nullable P payload) { - return new RpcMessage<>(Type.REQUEST, operation, payload, null); + return new RpcMessage<>(Type.REQUEST, operation, payload, null, null); } @NotNull public static

RpcMessage

response(@Nullable P payload) { - return new RpcMessage<>(Type.RESPONSE, null, payload, null); + return new RpcMessage<>(Type.RESPONSE, null, payload, null, null); } @NotNull public static

RpcMessage

failure(@NotNull Error error) { - return new RpcMessage<>(Type.ERROR, null, null, Objects.requireNonNull(error)); + return failure(error, null); + } + + @NotNull + public static

RpcMessage

failure(@NotNull Error error, @Nullable RpcExceptionDetails exception) { + return new RpcMessage<>(Type.ERROR, null, null, Objects.requireNonNull(error), exception); } } diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java index 3da2d48..4eeabf2 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java @@ -16,6 +16,7 @@ */ package com.dbeaver.datadam.external.api.rpc; +import com.dbeaver.datadam.external.api.DDExternalDamProtocol; import com.dbeaver.datadam.external.api.model.DDConnectionInfo; import com.dbeaver.datadam.external.api.model.DDMessage; import com.dbeaver.datadam.external.api.requestreply.DDRequestReplyTransport; @@ -34,6 +35,7 @@ import com.dbeaver.messaging.rpc.RpcCodec; import com.dbeaver.messaging.rpc.RpcEndpoint; import com.dbeaver.messaging.rpc.RpcException; +import com.dbeaver.messaging.rpc.RpcExceptionDetails; import com.dbeaver.messaging.rpc.RpcMessage; import com.google.gson.Gson; import com.google.gson.JsonArray; @@ -42,17 +44,22 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSyntaxException; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import java.io.PrintWriter; +import java.io.StringWriter; import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -274,11 +281,17 @@ void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { Assertions.assertEquals(request.replyTo(), sent.replyTo()); Assertions.assertEquals(RequestReplyMessage.Type.REQUEST, sent.type()); Assertions.assertEquals("dbeaver.rpc.v2", Objects.requireNonNull(sent.payload()).get("protocol").getAsString()); + JsonObject expectedRequest = requestEnvelope(); + expectedRequest.addProperty("data", "argument"); + Assertions.assertEquals(expectedRequest, sent.payload()); Assertions.assertFalse(sent.payload().has("topic")); delegate.deliver(sent); Assertions.assertEquals(request, received.messages.getLast()); RequestReplyMessage> response = request.response(RpcMessage.response(new JsonPrimitive(42))); adapter.send(response).toCompletableFuture().join(); + JsonObject expectedResponse = responseEnvelope(); + expectedResponse.addProperty("data", 42); + Assertions.assertEquals(expectedResponse, delegate.sent.getLast().payload()); delegate.deliver(delegate.sent.getLast()); Assertions.assertEquals(response, received.messages.getLast()); for (RpcMessage.Error error : RpcMessage.Error.values()) { @@ -291,6 +304,8 @@ void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { Assertions.assertNull(wire.error()); Assertions.assertFalse(Objects.requireNonNull(wire.payload()).has("data")); Assertions.assertEquals(error.name(), wire.payload().get("error").getAsString()); + Assertions.assertEquals(JsonNull.INSTANCE, wire.payload().get("exception")); + Assertions.assertEquals(errorEnvelope(error), wire.payload()); delegate.deliver(wire); Assertions.assertEquals(failure, received.messages.getLast()); } @@ -310,6 +325,104 @@ void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { Assertions.assertTrue(delegate.closed); } + @Test + void roundTripsStructuredDiagnosticsAndExplicitNullMessagesWithoutChangingOuterFrames() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRpcTransport adapter = new DDRpcTransport(delegate); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + RequestReplyMessage> request = request(endpoint, null); + List diagnostics = List.of( + new RpcExceptionDetails("application.Failure", "quoted \"message\"\n雪😀", "trace\r\n\t\0\\雪😀", false), + new RpcExceptionDetails("application.Failure", null, "", false), + new RpcExceptionDetails("application.Failure", "", "bounded trace", true) + ); + try (var subscription = adapter.subscribe(endpoint, received)) { + for (RpcMessage.Error error : RpcMessage.Error.values()) { + for (RpcExceptionDetails details : diagnostics) { + RequestReplyMessage> failure = request.response(RpcMessage.failure(error, details)); + adapter.send(failure).toCompletableFuture().join(); + RequestReplyMessage wire = delegate.sent.getLast(); + Assertions.assertEquals(request.requestId(), wire.requestId()); + Assertions.assertEquals(request.replyTo(), wire.replyTo()); + Assertions.assertEquals(RequestReplyMessage.Type.RESPONSE, wire.type()); + Assertions.assertNull(wire.error()); + JsonObject envelope = Objects.requireNonNull(wire.payload()); + Assertions.assertEquals(Set.of("protocol", "type", "error", "exception"), envelope.keySet()); + JsonObject expected = errorEnvelope(error); + JsonObject exception = new JsonObject(); + exception.addProperty("type", details.type()); + exception.add("message", details.message() == null ? JsonNull.INSTANCE : new JsonPrimitive(details.message())); + exception.addProperty("stackTrace", details.stackTrace()); + exception.addProperty("truncated", details.truncated()); + expected.add("exception", exception); + Assertions.assertEquals(expected, envelope); + delegate.deliver(new RequestReplyMessage<>(wire.requestId(), wire.replyTo(), wire.type(), + JsonParser.parseString(envelope.toString()).getAsJsonObject(), wire.error())); + Assertions.assertEquals(failure, received.messages.getLast()); + } + } + Assertions.assertEquals(RpcMessage.Error.values().length * diagnostics.size(), received.messages.size()); + } + } + + @Test + void maximumDiagnosticsFitTheFullFramedMessageBudgetUnderEscapingAndUnicode() throws Exception { + FakeChannel channel = new FakeChannel(); + DDRpcTransport adapter = DDRpcTransport.forChannel(channel, "x".repeat(256)); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + RequestReplyMessage> request = request(endpoint, null); + try (var subscription = adapter.subscribe(endpoint, received)) { + for (String unit : List.of("\0", "\"\\\n\r\t\b\f\37", "\u2028x\u2029x", "雪", "😀")) { + RpcExceptionDetails details = new RpcExceptionDetails( + unit.repeat(RpcExceptionDetails.MAX_TYPE_LENGTH / unit.length()), + unit.repeat(RpcExceptionDetails.MAX_MESSAGE_LENGTH / unit.length()), + unit.repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH / unit.length()), true); + Assertions.assertEquals(256, details.type().length()); + Assertions.assertEquals(1024, Objects.requireNonNull(details.message()).length()); + Assertions.assertEquals(8192, details.stackTrace().length()); + RequestReplyMessage> failure = request.response( + RpcMessage.failure(RpcMessage.Error.UNKNOWN_OPERATION, details)); + adapter.send(failure).toCompletableFuture().join(); + DDMessage wire = channel.sent.getLast(); + Assertions.assertEquals("datadam.requestreply.v1", wire.type()); + JsonObject outer = wire.payload(); + Assertions.assertEquals(Set.of("requestId", "replyTo", "type", "data"), outer.keySet()); + Assertions.assertEquals(request.requestId().toString(), outer.get("requestId").getAsString()); + Assertions.assertEquals(endpoint.toString(), outer.get("replyTo").getAsString()); + Assertions.assertEquals("RESPONSE", outer.get("type").getAsString()); + Assertions.assertEquals("dbeaver.rpc.v2", outer.getAsJsonObject("data").get("protocol").getAsString()); + String encoded = DDExternalDamProtocol.encode(wire); + Assertions.assertTrue(encoded.getBytes(StandardCharsets.UTF_8).length <= 65_536); + channel.deliver(DDExternalDamProtocol.decode(encoded)); + Assertions.assertEquals(failure, received.messages.getLast()); + } + Assertions.assertEquals(5, channel.sent.size()); + Assertions.assertEquals(5, received.messages.size()); + } + } + + @Test + void rejectsMalformedExceptionDetailsInErrorEnvelopes() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDRpcTransport adapter = new DDRpcTransport(delegate); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + RequestReplyMessage request = RequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope()); + List malformed = malformedExceptions(); + try (var subscription = adapter.subscribe(endpoint, received)) { + for (RpcMessage.Error error : RpcMessage.Error.values()) { + for (JsonElement details : malformed) { + JsonObject envelope = errorEnvelope(error); + envelope.add("exception", details); + delegate.deliver(request.response(envelope)); + } + } + Assertions.assertTrue(received.messages.isEmpty()); + } + } + @Test void acceptsArbitraryJsonValuesAndNormalizesEncodedNull() throws Exception { MemoryTransport delegate = new MemoryTransport(); @@ -394,6 +507,7 @@ void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception envelope -> envelope.addProperty("operation", "x".repeat(1025)), envelope -> envelope.remove("data"), envelope -> envelope.add("error", JsonNull.INSTANCE), + envelope -> envelope.add("exception", exceptionEnvelope()), envelope -> envelope.addProperty("extra", true) ); for (Consumer mutation : malformed) { @@ -425,6 +539,7 @@ void dropsMalformedResponseAndNestedErrorShapes() throws Exception { envelope -> envelope.add("topic", JsonNull.INSTANCE), envelope -> envelope.addProperty("operation", "apply"), envelope -> envelope.add("error", JsonNull.INSTANCE), + envelope -> envelope.add("exception", exceptionEnvelope()), envelope -> envelope.addProperty("protocol", 1), envelope -> envelope.addProperty("type", true), envelope -> envelope.addProperty("type", "OTHER") @@ -437,19 +552,25 @@ void dropsMalformedResponseAndNestedErrorShapes() throws Exception { delegate.deliver(request.response(requestEnvelope())); List> malformedErrors = List.of( envelope -> envelope.remove("error"), + envelope -> envelope.remove("exception"), envelope -> envelope.addProperty("error", "OTHER"), envelope -> envelope.addProperty("error", 1), envelope -> envelope.addProperty("error", true), envelope -> envelope.add("error", JsonNull.INSTANCE), envelope -> envelope.add("error", new JsonObject()), + envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v1"), + envelope -> envelope.addProperty("type", "OTHER"), envelope -> envelope.add("data", JsonNull.INSTANCE), envelope -> envelope.addProperty("topic", "names"), envelope -> envelope.add("operation", JsonNull.INSTANCE) ); for (Consumer mutation : malformedErrors) { - JsonObject invalid = errorEnvelope(RpcMessage.Error.HANDLER_FAILED); - mutation.accept(invalid); - delegate.deliver(request.response(invalid)); + for (JsonElement details : List.of(JsonNull.INSTANCE, exceptionEnvelope())) { + JsonObject invalid = errorEnvelope(RpcMessage.Error.HANDLER_FAILED); + invalid.add("exception", details); + mutation.accept(invalid); + delegate.deliver(request.response(invalid)); + } } Assertions.assertTrue(received.messages.isEmpty()); delegate.deliver(request.response(responseEnvelope())); @@ -488,13 +609,16 @@ void neutralRpcDistinguishesNestedErrorsOuterErrorsAndInvalidResponses() throws RpcException failure = Assertions.assertThrows(RpcException.class, () -> proxy.apply("request")); Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, failure.reason()); Assertions.assertEquals(error, failure.remoteError()); + Assertions.assertNull(failure.remoteException()); Assertions.assertNull(failure.getCause()); + Assertions.assertEquals("Remote RPC call failed: " + error, failure.getMessage()); } for (RequestReplyMessage.Error error : RequestReplyMessage.Error.values()) { delegate.forward = request -> delegate.deliver(request.failure(error)); RpcException failure = Assertions.assertThrows(RpcException.class, () -> proxy.apply("request")); Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, failure.reason()); Assertions.assertNull(failure.remoteError()); + Assertions.assertNull(failure.remoteException()); RequestReplyException outer = Assertions.assertInstanceOf(RequestReplyException.class, failure.getCause()); Assertions.assertEquals(error, outer.remoteError()); } @@ -544,14 +668,137 @@ void dispatchesRealRpcFailuresInsideSuccessfulRequestReplyResponses() throws Exc Assertions.assertEquals(RpcMessage.failure(RpcMessage.Error.UNKNOWN_OPERATION), missingOperation); String operation = "apply(" + String.class.getTypeName() + ")"; RpcMessage codecFailure = caller.request(RpcMessage.request(operation, new JsonObject())); - Assertions.assertEquals(RpcMessage.failure(RpcMessage.Error.CODEC_FAILED), codecFailure); + Assertions.assertEquals(RpcMessage.Error.CODEC_FAILED, codecFailure.error()); + RpcExceptionDetails codecDetails = Objects.requireNonNull(codecFailure.exception()); + Assertions.assertEquals(JsonSyntaxException.class.getName(), codecDetails.type()); + Assertions.assertNotNull(codecDetails.message()); + Assertions.assertTrue(codecDetails.stackTrace().contains("Expected a string")); RpcMessage handlerFailure = caller.request(RpcMessage.request(operation, new JsonPrimitive("value"))); - Assertions.assertEquals(RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED), handlerFailure); + Assertions.assertEquals(RpcMessage.Error.HANDLER_FAILED, handlerFailure.error()); + RpcExceptionDetails handlerDetails = Objects.requireNonNull(handlerFailure.exception()); + Assertions.assertEquals(IllegalStateException.class.getName(), handlerDetails.type()); + Assertions.assertEquals("Handler failed", handlerDetails.message()); + Assertions.assertTrue(handlerDetails.stackTrace().contains("IllegalStateException: Handler failed")); Assertions.assertTrue(right.sent.stream().allMatch(message -> "RESPONSE".equals(message.payload().get("type").getAsString()))); } } + @Test + void proxyFailuresExposeApplicationDiagnosticsWithNestedAndSuppressedTracesAcrossReferenceFraming() { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = message -> right.deliver(DDExternalDamProtocol.decode(DDExternalDamProtocol.encode(message))); + right.forward = message -> left.deliver(DDExternalDamProtocol.decode(DDExternalDamProtocol.encode(message))); + Throwable root = new UnsupportedOperationException("root cause"); + Throwable nested = new IllegalArgumentException("nested cause", root); + Throwable suppressed = new IllegalArgumentException("suppressed failure"); + IllegalStateException application = new IllegalStateException("application \"failure\"\n雪😀", nested); + application.addSuppressed(suppressed); + for (Throwable failure : List.of(root, nested, suppressed, application)) { + failure.setStackTrace(new StackTraceElement[] {new StackTraceElement("Application", "execute", "Application.java", 42)}); + } + StringWriter trace = new StringWriter(); + application.printStackTrace(new PrintWriter(trace)); + CountingCodec callerCodec = new CountingCodec(); + CountingCodec handlerCodec = new CountingCodec(); + AtomicInteger calls = new AtomicInteger(); + try (var caller = RpcEndpoint.open(DDRpcTransport.forChannel(left), callerCodec, OPTIONS); + var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right), handlerCodec, OPTIONS); + var service = handler.register(TOPIC, Text.class, value -> { + calls.incrementAndGet(); + throw application; + })) { + RpcException failure = Assertions.assertThrows(RpcException.class, () -> caller.proxy(TOPIC, Text.class).apply("request")); + Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, failure.reason()); + Assertions.assertEquals(RpcMessage.Error.HANDLER_FAILED, failure.remoteError()); + Assertions.assertEquals(new RpcExceptionDetails(application.getClass().getName(), application.getMessage(), + trace.toString(), false), failure.remoteException()); + Assertions.assertTrue(failure.getMessage().contains(application.getClass().getName())); + Assertions.assertTrue(failure.getMessage().contains(application.getMessage())); + Throwable remote = Objects.requireNonNull(failure.getCause()); + Assertions.assertEquals("Remote stack trace:\n" + trace, remote.getMessage()); + Assertions.assertNull(remote.getCause()); + Assertions.assertEquals(0, remote.getStackTrace().length); + Assertions.assertEquals(0, remote.getSuppressed().length); + Assertions.assertEquals(0, failure.getSuppressed().length); + Assertions.assertEquals(1, calls.get()); + Assertions.assertEquals(1, callerCodec.encodes.get()); + Assertions.assertEquals(0, callerCodec.decodes.get()); + Assertions.assertEquals(0, handlerCodec.encodes.get()); + Assertions.assertEquals(1, handlerCodec.decodes.get()); + Assertions.assertEquals(1, left.sent.size()); + Assertions.assertEquals(1, right.sent.size()); + JsonObject request = left.sent.getFirst().payload(); + JsonObject response = right.sent.getFirst().payload(); + Assertions.assertEquals(request.get("requestId"), response.get("requestId")); + Assertions.assertEquals(request.get("replyTo"), response.get("replyTo")); + Assertions.assertEquals("datadam.requestreply.v1", right.sent.getFirst().type()); + Assertions.assertEquals(Set.of("requestId", "replyTo", "type", "data"), response.keySet()); + Assertions.assertEquals("RESPONSE", response.get("type").getAsString()); + JsonObject rpc = response.getAsJsonObject("data"); + Assertions.assertEquals("dbeaver.rpc.v2", rpc.get("protocol").getAsString()); + Assertions.assertEquals("ERROR", rpc.get("type").getAsString()); + Assertions.assertEquals("HANDLER_FAILED", rpc.get("error").getAsString()); + } + } + + @Test + void remoteArgumentAndResultCodecFailuresCarryDiagnosticsWithoutEncodingErrorsOrRetrying() { + for (boolean encoding : List.of(false, true)) { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = message -> right.deliver(DDExternalDamProtocol.decode(DDExternalDamProtocol.encode(message))); + right.forward = message -> left.deliver(DDExternalDamProtocol.decode(DDExternalDamProtocol.encode(message))); + CountingCodec callerCodec = new CountingCodec(); + AtomicInteger encodes = new AtomicInteger(); + AtomicInteger decodes = new AtomicInteger(); + AtomicInteger calls = new AtomicInteger(); + IllegalStateException unavailable = new IllegalStateException(encoding ? "Result encoding failed" : "Argument decoding failed"); + RpcCodec handlerCodec = new RpcCodec<>() { + @Override + @Nullable + public JsonElement encode(@Nullable Object value, @NotNull Type type) { + encodes.incrementAndGet(); + throw unavailable; + } + + @Override + @Nullable + public Object decode(@Nullable JsonElement payload, @NotNull Type type) throws Exception { + decodes.incrementAndGet(); + if (!encoding) { + throw unavailable; + } + return CODEC.decode(payload, type); + } + }; + try (var caller = RpcEndpoint.open(DDRpcTransport.forChannel(left), callerCodec, OPTIONS); + var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right), handlerCodec, OPTIONS); + var service = handler.register(TOPIC, Text.class, value -> { + calls.incrementAndGet(); + return value; + })) { + RpcException failure = Assertions.assertThrows(RpcException.class, + () -> caller.proxy(TOPIC, Text.class).apply("request")); + Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, failure.reason()); + Assertions.assertEquals(RpcMessage.Error.CODEC_FAILED, failure.remoteError()); + RpcExceptionDetails details = Objects.requireNonNull(failure.remoteException()); + Assertions.assertEquals(IllegalStateException.class.getName(), details.type()); + Assertions.assertEquals(unavailable.getMessage(), details.message()); + Assertions.assertTrue(details.stackTrace().contains("IllegalStateException: " + unavailable.getMessage())); + Assertions.assertTrue(Objects.requireNonNull(failure.getCause()).getMessage().contains(unavailable.getMessage())); + Assertions.assertEquals(encoding ? 1 : 0, calls.get()); + Assertions.assertEquals(encoding ? 1 : 0, encodes.get()); + Assertions.assertEquals(1, decodes.get()); + Assertions.assertEquals(1, callerCodec.encodes.get()); + Assertions.assertEquals(0, callerCodec.decodes.get()); + Assertions.assertEquals(1, left.sent.size()); + Assertions.assertEquals(1, right.sent.size()); + } + } + } + @Test void rejectsMismatchedOutgoingFramesBeforeCallingTheDelegate() { MemoryTransport delegate = new MemoryTransport(); @@ -782,6 +1029,11 @@ public Object decode(@Nullable String payload, @NotNull Type type) { () -> caller.proxy(TOPIC, Text.class).apply("incompatible")); Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, codecFailure.reason()); Assertions.assertEquals(RpcMessage.Error.CODEC_FAILED, codecFailure.remoteError()); + RpcExceptionDetails details = Objects.requireNonNull(codecFailure.remoteException()); + Assertions.assertEquals(ClassCastException.class.getName(), details.type()); + Assertions.assertTrue(details.stackTrace().contains(ClassCastException.class.getName())); + Assertions.assertTrue(Objects.requireNonNull(codecFailure.getCause()).getMessage() + .contains(ClassCastException.class.getName())); } } } @@ -833,9 +1085,60 @@ private static JsonObject responseEnvelope() { private static JsonObject errorEnvelope(@NotNull RpcMessage.Error error) { JsonObject result = JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v2\",\"type\":\"ERROR\"}").getAsJsonObject(); result.addProperty("error", error.name()); + result.add("exception", JsonNull.INSTANCE); return result; } + @NotNull + private static JsonObject exceptionEnvelope() { + return JsonParser.parseString("{\"type\":\"application.Failure\",\"message\":\"failure\"," + + "\"stackTrace\":\"trace\",\"truncated\":false}").getAsJsonObject(); + } + + @NotNull + private static List malformedExceptions() { + List invalid = new ArrayList<>(List.of(new JsonPrimitive("failure"), + new JsonPrimitive(1), new JsonPrimitive(true), new JsonArray(), new JsonObject())); + List nonStrings = List.of(new JsonPrimitive(1), new JsonPrimitive(true), JsonNull.INSTANCE, + new JsonObject(), new JsonArray()); + for (String field : List.of("type", "message", "stackTrace", "truncated")) { + JsonObject missing = exceptionEnvelope(); + missing.remove(field); + invalid.add(missing); + JsonObject unknown = missing.deepCopy(); + unknown.addProperty("extra", "unexpected"); + invalid.add(unknown); + List wrongTypes = field.equals("truncated") + ? List.of(new JsonPrimitive("true"), new JsonPrimitive(1), JsonNull.INSTANCE, new JsonObject(), new JsonArray()) + : nonStrings; + for (JsonElement value : wrongTypes) { + if (field.equals("message") && value.isJsonNull()) { + continue; + } + JsonObject wrongType = exceptionEnvelope(); + wrongType.add(field, value); + invalid.add(wrongType); + } + } + List> malformed = List.of( + exception -> exception.addProperty("type", ""), + exception -> exception.addProperty("type", " \t\n\u2003"), + exception -> exception.addProperty("type", "x".repeat(RpcExceptionDetails.MAX_TYPE_LENGTH + 1)), + exception -> exception.addProperty("message", "x".repeat(RpcExceptionDetails.MAX_MESSAGE_LENGTH + 1)), + exception -> exception.addProperty("stackTrace", "x".repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH + 1)), + exception -> exception.addProperty("type", "😀".repeat(RpcExceptionDetails.MAX_TYPE_LENGTH / 2 + 1)), + exception -> exception.addProperty("message", "😀".repeat(RpcExceptionDetails.MAX_MESSAGE_LENGTH / 2 + 1)), + exception -> exception.addProperty("stackTrace", "😀".repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH / 2 + 1)), + exception -> exception.addProperty("extra", true) + ); + for (Consumer mutation : malformed) { + JsonObject exception = exceptionEnvelope(); + mutation.accept(exception); + invalid.add(exception); + } + return invalid; + } + /** Exchanges concretely typed generic values. */ public interface Names { @NotNull @@ -879,6 +1182,25 @@ public Object decode(@Nullable JsonElement payload, @NotNull Type type) { } } + private static final class CountingCodec implements RpcCodec { + private final AtomicInteger encodes = new AtomicInteger(); + private final AtomicInteger decodes = new AtomicInteger(); + + @Override + @Nullable + public JsonElement encode(@Nullable Object value, @NotNull Type type) throws Exception { + encodes.incrementAndGet(); + return CODEC.encode(value, type); + } + + @Override + @Nullable + public Object decode(@Nullable JsonElement payload, @NotNull Type type) throws Exception { + decodes.incrementAndGet(); + return CODEC.decode(payload, type); + } + } + private static final class Received implements RequestReplyTransport.Listener> { private final List>> messages = new ArrayList<>(); private int disconnects; diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java index 5df51a7..bee500d 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java @@ -28,6 +28,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Modifier; import java.lang.reflect.Type; import java.time.Duration; @@ -51,6 +52,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -277,7 +280,7 @@ void voidCallsWaitForHandlerCompletionAndDoNotEncodeOrDecodeAResult() throws Exc } @Test - void reportsHandlerFailuresWithoutLeakingPrivateDetailsOrRetrying() throws Exception { + void reportsOriginalHandlerExceptionDetailsWithoutSerializingTheThrowableOrRetrying() throws Exception { var clientTransport = new MemoryTransport(); var serverTransport = new MemoryTransport(); var calls = new AtomicInteger(); @@ -296,9 +299,15 @@ void reportsHandlerFailuresWithoutLeakingPrivateDetailsOrRetrying() throws Excep RpcException failure = remoteFailure(RpcMessage.Error.HANDLER_FAILED, () -> client.proxy("failure", FailingService.class, new TestCodec()).fail("secret argument")); - assertFalse(failure.toString().contains("private")); + RpcExceptionDetails details = failure.remoteException(); + assertNotNull(details); + assertEquals(cause.getClass().getName(), details.type()); + assertEquals(cause.getMessage(), details.message()); + assertTrue(details.stackTrace().contains(cause.toString())); + assertFalse(details.stackTrace().contains(InvocationTargetException.class.getName())); + assertNotSame(cause, failure.getCause()); assertFalse(failure.toString().contains("secret")); - assertEquals(RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED), serverTransport.next().payload()); + assertEquals(RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED, details), serverTransport.next().payload()); assertEquals("secret argument", clientTransport.next().payload().payload()); assertTrue(clientTransport.sent.isEmpty()); assertTrue(serverTransport.sent.isEmpty()); @@ -339,10 +348,15 @@ void distinguishesLocalAndRemoteCodecFailuresAtAllFourBoundariesAndRecovers() th RpcException failure = failure(RpcException.Reason.CODEC_FAILED, () -> proxy.echo("secret argument")); assertSame(cause, failure.getCause()); assertNull(failure.remoteError()); + assertNull(failure.remoteException()); } else { RpcException failure = remoteFailure(RpcMessage.Error.CODEC_FAILED, () -> proxy.echo("secret argument")); - assertFalse(failure.toString().contains("private")); - assertEquals(RpcMessage.failure(RpcMessage.Error.CODEC_FAILED), serverTransport.next().payload()); + RpcExceptionDetails details = failure.remoteException(); + assertNotNull(details); + assertEquals(cause.getClass().getName(), details.type()); + assertEquals(cause.getMessage(), details.message()); + assertNotSame(cause, failure.getCause()); + assertEquals(RpcMessage.failure(RpcMessage.Error.CODEC_FAILED, details), serverTransport.next().payload()); } assertEquals(boundary < 2 ? 0 : 1, calls.get()); assertEquals(boundary == 0 ? 0 : 1, clientTransport.sent.size()); diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java new file mode 100644 index 0000000..88e1240 --- /dev/null +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java @@ -0,0 +1,145 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.jkiss.code.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(10) +class RpcExceptionDetailsTest { + @Test + void capturesStandardTextIncludingCausesSuppressedExceptionsAndOriginalFrames() { + var cause = new IllegalArgumentException("Invalid argument"); + var failure = new IllegalStateException("Operation failed", cause); + var suppressed = new IOException("Cleanup failed"); + failure.setStackTrace(new StackTraceElement[] {new StackTraceElement("Receiver", "handle", "Receiver.java", 42)}); + cause.setStackTrace(new StackTraceElement[] {new StackTraceElement("Repository", "load", "Repository.java", 17)}); + suppressed.setStackTrace(new StackTraceElement[] {new StackTraceElement("Resource", "close", "Resource.java", 9)}); + failure.addSuppressed(suppressed); + StringWriter expected = new StringWriter(); + failure.printStackTrace(new PrintWriter(expected)); + + RpcExceptionDetails details = RpcExceptionDetails.capture(failure); + + assertEquals(IllegalStateException.class.getName(), details.type()); + assertEquals("Operation failed", details.message()); + assertEquals(expected.toString(), details.stackTrace()); + assertTrue(details.stackTrace().contains("Caused by: " + cause)); + assertTrue(details.stackTrace().contains("Suppressed: " + IOException.class.getName())); + assertFalse(details.truncated()); + } + + @Test + void preservesNullMessagesAndTerminatesCircularExceptionGraphs() { + var failure = new Exception(); + var cause = new Exception("Cause", failure); + failure.setStackTrace(new StackTraceElement[0]); + cause.setStackTrace(new StackTraceElement[0]); + failure.initCause(cause); + failure.addSuppressed(cause); + + RpcExceptionDetails details = RpcExceptionDetails.capture(failure); + + assertNull(details.message()); + assertTrue(details.stackTrace().contains("CIRCULAR REFERENCE")); + assertFalse(details.truncated()); + } + + @Test + void boundsMessagesAndTraceTextWithoutSplittingSurrogatePairs() { + String message = "m".repeat(RpcExceptionDetails.MAX_MESSAGE_LENGTH - 1) + "😀tail"; + var failure = new IllegalStateException(message) { + @Override + public void printStackTrace(@NotNull PrintWriter writer) { + writer.print("s".repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH - 1) + "😀tail"); + } + }; + + RpcExceptionDetails details = RpcExceptionDetails.capture(failure); + + assertEquals("m".repeat(RpcExceptionDetails.MAX_MESSAGE_LENGTH - 1), details.message()); + assertEquals("s".repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH - 1), details.stackTrace()); + assertTrue(details.truncated()); + } + + @Test + void stopsFormattingAtTheLimitInsteadOfBuildingAnUnboundedString() { + AtomicInteger progress = new AtomicInteger(); + var failure = new IllegalStateException("Failure") { + @Override + public void printStackTrace(@NotNull PrintWriter writer) { + writer.print("x".repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH)); + progress.incrementAndGet(); + writer.write(new char[] {'y'}, 0, 1); + progress.incrementAndGet(); + throw new AssertionError("Formatting continued past the limit"); + } + }; + + RpcExceptionDetails details = RpcExceptionDetails.capture(failure); + + assertEquals(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH, details.stackTrace().length()); + assertEquals(1, progress.get()); + assertTrue(details.truncated()); + } + + @Test + void preservesTheFailureCodeEvenIfCustomExceptionFormattingFails() { + var failure = new IllegalStateException() { + @Override + public String getMessage() { + throw new IllegalStateException("Message unavailable"); + } + }; + + RpcExceptionDetails details = RpcExceptionDetails.capture(failure); + + assertEquals(failure.getClass().getName(), details.type()); + assertNull(details.message()); + assertEquals(details.type(), details.stackTrace()); + assertTrue(details.truncated()); + } + + @Test + void validatesAllBoundsWithoutRejectingTheirExactLimits() { + var details = new RpcExceptionDetails("t".repeat(RpcExceptionDetails.MAX_TYPE_LENGTH), + "m".repeat(RpcExceptionDetails.MAX_MESSAGE_LENGTH), "s".repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH), false); + + assertFalse(details.truncated()); + assertThrows(NullPointerException.class, () -> new RpcExceptionDetails(null, null, "", false)); + assertThrows(NullPointerException.class, () -> new RpcExceptionDetails("type", null, null, false)); + assertThrows(IllegalArgumentException.class, () -> new RpcExceptionDetails(" ", null, "", false)); + assertThrows(IllegalArgumentException.class, + () -> new RpcExceptionDetails("t".repeat(RpcExceptionDetails.MAX_TYPE_LENGTH + 1), null, "", false)); + assertThrows(IllegalArgumentException.class, + () -> new RpcExceptionDetails("type", "m".repeat(RpcExceptionDetails.MAX_MESSAGE_LENGTH + 1), "", false)); + assertThrows(IllegalArgumentException.class, + () -> new RpcExceptionDetails("type", null, "s".repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH + 1), false)); + } +} diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionTest.java new file mode 100644 index 0000000..565e35a --- /dev/null +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionTest.java @@ -0,0 +1,67 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.junit.jupiter.api.Test; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RpcExceptionTest { + @Test + void includesRemoteTextInOrdinaryExceptionLoggingWithoutLoadingTheRemoteClass() { + var details = new RpcExceptionDetails("unavailable.application.Failure", "Application failed", + "unavailable.application.Failure: Application failed\n\tat remote.Receiver.call(Receiver.java:42)", true); + + var failure = new RpcException(RpcMessage.Error.HANDLER_FAILED, details); + StringWriter printed = new StringWriter(); + failure.printStackTrace(new PrintWriter(printed)); + + assertEquals(RpcException.Reason.REMOTE_ERROR, failure.reason()); + assertEquals(RpcMessage.Error.HANDLER_FAILED, failure.remoteError()); + assertSame(details, failure.remoteException()); + assertTrue(failure.getMessage().contains(details.type())); + assertTrue(failure.getMessage().contains(details.message())); + assertTrue(printed.toString().contains(details.stackTrace())); + assertTrue(printed.toString().contains("Remote stack trace (truncated)")); + assertNotEquals(details.type(), failure.getCause().getClass().getName()); + assertEquals(0, failure.getCause().getStackTrace().length); + assertTrue(failure.getStackTrace().length > 0); + } + + @Test + void preservesCodeOnlyErrorsAndLocalCausesWithoutInventingRemoteDiagnostics() { + var remote = new RpcException(RpcMessage.Error.UNKNOWN_SERVICE); + assertEquals("Remote RPC call failed: UNKNOWN_SERVICE", remote.getMessage()); + assertNull(remote.getCause()); + assertNull(remote.remoteException()); + + var cause = new IllegalStateException("Local failure"); + var local = new RpcException(RpcException.Reason.SEND_FAILED, cause); + assertSame(cause, local.getCause()); + assertNull(local.remoteException()); + assertNull(local.remoteError()); + assertFalse(local.getMessage().contains(cause.getMessage())); + } +} diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java index f2c132d..34e322e 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java @@ -44,11 +44,11 @@ void preservesOpaquePayloadsAndAllowsNullApplicationValues() { assertSame(payload, response.payload()); assertNull(response.operation()); assertNull(response.error()); - assertEquals(new RpcMessage<>(RpcMessage.Type.RESPONSE, null, null, null), RpcMessage.response(null)); + assertEquals(new RpcMessage<>(RpcMessage.Type.RESPONSE, null, null, null, null), RpcMessage.response(null)); } @Test - void failuresCarryOnlyASafeErrorCode() { + void failuresCanCarryACodeWithoutExceptionDetails() { for (RpcMessage.Error error : RpcMessage.Error.values()) { RpcMessage failure = RpcMessage.failure(error); @@ -56,10 +56,25 @@ void failuresCarryOnlyASafeErrorCode() { assertEquals(error, failure.error()); assertNull(failure.operation()); assertNull(failure.payload()); + assertNull(failure.exception()); } assertThrows(NullPointerException.class, () -> RpcMessage.failure(null)); } + @Test + void exceptionDetailsBelongOnlyToErrorResponsesAndNeverToApplicationPayloads() { + var details = new RpcExceptionDetails("test.ApplicationException", "failure", "remote stack trace", false); + RpcMessage failure = RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED, details); + + assertSame(details, failure.exception()); + assertNull(failure.payload()); + assertEquals(RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED), RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED, null)); + assertThrows(IllegalArgumentException.class, + () -> new RpcMessage<>(RpcMessage.Type.REQUEST, "echo(int)", 1, null, details)); + assertThrows(IllegalArgumentException.class, + () -> new RpcMessage<>(RpcMessage.Type.RESPONSE, null, 1, null, details)); + } + @Test void validatesOperationBoundsWithoutRejectingTheBoundary() { String operation = "o".repeat(1024); @@ -70,23 +85,23 @@ void validatesOperationBoundsWithoutRejectingTheBoundary() { assertThrows(IllegalArgumentException.class, () -> RpcMessage.request(invalid, null)); } assertThrows(IllegalArgumentException.class, () -> RpcMessage.request(null, null)); - assertThrows(NullPointerException.class, () -> new RpcMessage<>(null, null, null, null)); + assertThrows(NullPointerException.class, () -> new RpcMessage<>(null, null, null, null, null)); } @Test void rejectsRoutingFieldsOnRepliesAndInconsistentPayloadOrErrorFields() { for (RpcMessage.Type type : List.of(RpcMessage.Type.RESPONSE, RpcMessage.Type.ERROR)) { RpcMessage.Error error = type == RpcMessage.Type.ERROR ? RpcMessage.Error.HANDLER_FAILED : null; - assertThrows(IllegalArgumentException.class, () -> new RpcMessage<>(type, "echo(int)", null, error)); + assertThrows(IllegalArgumentException.class, () -> new RpcMessage<>(type, "echo(int)", null, error, null)); } - assertThrows(IllegalArgumentException.class, () -> new RpcMessage<>(RpcMessage.Type.ERROR, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> new RpcMessage<>(RpcMessage.Type.ERROR, null, null, null, null)); for (RpcMessage.Error error : RpcMessage.Error.values()) { assertThrows(IllegalArgumentException.class, - () -> new RpcMessage<>(RpcMessage.Type.REQUEST, "echo(int)", null, error)); + () -> new RpcMessage<>(RpcMessage.Type.REQUEST, "echo(int)", null, error, null)); assertThrows(IllegalArgumentException.class, - () -> new RpcMessage<>(RpcMessage.Type.RESPONSE, null, null, error)); + () -> new RpcMessage<>(RpcMessage.Type.RESPONSE, null, null, error, null)); assertThrows(IllegalArgumentException.class, - () -> new RpcMessage<>(RpcMessage.Type.ERROR, null, "private payload", error)); + () -> new RpcMessage<>(RpcMessage.Type.ERROR, null, "private payload", error, null)); } } } diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java index 5e6e8f3..6d654f9 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java @@ -104,7 +104,14 @@ static RpcException failure(@NotNull RpcException.Reason reason, @NotNull Future static RpcException remoteFailure(@NotNull RpcMessage.Error error, @NotNull Executable action) { RpcException failure = failure(RpcException.Reason.REMOTE_ERROR, action); assertEquals(error, failure.remoteError()); - assertNull(failure.getCause()); + if (failure.remoteException() == null) { + assertNull(failure.getCause()); + } else { + Throwable cause = failure.getCause(); + assertNotNull(cause); + assertEquals(0, cause.getStackTrace().length); + assertTrue(cause.getMessage().contains(failure.remoteException().stackTrace())); + } return failure; } From 4974a44521d3171f73e2f5480b5b5d1e56cba003 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Sun, 25 Oct 2026 07:23:08 +0400 Subject: [PATCH 20/30] dbeaver/pro#11108 Fix async External DAM cancellation and lint --- .../api/client/DDExternalDamGraphQlClient.java | 14 ++++++++++++-- .../client/DDExternalDamCallbackClientTest.java | 5 +++++ .../external/api/rpc/DDRpcTransportTest.java | 2 +- .../messaging/rpc/RpcExceptionDetailsTest.java | 2 ++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java index 5fccae5..46d8197 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java @@ -135,12 +135,21 @@ private static CompletionStage propagateCancellation( @NotNull CompletableFuture result, @NotNull CompletableFuture source ) { + CompletableFuture completion = new CompletableFuture<>(); result.whenComplete((value, error) -> { - if (result.isCancelled()) { + if (error == null) { + completion.complete(value); + } else { + completion.completeExceptionally(error); + } + }); + completion.whenComplete((value, error) -> { + if (completion.isCancelled()) { + result.cancel(true); source.cancel(true); } }); - return result; + return completion; } @NotNull @@ -227,6 +236,7 @@ private static String validateServiceUrl(@NotNull String serviceUrl) { @FunctionalInterface interface ResultReader { + @Nullable T read(@NotNull JsonElement result) throws DDExternalDamException; } } diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java index ee50441..2aba33e 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java @@ -319,6 +319,7 @@ private void assertStalledBodyIsCancelled(boolean cancelDelivery) throws Excepti assertTrue(headersSent.await(3, TimeUnit.SECONDS)); if (cancelDelivery) { assertTrue(delivery.cancel(true)); + assertTrue(delivery.isCancelled()); assertThrows(CancellationException.class, () -> delivery.get(1, TimeUnit.SECONDS)); } else { ExecutionException completion = assertThrows(ExecutionException.class, @@ -334,6 +335,10 @@ private void assertStalledBodyIsCancelled(boolean cancelDelivery) throws Excepti assertTrue(probeFinished.await(5, TimeUnit.SECONDS)); assertNotNull(bodyFailure.get()); assertEquals(1, requestCount.get()); + if (cancelDelivery) { + assertTrue(delivery.isCancelled()); + assertThrows(CancellationException.class, () -> delivery.get(1, TimeUnit.SECONDS)); + } } finally { resumeBody.countDown(); delivery.cancel(true); diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java index 4eeabf2..b58bfd2 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java @@ -349,7 +349,7 @@ void roundTripsStructuredDiagnosticsAndExplicitNullMessagesWithoutChangingOuterF Assertions.assertNull(wire.error()); JsonObject envelope = Objects.requireNonNull(wire.payload()); Assertions.assertEquals(Set.of("protocol", "type", "error", "exception"), envelope.keySet()); - JsonObject expected = errorEnvelope(error); + final JsonObject expected = errorEnvelope(error); JsonObject exception = new JsonObject(); exception.addProperty("type", details.type()); exception.add("message", details.message() == null ? JsonNull.INSTANCE : new JsonPrimitive(details.message())); diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java index 88e1240..c60302f 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java @@ -17,6 +17,7 @@ package com.dbeaver.messaging.rpc; import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -113,6 +114,7 @@ public void printStackTrace(@NotNull PrintWriter writer) { void preservesTheFailureCodeEvenIfCustomExceptionFormattingFails() { var failure = new IllegalStateException() { @Override + @Nullable public String getMessage() { throw new IllegalStateException("Message unavailable"); } From f71b0da07de3706266456dc8ea327634d7b1f7e2 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Sun, 25 Oct 2026 07:28:02 +0400 Subject: [PATCH 21/30] dbeaver/pro#11108 Avoid Gson wording assumptions in RPC tests --- .../datadam/external/api/rpc/DDRpcTransportTest.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java index b58bfd2..c859aef 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java +++ b/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java @@ -667,12 +667,14 @@ void dispatchesRealRpcFailuresInsideSuccessfulRequestReplyResponses() throws Exc RpcMessage missingOperation = caller.request(RpcMessage.request("missing", null)); Assertions.assertEquals(RpcMessage.failure(RpcMessage.Error.UNKNOWN_OPERATION), missingOperation); String operation = "apply(" + String.class.getTypeName() + ")"; + final JsonSyntaxException expectedCodecFailure = Assertions.assertThrows(JsonSyntaxException.class, + () -> CODEC.decode(new JsonObject(), String.class)); RpcMessage codecFailure = caller.request(RpcMessage.request(operation, new JsonObject())); Assertions.assertEquals(RpcMessage.Error.CODEC_FAILED, codecFailure.error()); RpcExceptionDetails codecDetails = Objects.requireNonNull(codecFailure.exception()); - Assertions.assertEquals(JsonSyntaxException.class.getName(), codecDetails.type()); - Assertions.assertNotNull(codecDetails.message()); - Assertions.assertTrue(codecDetails.stackTrace().contains("Expected a string")); + Assertions.assertEquals(expectedCodecFailure.getClass().getName(), codecDetails.type()); + Assertions.assertEquals(expectedCodecFailure.getMessage(), codecDetails.message()); + Assertions.assertTrue(codecDetails.stackTrace().startsWith(expectedCodecFailure + System.lineSeparator())); RpcMessage handlerFailure = caller.request(RpcMessage.request(operation, new JsonPrimitive("value"))); Assertions.assertEquals(RpcMessage.Error.HANDLER_FAILED, handlerFailure.error()); RpcExceptionDetails handlerDetails = Objects.requireNonNull(handlerFailure.exception()); From c557cafa75bf6f64f6d7a9ecc02d7d06f2acf428 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Mon, 26 Oct 2026 15:02:11 +0400 Subject: [PATCH 22/30] dbeaver/pro#11108 Rename External DAM API module to Messaging API --- README.md | 4 ++-- .../MESSAGING.md | 2 +- .../META-INF/MANIFEST.MF | 6 +++--- .../README.md | 4 ++-- .../build.properties | 0 .../pom.xml | 4 ++-- .../com/dbeaver/datadam/external/api/DDExternalDamApi.java | 0 .../dbeaver/datadam/external/api/DDExternalDamProtocol.java | 0 .../external/api/client/DDExternalDamCallbackClient.java | 0 .../datadam/external/api/client/DDExternalDamClient.java | 0 .../external/api/client/DDExternalDamGraphQlClient.java | 0 .../external/api/exception/DDExternalDamException.java | 0 .../datadam/external/api/model/DDConnectionInfo.java | 0 .../datadam/external/api/model/DDExternalDamConnection.java | 0 .../com/dbeaver/datadam/external/api/model/DDHello.java | 0 .../com/dbeaver/datadam/external/api/model/DDMessage.java | 0 .../com/dbeaver/datadam/external/api/model/DDReady.java | 0 .../external/api/requestreply/DDRequestReplyTransport.java | 0 .../dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java | 0 .../dbeaver/datadam/external/api/rpc/DDRpcTransport.java | 0 .../datadam/external/api/transport/DDChannelState.java | 0 .../datadam/external/api/transport/DDChannelStatus.java | 0 .../external/api/transport/DDConnectionListener.java | 0 .../datadam/external/api/transport/DDMessageChannel.java | 0 .../datadam/external/api/transport/DDMessageListener.java | 0 .../datadam/external/api/transport/DDSubscription.java | 0 .../dbeaver/messaging/requestreply/MessageSubscription.java | 0 .../com/dbeaver/messaging/requestreply/RequestHandler.java | 0 .../messaging/requestreply/RequestReplyEndpoint.java | 0 .../messaging/requestreply/RequestReplyException.java | 0 .../dbeaver/messaging/requestreply/RequestReplyMessage.java | 0 .../dbeaver/messaging/requestreply/RequestReplyOptions.java | 0 .../messaging/requestreply/RequestReplyTransport.java | 0 .../src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java | 0 .../main/java/com/dbeaver/messaging/rpc/RpcContract.java | 0 .../main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java | 0 .../main/java/com/dbeaver/messaging/rpc/RpcException.java | 0 .../java/com/dbeaver/messaging/rpc/RpcExceptionDetails.java | 0 .../src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java | 0 .../main/java/com/dbeaver/messaging/rpc/RpcTransport.java | 0 .../dbeaver/datadam/external/api/DDExternalDamApiTest.java | 0 .../datadam/external/api/DDExternalDamProtocolTest.java | 0 .../api/client/DDExternalDamCallbackClientTest.java | 0 .../external/api/client/DDExternalDamClientTest.java | 0 .../external/api/model/DDExternalDamConnectionTest.java | 0 .../dbeaver/datadam/external/api/model/DDMessageTest.java | 0 .../api/requestreply/DDRequestReplyTransportTest.java | 0 .../datadam/external/api/rpc/DDGsonRpcCodecTest.java | 0 .../external/api/rpc/DDRpcTopicsIntegrationTest.java | 0 .../datadam/external/api/rpc/DDRpcTransportTest.java | 0 .../datadam/external/api/transport/DDChannelStatusTest.java | 0 .../messaging/requestreply/RequestReplyDeadlineTest.java | 0 .../messaging/requestreply/RequestReplyEndpointTest.java | 0 .../messaging/requestreply/RequestReplyMessageTest.java | 0 .../messaging/requestreply/RequestReplyOptionsTest.java | 0 .../java/com/dbeaver/messaging/rpc/RpcContractTest.java | 0 .../com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java | 0 .../java/com/dbeaver/messaging/rpc/RpcEndpointTest.java | 0 .../com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java | 0 .../java/com/dbeaver/messaging/rpc/RpcExceptionTest.java | 0 .../test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java | 0 .../test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java | 0 .../messaging/rpc/fixtures/HiddenInheritedService.java | 0 .../messaging/rpc/fixtures/PublicInheritedService.java | 0 apis/pom.xml | 2 +- 65 files changed, 11 insertions(+), 11 deletions(-) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/MESSAGING.md (99%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/META-INF/MANIFEST.MF (81%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/README.md (99%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/build.properties (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/pom.xml (95%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/messaging/rpc/RpcException.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/messaging/rpc/RpcExceptionDetails.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java (100%) rename apis/{com.dbeaver.datadam.external-dam.api => com.dbeaver.datadam.messaging.api}/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java (100%) diff --git a/README.md b/README.md index 6c504cf..c870873 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ This repository contains API for global DBeaver ecosystem. OSGI APIs and Service for DataDam integration Used by DBeaver, dbvr, CloudBeaver and by DataDam itself -## External DAM API +## Messaging API -[`apis/com.dbeaver.datadam.external-dam.api`](apis/com.dbeaver.datadam.external-dam.api/README.md) contains the External DAM +[`apis/com.dbeaver.datadam.messaging.api`](apis/com.dbeaver.datadam.messaging.api/README.md) contains the External DAM contracts, clients and transport adapters, together with the messaging implementation. Java packages remain `com.dbeaver.datadam.external.api.*` and `com.dbeaver.messaging.*`; messaging is not a separate Maven or OSGi module. diff --git a/apis/com.dbeaver.datadam.external-dam.api/MESSAGING.md b/apis/com.dbeaver.datadam.messaging.api/MESSAGING.md similarity index 99% rename from apis/com.dbeaver.datadam.external-dam.api/MESSAGING.md rename to apis/com.dbeaver.datadam.messaging.api/MESSAGING.md index 70c7490..0df9178 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/MESSAGING.md +++ b/apis/com.dbeaver.datadam.messaging.api/MESSAGING.md @@ -1,7 +1,7 @@ # Messaging: request/reply and typed RPC The `com.dbeaver.messaging.requestreply` and `com.dbeaver.messaging.rpc` packages live in the -`com.dbeaver.datadam.external-dam.api` module. They supply transport-independent request/reply and typed RPC for Java 21. +`com.dbeaver.datadam.messaging.api` module. They supply transport-independent request/reply and typed RPC for Java 21. These packages do not depend on DataDam, HTTP, WebSocket, JSON, Spring, or Eclipse runtime services; their only non-JDK production dependency supplies nullability annotations. DataDam adapters and codecs live in separate packages in the same module. diff --git a/apis/com.dbeaver.datadam.external-dam.api/META-INF/MANIFEST.MF b/apis/com.dbeaver.datadam.messaging.api/META-INF/MANIFEST.MF similarity index 81% rename from apis/com.dbeaver.datadam.external-dam.api/META-INF/MANIFEST.MF rename to apis/com.dbeaver.datadam.messaging.api/META-INF/MANIFEST.MF index be5e7b9..eaeffea 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/META-INF/MANIFEST.MF +++ b/apis/com.dbeaver.datadam.messaging.api/META-INF/MANIFEST.MF @@ -1,8 +1,8 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Vendor: DBeaver Corp -Bundle-Name: DataDam External DAM API -Bundle-SymbolicName: com.dbeaver.datadam.external-dam.api +Bundle-Name: DataDam Messaging API +Bundle-SymbolicName: com.dbeaver.datadam.messaging.api Bundle-Version: 0.0.17.qualifier Bundle-RequiredExecutionEnvironment: JavaSE-21 Export-Package: com.dbeaver.datadam.external.api, @@ -18,4 +18,4 @@ Bundle-ActivationPolicy: lazy Require-Bundle: org.jkiss.utils, com.dbeaver.rest.client;visibility:=reexport Import-Package: com.google.gson -Automatic-Module-Name: com.dbeaver.datadam.external.dam.api +Automatic-Module-Name: com.dbeaver.datadam.messaging.api diff --git a/apis/com.dbeaver.datadam.external-dam.api/README.md b/apis/com.dbeaver.datadam.messaging.api/README.md similarity index 99% rename from apis/com.dbeaver.datadam.external-dam.api/README.md rename to apis/com.dbeaver.datadam.messaging.api/README.md index a802a8b..9a818b0 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/README.md +++ b/apis/com.dbeaver.datadam.messaging.api/README.md @@ -1,6 +1,6 @@ -# External DAM API +# Messaging API -The `com.dbeaver.datadam.external-dam.api` module contains the Desktop WebSocket contracts, a backend-only client for +The `com.dbeaver.datadam.messaging.api` module contains the Desktop WebSocket contracts, a backend-only client for the External DAM internal GraphQL API, and the transport-independent messaging packages. Neither the models nor the WebSocket protocol depend on the HTTP client, Spring, or Eclipse runtime services. diff --git a/apis/com.dbeaver.datadam.external-dam.api/build.properties b/apis/com.dbeaver.datadam.messaging.api/build.properties similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/build.properties rename to apis/com.dbeaver.datadam.messaging.api/build.properties diff --git a/apis/com.dbeaver.datadam.external-dam.api/pom.xml b/apis/com.dbeaver.datadam.messaging.api/pom.xml similarity index 95% rename from apis/com.dbeaver.datadam.external-dam.api/pom.xml rename to apis/com.dbeaver.datadam.messaging.api/pom.xml index 2681ff1..a635509 100644 --- a/apis/com.dbeaver.datadam.external-dam.api/pom.xml +++ b/apis/com.dbeaver.datadam.messaging.api/pom.xml @@ -10,9 +10,9 @@ ../pom.xml - com.dbeaver.datadam.external-dam.api + com.dbeaver.datadam.messaging.api eclipse-plugin - DataDam External DAM API + DataDam Messaging API diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcException.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcException.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcException.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcException.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcExceptionDetails.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcExceptionDetails.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcExceptionDetails.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcExceptionDetails.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java diff --git a/apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java similarity index 100% rename from apis/com.dbeaver.datadam.external-dam.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java diff --git a/apis/pom.xml b/apis/pom.xml index c37b331..481c343 100644 --- a/apis/pom.xml +++ b/apis/pom.xml @@ -20,7 +20,7 @@ com.dbeaver.datadam.dm.api - com.dbeaver.datadam.external-dam.api + com.dbeaver.datadam.messaging.api com.dbeaver.datadam.share.api com.dbeaver.datadam.lm.api com.dbeaver.datadam.sso.api From a35edc679565e97079c10a7e915779a0c352948e Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Mon, 26 Oct 2026 15:22:17 +0400 Subject: [PATCH 23/30] dbeaver/pro#11108 Prefix neutral messaging types with Messaging --- .../MESSAGING.md | 52 +-- .../README.md | 42 +- .../requestreply/DDRequestReplyTransport.java | 67 ++-- .../external/api/rpc/DDGsonRpcCodec.java | 4 +- .../external/api/rpc/DDRpcTransport.java | 85 ++-- ...dler.java => MessagingRequestHandler.java} | 2 +- ...ava => MessagingRequestReplyEndpoint.java} | 113 +++--- ...va => MessagingRequestReplyException.java} | 12 +- ...java => MessagingRequestReplyMessage.java} | 16 +- ...java => MessagingRequestReplyOptions.java} | 6 +- ...va => MessagingRequestReplyTransport.java} | 8 +- ...iption.java => MessagingSubscription.java} | 2 +- .../{RpcCodec.java => MessagingRpcCodec.java} | 2 +- ...ontract.java => MessagingRpcContract.java} | 14 +- ...ndpoint.java => MessagingRpcEndpoint.java} | 185 ++++----- ...eption.java => MessagingRpcException.java} | 24 +- ...java => MessagingRpcExceptionDetails.java} | 8 +- ...cMessage.java => MessagingRpcMessage.java} | 20 +- ...nsport.java => MessagingRpcTransport.java} | 8 +- .../DDRequestReplyTransportTest.java | 106 ++--- .../api/rpc/DDRpcTopicsIntegrationTest.java | 24 +- .../external/api/rpc/DDRpcTransportTest.java | 367 +++++++++--------- ...=> MessagingRequestReplyDeadlineTest.java} | 38 +- ...=> MessagingRequestReplyEndpointTest.java} | 276 ++++++------- ... => MessagingRequestReplyMessageTest.java} | 58 +-- ... => MessagingRequestReplyOptionsTest.java} | 18 +- ...est.java => MessagingRpcContractTest.java} | 26 +- ...va => MessagingRpcEndpointFacadeTest.java} | 104 ++--- ...est.java => MessagingRpcEndpointTest.java} | 243 ++++++------ ... => MessagingRpcExceptionDetailsTest.java} | 42 +- ...st.java => MessagingRpcExceptionTest.java} | 14 +- .../rpc/MessagingRpcMessageTest.java | 110 ++++++ ...port.java => MessagingRpcTestSupport.java} | 52 +-- .../dbeaver/messaging/rpc/RpcMessageTest.java | 107 ----- 34 files changed, 1157 insertions(+), 1098 deletions(-) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/{RequestHandler.java => MessagingRequestHandler.java} (95%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/{RequestReplyEndpoint.java => MessagingRequestReplyEndpoint.java} (67%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/{RequestReplyException.java => MessagingRequestReplyException.java} (80%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/{RequestReplyMessage.java => MessagingRequestReplyMessage.java} (70%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/{RequestReplyOptions.java => MessagingRequestReplyOptions.java} (83%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/{RequestReplyTransport.java => MessagingRequestReplyTransport.java} (76%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/{MessageSubscription.java => MessagingSubscription.java} (92%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/{RpcCodec.java => MessagingRpcCodec.java} (96%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/{RpcContract.java => MessagingRpcContract.java} (94%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/{RpcEndpoint.java => MessagingRpcEndpoint.java} (66%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/{RpcException.java => MessagingRpcException.java} (73%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/{RpcExceptionDetails.java => MessagingRpcExceptionDetails.java} (94%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/{RpcMessage.java => MessagingRpcMessage.java} (70%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/{RpcTransport.java => MessagingRpcTransport.java} (71%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/{RequestReplyDeadlineTest.java => MessagingRequestReplyDeadlineTest.java} (66%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/{RequestReplyEndpointTest.java => MessagingRequestReplyEndpointTest.java} (73%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/{RequestReplyMessageTest.java => MessagingRequestReplyMessageTest.java} (53%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/{RequestReplyOptionsTest.java => MessagingRequestReplyOptionsTest.java} (76%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/{RpcContractTest.java => MessagingRpcContractTest.java} (92%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/{RpcEndpointFacadeTest.java => MessagingRpcEndpointFacadeTest.java} (81%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/{RpcEndpointTest.java => MessagingRpcEndpointTest.java} (77%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/{RpcExceptionDetailsTest.java => MessagingRpcExceptionDetailsTest.java} (68%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/{RpcExceptionTest.java => MessagingRpcExceptionTest.java} (80%) create mode 100644 apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/{RpcTestSupport.java => MessagingRpcTestSupport.java} (72%) delete mode 100644 apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java diff --git a/apis/com.dbeaver.datadam.messaging.api/MESSAGING.md b/apis/com.dbeaver.datadam.messaging.api/MESSAGING.md index 0df9178..4297158 100644 --- a/apis/com.dbeaver.datadam.messaging.api/MESSAGING.md +++ b/apis/com.dbeaver.datadam.messaging.api/MESSAGING.md @@ -10,7 +10,7 @@ production dependency supplies nullability annotations. DataDam adapters and cod Create an endpoint over a transport already scoped to the intended peer and logical request topic: ```java -try (var requests = RequestReplyEndpoint.open(transport)) { +try (var requests = MessagingRequestReplyEndpoint.open(transport)) { var response = requests.request(payload); } ``` @@ -18,7 +18,7 @@ try (var requests = RequestReplyEndpoint.open(transport)) { The receiving peer installs one handler on its own endpoint: ```java -try (var requests = RequestReplyEndpoint.open(transport); +try (var requests = MessagingRequestReplyEndpoint.open(transport); var registration = requests.handle(request -> process(request))) { serveApplication(); } @@ -29,11 +29,11 @@ payload, or null for successful completion without a value. Use a worker or virt on the receiving thread by default. The endpoint owns no executor; the transport adapter chooses an execution boundary when its receiving loop must remain nonblocking. Future objects are internal. -`RequestReplyEndpoint

` leaves the payload representation to the application and transport adapter. For example, +`MessagingRequestReplyEndpoint

` leaves the payload representation to the application and transport adapter. For example, `P` can be a string, an immutable application value, or an encoded JSON object. The core does not copy arbitrary `P` values: payloads must remain immutable while a request is in use, or the adapter must take defensive snapshots. -The default `RequestReplyOptions` use a 30-second deadline, at most 256 outgoing requests, and 64 concurrent incoming +The default `MessagingRequestReplyOptions` use a 30-second deadline, at most 256 outgoing requests, and 64 concurrent incoming handlers. Supply options to `open`, or a deadline to an individual `request(payload, timeout)` call. Deadlines are measured with monotonic time and include local sending. The handler limit has no waiting queue: saturation replies with `OVERLOADED`. Outgoing saturation fails locally without sending. @@ -46,7 +46,7 @@ Pending correlation is installed before sending, including when the adapter deli its send stage completes. Request IDs and reply endpoint IDs are independent UUIDs. A response must match both. Unknown, late, and duplicate responses do not complete another request. -`RequestReplyException.reason()` distinguishes local closure, disconnection, capacity rejection, send failure, timeout, +`MessagingRequestReplyException.reason()` distinguishes local closure, disconnection, capacity rejection, send failure, timeout, and remote errors. `remoteError()` identifies `NO_HANDLER`, `OVERLOADED`, or `HANDLER_FAILED` for remote errors. Raw request/reply handler exception classes, messages, and stack traces are not sent to the peer; the typed RPC layer below adds its own diagnostic error payload. Local send failures retain their causes. @@ -69,7 +69,7 @@ adapter cannot observe peer disconnection, outstanding calls expire through thei ## Transport adapter contract -Implement `RequestReplyTransport

` to integrate a message transport: +Implement `MessagingRequestReplyTransport

` to integrate a message transport: - `subscribe(endpointId, listener)` establishes receiving routes before returning. Requests go to one designated handler endpoint; responses go to the original request's `replyTo` endpoint. A transport may limit an adapter @@ -86,7 +86,7 @@ Implement `RequestReplyTransport

` to integrate a message transport: `RejectedExecutionException`; the core maps rejection to `OVERLOADED`. The core never closes that execution service. Responses and disconnects do not use this dispatch hook. A serialized receiver must dispatch requests elsewhere if handlers can wait synchronously for replies arriving through that same receiver. -- Closing the returned `MessageSubscription` removes receiving routes and releases adapter-owned resources. It must +- Closing the returned `MessagingSubscription` removes receiving routes and releases adapter-owned resources. It must leave a caller-owned physical connection available to other consumers. - The adapter owns wire validation, payload snapshots, message size limits, and bounded transport send work. It must not introduce automatic retries or an offline business-message queue. @@ -113,7 +113,7 @@ The connection owner creates one reusable endpoint over a topic-scoped transport its own codec when registering an implementation: ```java -try (var rpc = RpcEndpoint.open(transports); +try (var rpc = MessagingRpcEndpoint.open(transports); var registration = rpc.register("documents", Documents.class, implementation, codec)) { serveApplication(); } @@ -122,14 +122,14 @@ try (var rpc = RpcEndpoint.open(transports); The caller creates its own endpoint over the corresponding transport and obtains a proxy: ```java -try (var rpc = RpcEndpoint.open(transports)) { +try (var rpc = MessagingRpcEndpoint.open(transports)) { Documents documents = rpc.proxy("documents", Documents.class, codec); Document document = documents.load(request); documents.save(saveRequest); } ``` -An owner can provide a default codec with `RpcEndpoint.open(transports, defaultCodec)`. Its consumers can then use +An owner can provide a default codec with `MessagingRpcEndpoint.open(transports, defaultCodec)`. Its consumers can then use `rpc.proxy("documents", Documents.class)` and `rpc.register("documents", Documents.class, implementation)`. The overloads with an explicit codec override that fallback only for the new proxy or registration. The core does not construct a serialization framework. Without a configured default, no-codec overloads fail before opening routes. @@ -145,9 +145,9 @@ Opening a topic can establish subscriptions synchronously; use a worker thread w Proxy methods block the calling thread. They may run in concurrent HTTP request handlers or worker/virtual threads, but not on an event loop or serialized receiver needed to deliver their replies. -`RpcEndpoint` has no payload type parameter. Its transport factory supplies topic-scoped -`RequestReplyTransport>` adapters. Each proxy and registration supplies a codec compatible with that -payload representation. Business code receives an ordinary `RpcEndpoint` and uses typed interfaces. The transport +`MessagingRpcEndpoint` has no payload type parameter. Its transport factory supplies topic-scoped +`MessagingRequestReplyTransport>` adapters. Each proxy and registration supplies a codec compatible with that +payload representation. Business code receives an ordinary `MessagingRpcEndpoint` and uses typed interfaces. The transport adapter encodes the neutral protocol envelope; the local codec encodes application arguments and results. A TCP, WebSocket, long-polling, or in-memory transport can implement this interface without changing RPC code. No WebSocket types or JSON library are used here. @@ -159,15 +159,15 @@ and proxy creation are runtime operations and do not require Spring or a separat ```java public final class DocumentReceiver { - private final RpcEndpoint rpc; - private final RpcCodec documentCodec; + private final MessagingRpcEndpoint rpc; + private final MessagingRpcCodec documentCodec; - public DocumentReceiver(RpcEndpoint rpc, RpcCodec documentCodec) { + public DocumentReceiver(MessagingRpcEndpoint rpc, MessagingRpcCodec documentCodec) { this.rpc = rpc; this.documentCodec = documentCodec; } - public MessageSubscription enable(Documents implementation) { + public MessagingSubscription enable(Documents implementation) { return rpc.register("documents", Documents.class, implementation, documentCodec); } } @@ -177,10 +177,10 @@ The opposite peer uses its own endpoint in a separate caller: ```java public final class DocumentCaller { - private final RpcEndpoint rpc; - private final RpcCodec documentCodec; + private final MessagingRpcEndpoint rpc; + private final MessagingRpcCodec documentCodec; - public DocumentCaller(RpcEndpoint rpc, RpcCodec documentCodec) { + public DocumentCaller(MessagingRpcEndpoint rpc, MessagingRpcCodec documentCodec) { this.rpc = rpc; this.documentCodec = documentCodec; } @@ -192,7 +192,7 @@ public final class DocumentCaller { } ``` -Keep the receiving `MessageSubscription` while the implementation is available, then close it to unregister. A proxy +Keep the receiving `MessagingSubscription` while the implementation is available, then close it to unregister. A proxy is an ordinary business interface, not a registration handle, and requires no separate close. Application code sharing an endpoint closes its own registrations; the connection owner closes the endpoint when its scope ends. Owners can use `isClosed()` to reject new use once closure starts, and `isCloseComplete()` before releasing a cached endpoint's @@ -210,7 +210,7 @@ cleanup throws. A repeated `close()` does not wait for another thread's ongoing type variables, generic methods, default methods, varargs, and `Future`/`CompletionStage` returns are rejected during proxy construction or registration. Generic-parent substitutions are not inferred. Inherited operations with ambiguous generic argument or result types must be explicitly redeclared in the public contract. -- `RpcCodec.encode(value, type)` and `decode(payload, type)` are selected per proxy or implementation registration. Implementations must handle +- `MessagingRpcCodec.encode(value, type)` and `decode(payload, type)` are selected per proxy or implementation registration. Implementations must handle nulls and concurrent calls. Serialization remains application-owned; the RPC core does not construct application types or select a serialization framework. Payload immutability and snapshot requirements are the same as request/reply. Peers must use compatible contracts and codecs; no automatic contract negotiation or class transfer is performed. @@ -221,17 +221,17 @@ cleanup throws. A repeated `close()` does not wait for another thread's ongoing ### Failure and ownership `void` still waits for remote handler completion and a successful reply. It is not fire-and-forget, and its response -does not invoke the application codec. `RpcException` is unchecked, so a business interface does not need a library +does not invoke the application codec. `MessagingRpcException` is unchecked, so a business interface does not need a library exception declaration. Its reason distinguishes timeout, interruption, disconnection, overload, closure, send failure, codec failure, invalid response, and remote failure. Caller interruption restores the interrupt flag. An absent logical service returns `UNKNOWN_SERVICE`; an absent operation returns `UNKNOWN_OPERATION`. Invalid arguments, handler failures, and remote codec failures retain their fixed error codes. Handler and remote codec exceptions also -carry immutable `RpcExceptionDetails`: the exception class name (`type`), nullable original `message`, formatted +carry immutable `MessagingRpcExceptionDetails`: the exception class name (`type`), nullable original `message`, formatted `stackTrace`, and a `truncated` flag. The receiving side formats the trace where the failure occurs, including causes, suppressed exceptions, and circular-reference markers. Reflection's `InvocationTargetException` is unwrapped before capture. -The caller receives `RpcException` with `reason() == REMOTE_ERROR`, the existing `remoteError()` code, and optional +The caller receives `MessagingRpcException` with `reason() == REMOTE_ERROR`, the existing `remoteError()` code, and optional `remoteException()` details. Its message includes the remote type and message. Its cause is a local diagnostic wrapper with the remote text and no invented local frames, so ordinary exception logging includes both the local call stack and the remote trace. Neither the original `Throwable` nor its object graph crosses the transport. The caller never loads or @@ -247,7 +247,7 @@ Declared application exceptions are not reconstructed on the caller. A peer with detected through transport failure or timeout; an error reply lost in transport cannot expose its remote diagnostics. Local failures retain their original causes. -`RpcEndpoint.open(transports, options)` accepts the existing `RequestReplyOptions` for default deadlines and shared +`MessagingRpcEndpoint.open(transports, options)` accepts the existing `MessagingRequestReplyOptions` for default deadlines and shared capacity. Without explicit options, the default timeout is 30 seconds. Each proxy can override the request timeout with a positive `Duration` representable in nanoseconds: diff --git a/apis/com.dbeaver.datadam.messaging.api/README.md b/apis/com.dbeaver.datadam.messaging.api/README.md index 9a818b0..49f8680 100644 --- a/apis/com.dbeaver.datadam.messaging.api/README.md +++ b/apis/com.dbeaver.datadam.messaging.api/README.md @@ -155,15 +155,15 @@ subsequent callbacks run on the notifying thread. Existing `addConnectionListene ## Request/reply -The `com.dbeaver.messaging.requestreply` package supplies `RequestReplyEndpoint

`. It knows nothing about DataDam +The `com.dbeaver.messaging.requestreply` package supplies `MessagingRequestReplyEndpoint

`. It knows nothing about DataDam accounts, deployment IDs, GraphQL, or JSON. `DDRequestReplyTransport` adapts an existing scoped DataDam channel to -`RequestReplyEndpoint` without changing the channel's asynchronous message API. +`MessagingRequestReplyEndpoint` without changing the channel's asynchronous message API. On Desktop, or inside External DAM over an existing physical channel: ```java var transport = DDRequestReplyTransport.forChannel(channel, "documents"); -try (var requests = RequestReplyEndpoint.open(transport)) { +try (var requests = MessagingRequestReplyEndpoint.open(transport)) { var response = requests.request(payload); } ``` @@ -176,7 +176,7 @@ An Account, Storage, or other backend selects a physical connection once: ```java var transport = subscriptions.forConnection(accountId, connectionId).requestReply("documents"); -try (var requests = RequestReplyEndpoint.open(transport)) { +try (var requests = MessagingRequestReplyEndpoint.open(transport)) { var response = requests.request(payload); } ``` @@ -218,20 +218,20 @@ off a serialized receiver when they can make synchronous calls whose replies nee ## Typed RPC `com.dbeaver.messaging.rpc` adds typed Java proxies and registered implementations without depending on DataDam or -WebSocket. `DDRpcTransport` adapts the existing request/reply transport to the neutral `RpcMessage` envelope. -It owns no execution service or application serializer; the developer selects serialization through `RpcCodec`. +WebSocket. `DDRpcTransport` adapts the existing request/reply transport to the neutral `MessagingRpcMessage` envelope. +It owns no execution service or application serializer; the developer selects serialization through `MessagingRpcCodec`. For example, a plugin can supply its own Gson instance and adapters for its proxies and implementations: ```java Gson gson = new Gson(); -RpcCodec codec = new DDGsonRpcCodec(gson); +MessagingRpcCodec codec = new DDGsonRpcCodec(gson); ``` The Desktop side installs an implementation and can independently obtain proxies for backend services: ```java -try (var rpc = RpcEndpoint.open(topic -> DDRpcTransport.forChannel(channel, topic, requestExecutor)); +try (var rpc = MessagingRpcEndpoint.open(topic -> DDRpcTransport.forChannel(channel, topic, requestExecutor)); var registration = rpc.register("desktop.projects", DesktopProjects.class, implementation, codec)) { ServerDocuments documents = rpc.proxy("server.documents", ServerDocuments.class, codec); serveApplication(documents); @@ -247,11 +247,11 @@ The backend selects its physical account/connection scope once and uses the same ```java var scope = subscriptions.forConnection(accountId, connectionId); -try (var rpc = RpcEndpoint.open(topic -> { +try (var rpc = MessagingRpcEndpoint.open(topic -> { try { return new DDRpcTransport(scope.requestReply(topic)); } catch (DDExternalDamException e) { - throw new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, e); + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e); } }); var registration = rpc.register("server.documents", ServerDocuments.class, implementation, codec)) { @@ -270,7 +270,7 @@ the same topic are not service discovery. Replica routing and exact callback-pro `DDGsonRpcCodec` retains the supplied `Gson` instance, including application adapters and naming policies, and preserves the declared generic `Type` passed by RPC. Encoded JSON null is represented as a nullable application payload. The neutral messaging packages remain independent of Gson, and each proxy or registration can supply another compatible codec. -The resulting `RpcEndpoint` is not parameterized by JSON or by any other payload representation. +The resulting `MessagingRpcEndpoint` is not parameterized by JSON or by any other payload representation. ### Ready endpoints in Account, Storage, and Desktop @@ -281,7 +281,7 @@ That configuration supplies the callback receiver, subscriptions, and a shared ```java var connections = rpcConnections.getConnections(accountId); var selected = chooseConnection(connections); -RpcEndpoint rpc = rpcConnections.forConnection(selected.connectionId()); +MessagingRpcEndpoint rpc = rpcConnections.forConnection(selected.connectionId()); ``` `rpcConnections` is the `DDExternalDamRpc` Spring bean. `getConnections(accountId)` returns an immutable current @@ -307,7 +307,7 @@ Desktop obtains the corresponding endpoint from its existing connection service: ```java var connections = DDDesktopConnectionService.getInstance(); -RpcEndpoint rpc = connections.getRpc(); +MessagingRpcEndpoint rpc = connections.getRpc(); ``` The Desktop connection must first become ready. Endpoint access never starts login or waits for a new connection. @@ -317,15 +317,15 @@ Pass the obtained endpoint to ordinary Java objects. The receiving object regist ```java public final class GreetingReceiver { - private final RpcEndpoint rpc; - private final RpcCodec codec; + private final MessagingRpcEndpoint rpc; + private final MessagingRpcCodec codec; - public GreetingReceiver(RpcEndpoint rpc, RpcCodec codec) { + public GreetingReceiver(MessagingRpcEndpoint rpc, MessagingRpcCodec codec) { this.rpc = rpc; this.codec = codec; } - public MessageSubscription enable(Greeting implementation) { + public MessagingSubscription enable(Greeting implementation) { return rpc.register("greeting", Greeting.class, implementation, codec); } } @@ -335,10 +335,10 @@ The other peer keeps its calling code separate: ```java public final class GreetingCaller { - private final RpcEndpoint rpc; - private final RpcCodec codec; + private final MessagingRpcEndpoint rpc; + private final MessagingRpcCodec codec; - public GreetingCaller(RpcEndpoint rpc, RpcCodec codec) { + public GreetingCaller(MessagingRpcEndpoint rpc, MessagingRpcCodec codec) { this.rpc = rpc; this.codec = codec; } @@ -389,7 +389,7 @@ Cancellation of a returned send future does not free its slot until the underlyi Interfaces declare ordinary one-argument methods returning values or `void`; applications do not implement transport listeners. A `void` call waits for handler completion. Local serialization errors and transport failures become unchecked -`RpcException`. Remote handler and codec failures preserve their codes and expose `remoteException()` with bounded +`MessagingRpcException`. Remote handler and codec failures preserve their codes and expose `remoteException()` with bounded type, message, and stack-trace text; ordinary exception logging includes the remote trace through a local diagnostic cause. Limits are 256 UTF-16 code units for the type, 1,024 for the message, and 8,192 for the trace, with `truncated` indicating incomplete diagnostics. Errors without exception details have null `remoteException()`. diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java index f662a10..7c0b44b 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java @@ -23,10 +23,10 @@ import com.dbeaver.datadam.external.api.transport.DDMessageChannel; import com.dbeaver.datadam.external.api.transport.DDMessageListener; import com.dbeaver.datadam.external.api.transport.DDSubscription; -import com.dbeaver.messaging.requestreply.MessageSubscription; -import com.dbeaver.messaging.requestreply.RequestReplyException; -import com.dbeaver.messaging.requestreply.RequestReplyMessage; -import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingSubscription; import com.google.gson.JsonElement; import com.google.gson.JsonNull; import com.google.gson.JsonObject; @@ -46,7 +46,7 @@ import java.util.concurrent.CompletionStage; import java.util.concurrent.Semaphore; -public final class DDRequestReplyTransport implements RequestReplyTransport, AutoCloseable { +public final class DDRequestReplyTransport implements MessagingRequestReplyTransport, AutoCloseable { private static final String MESSAGE_TYPE = "datadam.requestreply.v1"; private static final String FIELD_REQUEST_ID = "requestId"; private static final String FIELD_REPLY_TO = "replyTo"; @@ -109,8 +109,8 @@ public static DDRequestReplyTransport forChannel(@NotNull DDMessageChannel chann @Override @NotNull - public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener listener) - throws RequestReplyException { + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener listener) + throws MessagingRequestReplyException { Registration opened; synchronized (lock) { if (claimed) { @@ -136,47 +136,48 @@ public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener opened.opening = false; } return opened; - } catch (DDExternalDamException | RequestReplyException | RuntimeException e) { + } catch (DDExternalDamException | MessagingRequestReplyException | RuntimeException e) { try { opened.close(); } catch (RuntimeException closeFailure) { e.addSuppressed(closeFailure); } - if (e instanceof RequestReplyException failure) { + if (e instanceof MessagingRequestReplyException failure) { throw failure; } - throw new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, e); + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e); } } @Override @NotNull - public CompletionStage send(@NotNull RequestReplyMessage message) { + public CompletionStage send(@NotNull MessagingRequestReplyMessage message) { synchronized (lock) { if (registration == null || registration.closed) { - return CompletableFuture.failedFuture(new RequestReplyException(RequestReplyException.Reason.CLOSED)); + return CompletableFuture.failedFuture(new MessagingRequestReplyException(MessagingRequestReplyException.Reason.CLOSED)); } if (channel != null && !channel.getStatus().isReady()) { - return CompletableFuture.failedFuture(new RequestReplyException(RequestReplyException.Reason.DISCONNECTED)); + return CompletableFuture.failedFuture( + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED)); } - if (message.type() == RequestReplyMessage.Type.REQUEST && !registration.endpointId.equals(message.replyTo())) { + if (message.type() == MessagingRequestReplyMessage.Type.REQUEST && !registration.endpointId.equals(message.replyTo())) { return CompletableFuture.failedFuture(new IllegalArgumentException("Request origin does not match its endpoint")); } } if (!sendBudget.tryAcquire()) { - return CompletableFuture.failedFuture(new RequestReplyException(RequestReplyException.Reason.OVERLOADED)); + return CompletableFuture.failedFuture(new MessagingRequestReplyException(MessagingRequestReplyException.Reason.OVERLOADED)); } try { JsonObject envelope = new JsonObject(); envelope.addProperty(FIELD_REQUEST_ID, message.requestId().toString()); envelope.addProperty(FIELD_REPLY_TO, message.replyTo().toString()); envelope.addProperty(FIELD_TYPE, message.type().name()); - if (message.type() == RequestReplyMessage.Type.ERROR) { + if (message.type() == MessagingRequestReplyMessage.Type.ERROR) { envelope.addProperty(FIELD_ERROR, Objects.requireNonNull(message.error()).name()); } else { envelope.add(FIELD_DATA, message.payload() == null ? JsonNull.INSTANCE : message.payload()); } - String target = message.type() == RequestReplyMessage.Type.REQUEST ? topic : replyPrefix + message.replyTo(); + String target = message.type() == MessagingRequestReplyMessage.Type.REQUEST ? topic : replyPrefix + message.replyTo(); CompletableFuture result = new CompletableFuture<>(); Objects.requireNonNull(sender.send(new DDMessage(target, MESSAGE_TYPE, envelope))).whenComplete((ignored, error) -> { sendBudget.release(); @@ -216,7 +217,7 @@ private static String replyPrefix(@NotNull String topic) { } @Nullable - private static RequestReplyMessage decode(@NotNull DDMessage message) { + private static MessagingRequestReplyMessage decode(@NotNull DDMessage message) { if (!MESSAGE_TYPE.equals(message.type())) { return null; } @@ -224,23 +225,23 @@ private static RequestReplyMessage decode(@NotNull DDMessage message JsonObject envelope = message.payload(); UUID requestId = uuid(envelope, FIELD_REQUEST_ID); UUID replyTo = uuid(envelope, FIELD_REPLY_TO); - RequestReplyMessage.Type type = RequestReplyMessage.Type.valueOf(string(envelope, FIELD_TYPE)); + MessagingRequestReplyMessage.Type type = MessagingRequestReplyMessage.Type.valueOf(string(envelope, FIELD_TYPE)); JsonElement data = envelope.get(FIELD_DATA); - RequestReplyMessage.Error error = null; + MessagingRequestReplyMessage.Error error = null; JsonObject payload = null; - if (type == RequestReplyMessage.Type.ERROR) { + if (type == MessagingRequestReplyMessage.Type.ERROR) { if (data != null && !data.isJsonNull()) { return null; } - error = RequestReplyMessage.Error.valueOf(string(envelope, FIELD_ERROR)); + error = MessagingRequestReplyMessage.Error.valueOf(string(envelope, FIELD_ERROR)); } else { if (envelope.has(FIELD_ERROR) || data == null || !data.isJsonObject() && !data.isJsonNull() - || type == RequestReplyMessage.Type.REQUEST && data.isJsonNull()) { + || type == MessagingRequestReplyMessage.Type.REQUEST && data.isJsonNull()) { return null; } payload = data.isJsonNull() ? null : data.getAsJsonObject(); } - return new RequestReplyMessage<>(requestId, replyTo, type, payload, error); + return new MessagingRequestReplyMessage<>(requestId, replyTo, type, payload, error); } catch (IllegalArgumentException e) { return null; } @@ -265,7 +266,7 @@ private static UUID uuid(@NotNull JsonObject envelope, @NotNull String field) { return id; } - private final class Registration implements MessageSubscription { + private final class Registration implements MessagingSubscription { private final UUID endpointId; private final String replyTopic; private final Listener listener; @@ -294,13 +295,13 @@ private void add(@NotNull DDSubscription subscription) { subscription.close(); } - private void requireReady() throws RequestReplyException { + private void requireReady() throws MessagingRequestReplyException { synchronized (lock) { if (closed) { - throw new RequestReplyException(RequestReplyException.Reason.CLOSED); + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.CLOSED); } if (openingDisconnected || channel != null && (status == null || !status.isReady())) { - throw new RequestReplyException(RequestReplyException.Reason.DISCONNECTED); + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED); } } } @@ -331,9 +332,9 @@ private boolean updateState(@NotNull DDChannelStatus next) { } private void receive(@NotNull DDConnectionInfo connection, @NotNull DDMessage message, boolean requestTopic) { - RequestReplyMessage frame = decode(message); - if (frame == null || (requestTopic ? frame.type() != RequestReplyMessage.Type.REQUEST - : frame.type() == RequestReplyMessage.Type.REQUEST || !endpointId.equals(frame.replyTo()))) { + MessagingRequestReplyMessage frame = decode(message); + if (frame == null || (requestTopic ? frame.type() != MessagingRequestReplyMessage.Type.REQUEST + : frame.type() == MessagingRequestReplyMessage.Type.REQUEST || !endpointId.equals(frame.replyTo()))) { return; } boolean drain = false; @@ -366,7 +367,7 @@ private void receive(@NotNull DDConnectionInfo connection, @NotNull DDMessage me } } - private boolean enqueue(@Nullable RequestReplyMessage message) { + private boolean enqueue(@Nullable MessagingRequestReplyMessage message) { if (message == null) { if (!deliveries.isEmpty() && deliveries.getLast().message() == null) { return false; @@ -442,6 +443,6 @@ public void close() { } } - private record Delivery(@Nullable RequestReplyMessage message) { + private record Delivery(@Nullable MessagingRequestReplyMessage message) { } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java index 75b65b1..e5464bf 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java @@ -16,7 +16,7 @@ */ package com.dbeaver.datadam.external.api.rpc; -import com.dbeaver.messaging.rpc.RpcCodec; +import com.dbeaver.messaging.rpc.MessagingRpcCodec; import com.google.gson.Gson; import com.google.gson.JsonElement; import org.jkiss.code.NotNull; @@ -25,7 +25,7 @@ import java.lang.reflect.Type; import java.util.Objects; -public final class DDGsonRpcCodec implements RpcCodec { +public final class DDGsonRpcCodec implements MessagingRpcCodec { private final Gson gson; public DDGsonRpcCodec(@NotNull Gson gson) { diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java index 10e664f..ee75f67 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java @@ -18,13 +18,13 @@ import com.dbeaver.datadam.external.api.requestreply.DDRequestReplyTransport; import com.dbeaver.datadam.external.api.transport.DDMessageChannel; -import com.dbeaver.messaging.requestreply.MessageSubscription; -import com.dbeaver.messaging.requestreply.RequestReplyException; -import com.dbeaver.messaging.requestreply.RequestReplyMessage; -import com.dbeaver.messaging.requestreply.RequestReplyTransport; -import com.dbeaver.messaging.rpc.RpcExceptionDetails; -import com.dbeaver.messaging.rpc.RpcMessage; -import com.dbeaver.messaging.rpc.RpcTransport; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingSubscription; +import com.dbeaver.messaging.rpc.MessagingRpcExceptionDetails; +import com.dbeaver.messaging.rpc.MessagingRpcMessage; +import com.dbeaver.messaging.rpc.MessagingRpcTransport; import com.google.gson.JsonElement; import com.google.gson.JsonNull; import com.google.gson.JsonObject; @@ -38,7 +38,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.Semaphore; -public final class DDRpcTransport implements RequestReplyTransport> { +public final class DDRpcTransport implements MessagingRequestReplyTransport> { private static final String PROTOCOL = "dbeaver.rpc.v2"; private static final String FIELD_PROTOCOL = "protocol"; private static final String FIELD_TYPE = "type"; @@ -51,21 +51,21 @@ public final class DDRpcTransport implements RequestReplyTransport delegate; + private final MessagingRequestReplyTransport delegate; @Nullable private final Executor requestExecutor; - public DDRpcTransport(@NotNull RequestReplyTransport delegate) { + public DDRpcTransport(@NotNull MessagingRequestReplyTransport delegate) { this(delegate, null); } - public DDRpcTransport(@NotNull RequestReplyTransport delegate, @Nullable Executor requestExecutor) { + public DDRpcTransport(@NotNull MessagingRequestReplyTransport delegate, @Nullable Executor requestExecutor) { this.delegate = Objects.requireNonNull(delegate); this.requestExecutor = requestExecutor; } @NotNull - public static RpcTransport forChannel(@NotNull DDMessageChannel channel) { + public static MessagingRpcTransport forChannel(@NotNull DDMessageChannel channel) { Objects.requireNonNull(channel); return topic -> forChannel(channel, topic); } @@ -95,14 +95,14 @@ public void executeRequest(@NotNull Runnable request) { @Override @NotNull - public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) - throws RequestReplyException { + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) + throws MessagingRequestReplyException { Objects.requireNonNull(endpointId); Objects.requireNonNull(listener); return delegate.subscribe(endpointId, new Listener<>() { @Override - public void onMessage(@NotNull RequestReplyMessage message) { - RequestReplyMessage> decoded = decode(message); + public void onMessage(@NotNull MessagingRequestReplyMessage message) { + MessagingRequestReplyMessage> decoded = decode(message); if (decoded != null) { listener.onMessage(decoded); } @@ -117,16 +117,17 @@ public void onDisconnect() { @Override @NotNull - public CompletionStage send(@NotNull RequestReplyMessage> message) { + public CompletionStage send(@NotNull MessagingRequestReplyMessage> message) { if (!sendBudget.tryAcquire()) { - return CompletableFuture.failedFuture(new RequestReplyException(RequestReplyException.Reason.OVERLOADED)); + return CompletableFuture.failedFuture(new MessagingRequestReplyException(MessagingRequestReplyException.Reason.OVERLOADED)); } try { - RpcMessage payload = message.payload(); + MessagingRpcMessage payload = message.payload(); if (payload != null) { requireType(message.type(), payload.type()); } - RequestReplyMessage frame = new RequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), + MessagingRequestReplyMessage frame = new MessagingRequestReplyMessage<>( + message.requestId(), message.replyTo(), message.type(), payload == null ? null : encode(payload), message.error()); CompletableFuture result = new CompletableFuture<>(); Objects.requireNonNull(delegate.send(frame)).whenComplete((ignored, error) -> { @@ -145,15 +146,15 @@ public CompletionStage send(@NotNull RequestReplyMessage message) { + private static JsonObject encode(@NotNull MessagingRpcMessage message) { JsonObject envelope = new JsonObject(); envelope.addProperty(FIELD_PROTOCOL, PROTOCOL); envelope.addProperty(FIELD_TYPE, message.type().name()); - if (message.type() == RpcMessage.Type.ERROR) { + if (message.type() == MessagingRpcMessage.Type.ERROR) { envelope.addProperty(FIELD_ERROR, Objects.requireNonNull(message.error()).name()); envelope.add(FIELD_EXCEPTION, message.exception() == null ? JsonNull.INSTANCE : encodeException(message.exception())); } else { - if (message.type() == RpcMessage.Type.REQUEST) { + if (message.type() == MessagingRpcMessage.Type.REQUEST) { envelope.addProperty(FIELD_OPERATION, message.operation()); } envelope.add(FIELD_DATA, message.payload() == null ? JsonNull.INSTANCE : message.payload().deepCopy()); @@ -162,7 +163,7 @@ private static JsonObject encode(@NotNull RpcMessage message) { } @NotNull - private static JsonObject encodeException(@NotNull RpcExceptionDetails details) { + private static JsonObject encodeException(@NotNull MessagingRpcExceptionDetails details) { JsonObject exception = new JsonObject(); exception.addProperty(FIELD_TYPE, details.type()); exception.addProperty(FIELD_MESSAGE, details.message()); @@ -172,43 +173,45 @@ private static JsonObject encodeException(@NotNull RpcExceptionDetails details) } @Nullable - private static RequestReplyMessage> decode(@NotNull RequestReplyMessage message) { - if (message.type() == RequestReplyMessage.Type.ERROR || message.payload() == null) { - return new RequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), null, message.error()); + private static MessagingRequestReplyMessage> decode( + @NotNull MessagingRequestReplyMessage message + ) { + if (message.type() == MessagingRequestReplyMessage.Type.ERROR || message.payload() == null) { + return new MessagingRequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), null, message.error()); } try { JsonObject envelope = message.payload(); if (!PROTOCOL.equals(string(envelope, FIELD_PROTOCOL))) { return null; } - RpcMessage.Type type = RpcMessage.Type.valueOf(string(envelope, FIELD_TYPE)); + MessagingRpcMessage.Type type = MessagingRpcMessage.Type.valueOf(string(envelope, FIELD_TYPE)); requireType(message.type(), type); - RpcMessage payload; - if (type == RpcMessage.Type.ERROR) { + MessagingRpcMessage payload; + if (type == MessagingRpcMessage.Type.ERROR) { JsonElement exception = envelope.get(FIELD_EXCEPTION); if (envelope.size() != 4 || exception == null) { return null; } - payload = RpcMessage.failure(RpcMessage.Error.valueOf(string(envelope, FIELD_ERROR)), + payload = MessagingRpcMessage.failure(MessagingRpcMessage.Error.valueOf(string(envelope, FIELD_ERROR)), decodeException(exception)); } else { JsonElement data = envelope.get(FIELD_DATA); - if (data == null || envelope.size() != (type == RpcMessage.Type.REQUEST ? 4 : 3)) { + if (data == null || envelope.size() != (type == MessagingRpcMessage.Type.REQUEST ? 4 : 3)) { return null; } JsonElement snapshot = data.isJsonNull() ? null : data.deepCopy(); - payload = type == RpcMessage.Type.REQUEST - ? RpcMessage.request(string(envelope, FIELD_OPERATION), snapshot) - : RpcMessage.response(snapshot); + payload = type == MessagingRpcMessage.Type.REQUEST + ? MessagingRpcMessage.request(string(envelope, FIELD_OPERATION), snapshot) + : MessagingRpcMessage.response(snapshot); } - return new RequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), payload, message.error()); + return new MessagingRequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), payload, message.error()); } catch (IllegalArgumentException e) { return null; } } @Nullable - private static RpcExceptionDetails decodeException(@NotNull JsonElement value) { + private static MessagingRpcExceptionDetails decodeException(@NotNull JsonElement value) { if (value.isJsonNull()) { return null; } @@ -222,14 +225,14 @@ private static RpcExceptionDetails decodeException(@NotNull JsonElement value) { || !truncated.getAsJsonPrimitive().isBoolean()) { throw new IllegalArgumentException("Invalid RPC exception details"); } - return new RpcExceptionDetails(string(exception, FIELD_TYPE), + return new MessagingRpcExceptionDetails(string(exception, FIELD_TYPE), message.isJsonNull() ? null : string(exception, FIELD_MESSAGE), string(exception, FIELD_STACK_TRACE), truncated.getAsBoolean()); } - private static void requireType(@NotNull RequestReplyMessage.Type outer, @NotNull RpcMessage.Type inner) { - if (outer == RequestReplyMessage.Type.REQUEST ? inner != RpcMessage.Type.REQUEST - : outer != RequestReplyMessage.Type.RESPONSE || inner == RpcMessage.Type.REQUEST) { + private static void requireType(@NotNull MessagingRequestReplyMessage.Type outer, @NotNull MessagingRpcMessage.Type inner) { + if (outer == MessagingRequestReplyMessage.Type.REQUEST ? inner != MessagingRpcMessage.Type.REQUEST + : outer != MessagingRequestReplyMessage.Type.RESPONSE || inner == MessagingRpcMessage.Type.REQUEST) { throw new IllegalArgumentException("RPC message type does not match its request/reply frame"); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestHandler.java similarity index 95% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestHandler.java index eb12162..e69099e 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestHandler.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestHandler.java @@ -21,7 +21,7 @@ /** Handles incoming requests and returns response payloads. */ @FunctionalInterface -public interface RequestHandler

{ +public interface MessagingRequestHandler

{ @Nullable P handle(@NotNull P request) throws Exception; } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpoint.java similarity index 67% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpoint.java index 2aabe44..b25fe65 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyEndpoint.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpoint.java @@ -36,32 +36,32 @@ import java.util.logging.Level; import java.util.logging.Logger; -public final class RequestReplyEndpoint

implements AutoCloseable { - private static final Logger log = Logger.getLogger(RequestReplyEndpoint.class.getName()); +public final class MessagingRequestReplyEndpoint

implements AutoCloseable { + private static final Logger log = Logger.getLogger(MessagingRequestReplyEndpoint.class.getName()); private final Object lock = new Object(); private final UUID endpointId = UUID.randomUUID(); - private final RequestReplyTransport

transport; - private final RequestReplyOptions options; + private final MessagingRequestReplyTransport

transport; + private final MessagingRequestReplyOptions options; private final LongSupplier nanoTime; private final Map pending = new HashMap<>(); private final Map incoming = new HashMap<>(); - private final MessageSubscription subscription; + private final MessagingSubscription subscription; @Nullable private HandlerRegistration handler; private boolean closed; private long generation; - RequestReplyEndpoint( - @NotNull RequestReplyTransport

transport, - @NotNull RequestReplyOptions options, + MessagingRequestReplyEndpoint( + @NotNull MessagingRequestReplyTransport

transport, + @NotNull MessagingRequestReplyOptions options, @NotNull LongSupplier nanoTime - ) throws RequestReplyException { + ) throws MessagingRequestReplyException { this.transport = Objects.requireNonNull(transport); this.options = Objects.requireNonNull(options); this.nanoTime = Objects.requireNonNull(nanoTime); - this.subscription = Objects.requireNonNull(transport.subscribe(endpointId, new RequestReplyTransport.Listener<>() { + this.subscription = Objects.requireNonNull(transport.subscribe(endpointId, new MessagingRequestReplyTransport.Listener<>() { @Override - public void onMessage(@NotNull RequestReplyMessage

message) { + public void onMessage(@NotNull MessagingRequestReplyMessage

message) { receive(message); } @@ -73,27 +73,29 @@ public void onDisconnect() { } @NotNull - public static

RequestReplyEndpoint

open(@NotNull RequestReplyTransport

transport) throws RequestReplyException { - return open(transport, RequestReplyOptions.DEFAULT); + public static

MessagingRequestReplyEndpoint

open( + @NotNull MessagingRequestReplyTransport

transport + ) throws MessagingRequestReplyException { + return open(transport, MessagingRequestReplyOptions.DEFAULT); } @NotNull - public static

RequestReplyEndpoint

open( - @NotNull RequestReplyTransport

transport, - @NotNull RequestReplyOptions options - ) throws RequestReplyException { - return new RequestReplyEndpoint<>(transport, options, System::nanoTime); + public static

MessagingRequestReplyEndpoint

open( + @NotNull MessagingRequestReplyTransport

transport, + @NotNull MessagingRequestReplyOptions options + ) throws MessagingRequestReplyException { + return new MessagingRequestReplyEndpoint<>(transport, options, System::nanoTime); } @Nullable - public P request(@NotNull P payload) throws RequestReplyException, InterruptedException { + public P request(@NotNull P payload) throws MessagingRequestReplyException, InterruptedException { return request(payload, options.timeout()); } @Nullable - public P request(@NotNull P payload, @NotNull Duration timeout) throws RequestReplyException, InterruptedException { + public P request(@NotNull P payload, @NotNull Duration timeout) throws MessagingRequestReplyException, InterruptedException { Objects.requireNonNull(payload); - long nanos = RequestReplyOptions.requireTimeout(timeout); + long nanos = MessagingRequestReplyOptions.requireTimeout(timeout); if (Thread.interrupted()) { throw new InterruptedException(); } @@ -102,30 +104,30 @@ public P request(@NotNull P payload, @NotNull Duration timeout) throws RequestRe Pending call = new Pending(started, nanos); synchronized (lock) { if (closed) { - throw new RequestReplyException(RequestReplyException.Reason.CLOSED); + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.CLOSED); } if (pending.size() >= options.maxPendingRequests()) { - throw new RequestReplyException(RequestReplyException.Reason.OVERLOADED); + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.OVERLOADED); } pending.put(requestId, call); } try { if (call.remaining() <= 0) { - fail(requestId, call, new RequestReplyException(RequestReplyException.Reason.TIMEOUT)); + fail(requestId, call, new MessagingRequestReplyException(MessagingRequestReplyException.Reason.TIMEOUT)); } else { sendRequest(requestId, payload, call); } try { return call.result.get(Math.max(0, call.remaining()), TimeUnit.NANOSECONDS); } catch (TimeoutException e) { - fail(requestId, call, new RequestReplyException(RequestReplyException.Reason.TIMEOUT)); + fail(requestId, call, new MessagingRequestReplyException(MessagingRequestReplyException.Reason.TIMEOUT)); return call.result.get(); } } catch (ExecutionException e) { - if (e.getCause() instanceof RequestReplyException failure) { + if (e.getCause() instanceof MessagingRequestReplyException failure) { throw failure; } - throw new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, e.getCause()); + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e.getCause()); } finally { synchronized (lock) { pending.remove(requestId, call); @@ -134,7 +136,7 @@ public P request(@NotNull P payload, @NotNull Duration timeout) throws RequestRe } @NotNull - public MessageSubscription handle(@NotNull RequestHandler

requestHandler) { + public MessagingSubscription handle(@NotNull MessagingRequestHandler

requestHandler) { Objects.requireNonNull(requestHandler); synchronized (lock) { if (closed || handler != null) { @@ -152,7 +154,7 @@ private void sendRequest(@NotNull UUID requestId, @NotNull P payload, @NotNull P } } try { - transport.send(RequestReplyMessage.request(requestId, endpointId, payload)).whenComplete((ignored, error) -> { + transport.send(MessagingRequestReplyMessage.request(requestId, endpointId, payload)).whenComplete((ignored, error) -> { if (error != null) { fail(requestId, call, sendFailure(error)); } @@ -163,19 +165,19 @@ private void sendRequest(@NotNull UUID requestId, @NotNull P payload, @NotNull P } @NotNull - private static RequestReplyException sendFailure(@NotNull Throwable error) { + private static MessagingRequestReplyException sendFailure(@NotNull Throwable error) { Throwable cause = error; while ((cause instanceof CompletionException || cause instanceof ExecutionException) && cause.getCause() != null) { cause = cause.getCause(); } - return cause instanceof RequestReplyException failure - ? failure : new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, error); + return cause instanceof MessagingRequestReplyException failure + ? failure : new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, error); } private void fail( @NotNull UUID requestId, @NotNull Pending expected, - @NotNull RequestReplyException failure + @NotNull MessagingRequestReplyException failure ) { synchronized (lock) { if (pending.remove(requestId, expected)) { @@ -184,8 +186,8 @@ private void fail( } } - private void receive(@NotNull RequestReplyMessage

message) { - if (message.type() == RequestReplyMessage.Type.REQUEST) { + private void receive(@NotNull MessagingRequestReplyMessage

message) { + if (message.type() == MessagingRequestReplyMessage.Type.REQUEST) { accept(message); return; } @@ -194,20 +196,21 @@ private void receive(@NotNull RequestReplyMessage

message) { Pending call = pending.remove(message.requestId()); if (call != null) { if (call.remaining() <= 0) { - call.result.completeExceptionally(new RequestReplyException(RequestReplyException.Reason.TIMEOUT)); - } else if (message.type() == RequestReplyMessage.Type.RESPONSE) { + call.result.completeExceptionally( + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.TIMEOUT)); + } else if (message.type() == MessagingRequestReplyMessage.Type.RESPONSE) { call.result.complete(message.payload()); } else { - call.result.completeExceptionally(new RequestReplyException(Objects.requireNonNull(message.error()))); + call.result.completeExceptionally(new MessagingRequestReplyException(Objects.requireNonNull(message.error()))); } } } } } - private void accept(@NotNull RequestReplyMessage

message) { + private void accept(@NotNull MessagingRequestReplyMessage

message) { Incoming accepted = null; - RequestReplyMessage.Error rejection = RequestReplyMessage.Error.OVERLOADED; + MessagingRequestReplyMessage.Error rejection = MessagingRequestReplyMessage.Error.OVERLOADED; synchronized (lock) { if (closed) { return; @@ -217,7 +220,7 @@ private void accept(@NotNull RequestReplyMessage

message) { return; } if (handler == null) { - rejection = RequestReplyMessage.Error.NO_HANDLER; + rejection = MessagingRequestReplyMessage.Error.NO_HANDLER; } else if (incoming.size() < options.maxConcurrentHandlers()) { accepted = new Incoming(key, message, handler.requestHandler, generation); incoming.put(key, accepted); @@ -237,7 +240,7 @@ private void accept(@NotNull RequestReplyMessage

message) { sendReply(message.failure(rejection)); } - private void sendReply(@NotNull RequestReplyMessage

message) { + private void sendReply(@NotNull MessagingRequestReplyMessage

message) { try { transport.send(message).whenComplete((ignored, error) -> { if (error != null) { @@ -253,14 +256,14 @@ private void disconnect() { List stopped; synchronized (lock) { generation++; - failPending(RequestReplyException.Reason.DISCONNECTED); + failPending(MessagingRequestReplyException.Reason.DISCONNECTED); stopped = List.copyOf(incoming.values()); } stopped.forEach(request -> request.task.cancel(true)); } - private void failPending(@NotNull RequestReplyException.Reason reason) { - pending.values().forEach(call -> call.result.completeExceptionally(new RequestReplyException(reason))); + private void failPending(@NotNull MessagingRequestReplyException.Reason reason) { + pending.values().forEach(call -> call.result.completeExceptionally(new MessagingRequestReplyException(reason))); pending.clear(); } @@ -273,7 +276,7 @@ public void close() { } closed = true; handler = null; - failPending(RequestReplyException.Reason.CLOSED); + failPending(MessagingRequestReplyException.Reason.CLOSED); stopped = List.copyOf(incoming.values()); } stopped.forEach(request -> request.task.cancel(true)); @@ -298,10 +301,10 @@ private long remaining() { } } - private final class HandlerRegistration implements MessageSubscription { - private final RequestHandler

requestHandler; + private final class HandlerRegistration implements MessagingSubscription { + private final MessagingRequestHandler

requestHandler; - private HandlerRegistration(@NotNull RequestHandler

requestHandler) { + private HandlerRegistration(@NotNull MessagingRequestHandler

requestHandler) { this.requestHandler = requestHandler; } @@ -317,15 +320,15 @@ public void close() { private final class Incoming implements Runnable { private final RequestKey key; - private final RequestReplyMessage

message; - private final RequestHandler

requestHandler; + private final MessagingRequestReplyMessage

message; + private final MessagingRequestHandler

requestHandler; private final long acceptedGeneration; private final FutureTask task; private Incoming( @NotNull RequestKey key, - @NotNull RequestReplyMessage

message, - @NotNull RequestHandler

requestHandler, + @NotNull MessagingRequestReplyMessage

message, + @NotNull MessagingRequestHandler

requestHandler, long acceptedGeneration ) { this.key = key; @@ -350,14 +353,14 @@ public void run() { } private void process() { - RequestReplyMessage

reply; + MessagingRequestReplyMessage

reply; try { reply = message.response(requestHandler.handle(Objects.requireNonNull(message.payload()))); } catch (Exception e) { if (e instanceof InterruptedException) { Thread.currentThread().interrupt(); } - reply = message.failure(RequestReplyMessage.Error.HANDLER_FAILED); + reply = message.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED); } synchronized (lock) { if (closed || acceptedGeneration != generation) { diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyException.java similarity index 80% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyException.java index 94d62e7..c915310 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyException.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyException.java @@ -21,26 +21,26 @@ import java.util.Objects; -public class RequestReplyException extends Exception { +public class MessagingRequestReplyException extends Exception { public enum Reason { CLOSED, DISCONNECTED, OVERLOADED, SEND_FAILED, TIMEOUT, REMOTE_ERROR } private final Reason reason; @Nullable - private final RequestReplyMessage.Error remoteError; + private final MessagingRequestReplyMessage.Error remoteError; - public RequestReplyException(@NotNull Reason reason) { + public MessagingRequestReplyException(@NotNull Reason reason) { this(reason, null); } - public RequestReplyException(@NotNull Reason reason, @Nullable Throwable cause) { + public MessagingRequestReplyException(@NotNull Reason reason, @Nullable Throwable cause) { super(message(Objects.requireNonNull(reason)), cause); this.reason = reason; this.remoteError = null; } - public RequestReplyException(@NotNull RequestReplyMessage.Error remoteError) { + public MessagingRequestReplyException(@NotNull MessagingRequestReplyMessage.Error remoteError) { super("Remote request failed: " + Objects.requireNonNull(remoteError)); this.reason = Reason.REMOTE_ERROR; this.remoteError = remoteError; @@ -52,7 +52,7 @@ public Reason reason() { } @Nullable - public RequestReplyMessage.Error remoteError() { + public MessagingRequestReplyMessage.Error remoteError() { return remoteError; } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessage.java similarity index 70% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessage.java index c5ccf24..5289c75 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyMessage.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessage.java @@ -22,7 +22,7 @@ import java.util.Objects; import java.util.UUID; -public record RequestReplyMessage

( +public record MessagingRequestReplyMessage

( @NotNull UUID requestId, @NotNull UUID replyTo, @NotNull Type type, @@ -37,7 +37,7 @@ public enum Error { NO_HANDLER, OVERLOADED, HANDLER_FAILED } - public RequestReplyMessage { + public MessagingRequestReplyMessage { Objects.requireNonNull(requestId); Objects.requireNonNull(replyTo); Objects.requireNonNull(type); @@ -48,20 +48,20 @@ public enum Error { } @NotNull - public static

RequestReplyMessage

request(@NotNull UUID requestId, @NotNull UUID replyTo, @NotNull P payload) { - return new RequestReplyMessage<>(requestId, replyTo, Type.REQUEST, Objects.requireNonNull(payload), null); + public static

MessagingRequestReplyMessage

request(@NotNull UUID requestId, @NotNull UUID replyTo, @NotNull P payload) { + return new MessagingRequestReplyMessage<>(requestId, replyTo, Type.REQUEST, Objects.requireNonNull(payload), null); } @NotNull - public RequestReplyMessage

response(@Nullable P result) { + public MessagingRequestReplyMessage

response(@Nullable P result) { requireRequest(); - return new RequestReplyMessage<>(requestId, replyTo, Type.RESPONSE, result, null); + return new MessagingRequestReplyMessage<>(requestId, replyTo, Type.RESPONSE, result, null); } @NotNull - public RequestReplyMessage

failure(@NotNull Error reason) { + public MessagingRequestReplyMessage

failure(@NotNull Error reason) { requireRequest(); - return new RequestReplyMessage<>(requestId, replyTo, Type.ERROR, null, Objects.requireNonNull(reason)); + return new MessagingRequestReplyMessage<>(requestId, replyTo, Type.ERROR, null, Objects.requireNonNull(reason)); } private void requireRequest() { diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptions.java similarity index 83% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptions.java index f25de1a..4c7a84e 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyOptions.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptions.java @@ -21,10 +21,10 @@ import java.time.Duration; import java.util.Objects; -public record RequestReplyOptions(@NotNull Duration timeout, int maxPendingRequests, int maxConcurrentHandlers) { - public static final RequestReplyOptions DEFAULT = new RequestReplyOptions(Duration.ofSeconds(30), 256, 64); +public record MessagingRequestReplyOptions(@NotNull Duration timeout, int maxPendingRequests, int maxConcurrentHandlers) { + public static final MessagingRequestReplyOptions DEFAULT = new MessagingRequestReplyOptions(Duration.ofSeconds(30), 256, 64); - public RequestReplyOptions { + public MessagingRequestReplyOptions { requireTimeout(timeout); if (maxPendingRequests < 1 || maxConcurrentHandlers < 1) { throw new IllegalArgumentException("Request/reply limits must be positive"); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyTransport.java similarity index 76% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyTransport.java index 88cb6cd..e60ce9d 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/RequestReplyTransport.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyTransport.java @@ -22,12 +22,12 @@ import java.util.concurrent.CompletionStage; /** Provides asynchronous request/reply message transport for a selected peer. */ -public interface RequestReplyTransport

{ +public interface MessagingRequestReplyTransport

{ @NotNull - MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener

listener) throws RequestReplyException; + MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener

listener) throws MessagingRequestReplyException; @NotNull - CompletionStage send(@NotNull RequestReplyMessage

message); + CompletionStage send(@NotNull MessagingRequestReplyMessage

message); default void executeRequest(@NotNull Runnable request) { request.run(); @@ -35,7 +35,7 @@ default void executeRequest(@NotNull Runnable request) { /** Receives endpoint messages and disconnection notifications. */ interface Listener

{ - void onMessage(@NotNull RequestReplyMessage

message); + void onMessage(@NotNull MessagingRequestReplyMessage

message); void onDisconnect(); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingSubscription.java similarity index 92% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingSubscription.java index 3f3daad..756a1b1 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessageSubscription.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingSubscription.java @@ -18,7 +18,7 @@ /** Controls the lifetime of a listener or handler registration. */ @FunctionalInterface -public interface MessageSubscription extends AutoCloseable { +public interface MessagingSubscription extends AutoCloseable { @Override void close(); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcCodec.java similarity index 96% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcCodec.java index da2c47f..c8e3741 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcCodec.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcCodec.java @@ -22,7 +22,7 @@ import java.lang.reflect.Type; /** Encodes and decodes declared RPC types using the payload representation accepted by the endpoint transport. */ -public interface RpcCodec

{ +public interface MessagingRpcCodec

{ @Nullable P encode(@Nullable Object value, @NotNull Type type) throws Exception; diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java similarity index 94% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java index e4bc21e..6261822 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcContract.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java @@ -34,7 +34,7 @@ import java.util.concurrent.CompletionStage; import java.util.concurrent.Future; -final class RpcContract { +final class MessagingRpcContract { private static final Map, Class> BOXED = Map.of( boolean.class, Boolean.class, byte.class, Byte.class, short.class, Short.class, char.class, Character.class, int.class, Integer.class, long.class, Long.class, float.class, Float.class, double.class, Double.class @@ -42,12 +42,12 @@ final class RpcContract { private final Map operations; - private RpcContract(@NotNull Map operations) { + private MessagingRpcContract(@NotNull Map operations) { this.operations = Map.copyOf(operations); } @NotNull - static RpcContract create(@NotNull Class type) { + static MessagingRpcContract create(@NotNull Class type) { Objects.requireNonNull(type); if (!type.isInterface() || !Modifier.isPublic(type.getModifiers()) || type.isSealed() || type.getTypeParameters().length != 0) { throw new IllegalArgumentException("RPC contract must be a public, nonsealed interface with concrete types"); @@ -70,7 +70,7 @@ static RpcContract create(@NotNull Class type) { throw new IllegalArgumentException("RPC contract has an unresolved generic bridge operation"); } } - return new RpcContract<>(operations); + return new MessagingRpcContract<>(operations); } void requireAccessible(@NotNull Object implementation) { @@ -142,14 +142,14 @@ private static boolean isConcrete(@NotNull Type type) { } if (type instanceof ParameterizedType parameterized) { return (parameterized.getOwnerType() == null || isConcrete(parameterized.getOwnerType())) - && Arrays.stream(parameterized.getActualTypeArguments()).allMatch(RpcContract::isConcrete); + && Arrays.stream(parameterized.getActualTypeArguments()).allMatch(MessagingRpcContract::isConcrete); } if (type instanceof GenericArrayType array) { return isConcrete(array.getGenericComponentType()); } if (type instanceof WildcardType wildcard) { - return Arrays.stream(wildcard.getUpperBounds()).allMatch(RpcContract::isConcrete) - && Arrays.stream(wildcard.getLowerBounds()).allMatch(RpcContract::isConcrete); + return Arrays.stream(wildcard.getUpperBounds()).allMatch(MessagingRpcContract::isConcrete) + && Arrays.stream(wildcard.getLowerBounds()).allMatch(MessagingRpcContract::isConcrete); } return false; } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java similarity index 66% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java index 729d2f8..e4f1a08 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcEndpoint.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java @@ -16,10 +16,10 @@ */ package com.dbeaver.messaging.rpc; -import com.dbeaver.messaging.requestreply.MessageSubscription; -import com.dbeaver.messaging.requestreply.RequestReplyEndpoint; -import com.dbeaver.messaging.requestreply.RequestReplyException; -import com.dbeaver.messaging.requestreply.RequestReplyOptions; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions; +import com.dbeaver.messaging.requestreply.MessagingSubscription; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; @@ -36,51 +36,51 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -public final class RpcEndpoint implements AutoCloseable { +public final class MessagingRpcEndpoint implements AutoCloseable { private final State state; - private RpcEndpoint(@NotNull State state) { + private MessagingRpcEndpoint(@NotNull State state) { this.state = state; } @NotNull - public static

RpcEndpoint open(@NotNull RpcTransport

transport) { - return open(transport, RequestReplyOptions.DEFAULT); + public static

MessagingRpcEndpoint open(@NotNull MessagingRpcTransport

transport) { + return open(transport, MessagingRequestReplyOptions.DEFAULT); } @NotNull - public static

RpcEndpoint open(@NotNull RpcTransport

transport, @NotNull RpcCodec

defaultCodec) { - return open(transport, defaultCodec, RequestReplyOptions.DEFAULT); + public static

MessagingRpcEndpoint open(@NotNull MessagingRpcTransport

transport, @NotNull MessagingRpcCodec

defaultCodec) { + return open(transport, defaultCodec, MessagingRequestReplyOptions.DEFAULT); } @NotNull - public static

RpcEndpoint open( - @NotNull RpcTransport

transport, - @NotNull RequestReplyOptions options + public static

MessagingRpcEndpoint open( + @NotNull MessagingRpcTransport

transport, + @NotNull MessagingRequestReplyOptions options ) { - return new RpcEndpoint(new State<>(transport, null, options)); + return new MessagingRpcEndpoint(new State<>(transport, null, options)); } @NotNull - public static

RpcEndpoint open( - @NotNull RpcTransport

transport, - @NotNull RpcCodec

defaultCodec, - @NotNull RequestReplyOptions options + public static

MessagingRpcEndpoint open( + @NotNull MessagingRpcTransport

transport, + @NotNull MessagingRpcCodec

defaultCodec, + @NotNull MessagingRequestReplyOptions options ) { - return new RpcEndpoint(new State<>(transport, Objects.requireNonNull(defaultCodec), options)); + return new MessagingRpcEndpoint(new State<>(transport, Objects.requireNonNull(defaultCodec), options)); } @NotNull - public MessageSubscription register(@NotNull String topic, @NotNull Class type, @NotNull T implementation) { + public MessagingSubscription register(@NotNull String topic, @NotNull Class type, @NotNull T implementation) { return register(topic, type, implementation, state.defaultCodec()); } @NotNull - public MessageSubscription register( + public MessagingSubscription register( @NotNull String topic, @NotNull Class type, @NotNull T implementation, - @NotNull RpcCodec codec + @NotNull MessagingRpcCodec codec ) { return state.register(topic, type, implementation, codec); } @@ -96,7 +96,7 @@ public T proxy(@NotNull String topic, @NotNull Class type, @NotNull Durat } @NotNull - public T proxy(@NotNull String topic, @NotNull Class type, @NotNull RpcCodec codec) { + public T proxy(@NotNull String topic, @NotNull Class type, @NotNull MessagingRpcCodec codec) { return proxy(topic, type, codec, state.options.timeout()); } @@ -104,7 +104,7 @@ public T proxy(@NotNull String topic, @NotNull Class type, @NotNull RpcCo public T proxy( @NotNull String topic, @NotNull Class type, - @NotNull RpcCodec codec, + @NotNull MessagingRpcCodec codec, @NotNull Duration timeout ) { return state.proxy(topic, type, codec, timeout); @@ -125,22 +125,26 @@ public void close() { private static final class State

{ private final Object lock = new Object(); - private final RpcTransport

transport; + private final MessagingRpcTransport

transport; @Nullable - private final RpcCodec

defaultCodec; - private final RequestReplyOptions options; + private final MessagingRpcCodec

defaultCodec; + private final MessagingRequestReplyOptions options; private final Map topics = new HashMap<>(); private boolean closed; private boolean closeComplete; - private State(@NotNull RpcTransport

transport, @Nullable RpcCodec

defaultCodec, @NotNull RequestReplyOptions options) { + private State( + @NotNull MessagingRpcTransport

transport, + @Nullable MessagingRpcCodec

defaultCodec, + @NotNull MessagingRequestReplyOptions options + ) { this.transport = Objects.requireNonNull(transport); this.defaultCodec = defaultCodec; this.options = Objects.requireNonNull(options); } @NotNull - private RpcCodec

defaultCodec() { + private MessagingRpcCodec

defaultCodec() { if (defaultCodec == null) { throw new IllegalStateException("RPC endpoint has no default codec"); } @@ -148,15 +152,15 @@ private RpcCodec

defaultCodec() { } @NotNull - private MessageSubscription register( + private MessagingSubscription register( @NotNull String topic, @NotNull Class type, @NotNull T implementation, - @NotNull RpcCodec codec + @NotNull MessagingRpcCodec codec ) { requireTopic(topic); - RpcCodec

bindingCodec = bindingCodec(codec); - RpcContract contract = RpcContract.create(type); + MessagingRpcCodec

bindingCodec = bindingCodec(codec); + MessagingRpcContract contract = MessagingRpcContract.create(type); if (!type.isInstance(Objects.requireNonNull(implementation))) { throw new IllegalArgumentException("RPC implementation does not implement its contract"); } @@ -177,19 +181,19 @@ private MessageSubscription register( private T proxy( @NotNull String topic, @NotNull Class type, - @NotNull RpcCodec codec, + @NotNull MessagingRpcCodec codec, @NotNull Duration timeout ) { requireTopic(topic); requireTimeout(timeout); - RpcCodec

bindingCodec = bindingCodec(codec); - RpcContract contract = RpcContract.create(type); + MessagingRpcCodec

bindingCodec = bindingCodec(codec); + MessagingRpcContract contract = MessagingRpcContract.create(type); Topic selected = topic(topic); synchronized (lock) { requireOpen(); } Object proxy = Proxy.newProxyInstance(type.getClassLoader(), new Class[] {type}, (instance, method, arguments) -> { - if (RpcContract.isObjectMethod(method)) { + if (MessagingRpcContract.isObjectMethod(method)) { return switch (method.getName()) { case "equals" -> instance == Objects.requireNonNull(arguments)[0]; case "hashCode" -> System.identityHashCode(instance); @@ -197,7 +201,7 @@ private T proxy( default -> throw new IllegalStateException("Unsupported proxy object method"); }; } - Method operation = Objects.requireNonNull(contract.operation(RpcContract.operationId(method))); + Method operation = Objects.requireNonNull(contract.operation(MessagingRpcContract.operationId(method))); return invoke(selected, operation, Objects.requireNonNull(arguments)[0], bindingCodec, timeout); }); return type.cast(proxy); @@ -208,40 +212,40 @@ private Object invoke( @NotNull Topic topic, @NotNull Method operation, @Nullable Object argument, - @NotNull RpcCodec

codec, + @NotNull MessagingRpcCodec

codec, @NotNull Duration timeout ) { synchronized (lock) { requireOpen(); } if (Thread.currentThread().isInterrupted()) { - throw new RpcException(RpcException.Reason.INTERRUPTED); + throw new MessagingRpcException(MessagingRpcException.Reason.INTERRUPTED); } - RpcMessage

request; + MessagingRpcMessage

request; try { P payload = codec.encode(argument, operation.getGenericParameterTypes()[0]); - request = RpcMessage.request(RpcContract.operationId(operation), payload); + request = MessagingRpcMessage.request(MessagingRpcContract.operationId(operation), payload); } catch (Exception e) { throw codecFailure(e); } - RpcMessage

response; + MessagingRpcMessage

response; try { response = Objects.requireNonNull(topic.requests).request(request, timeout); - } catch (RequestReplyException e) { - throw new RpcException(e); + } catch (MessagingRequestReplyException e) { + throw new MessagingRpcException(e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new RpcException(RpcException.Reason.INTERRUPTED, e); + throw new MessagingRpcException(MessagingRpcException.Reason.INTERRUPTED, e); } - if (response == null || response.type() == RpcMessage.Type.REQUEST) { - throw new RpcException(RpcException.Reason.INVALID_RESPONSE); + if (response == null || response.type() == MessagingRpcMessage.Type.REQUEST) { + throw new MessagingRpcException(MessagingRpcException.Reason.INVALID_RESPONSE); } - if (response.type() == RpcMessage.Type.ERROR) { - throw new RpcException(Objects.requireNonNull(response.error()), response.exception()); + if (response.type() == MessagingRpcMessage.Type.ERROR) { + throw new MessagingRpcException(Objects.requireNonNull(response.error()), response.exception()); } if (operation.getReturnType() == void.class) { if (response.payload() != null) { - throw new RpcException(RpcException.Reason.INVALID_RESPONSE); + throw new MessagingRpcException(MessagingRpcException.Reason.INVALID_RESPONSE); } return null; } @@ -251,66 +255,66 @@ private Object invoke( } catch (Exception e) { throw codecFailure(e); } - if (!RpcContract.accepts(operation.getReturnType(), result)) { - throw new RpcException(RpcException.Reason.INVALID_RESPONSE); + if (!MessagingRpcContract.accepts(operation.getReturnType(), result)) { + throw new MessagingRpcException(MessagingRpcException.Reason.INVALID_RESPONSE); } return result; } @NotNull - private RpcMessage

dispatch(@NotNull Topic topic, @NotNull RpcMessage

request) { - if (request.type() != RpcMessage.Type.REQUEST) { - return RpcMessage.failure(RpcMessage.Error.INVALID_REQUEST); + private MessagingRpcMessage

dispatch(@NotNull Topic topic, @NotNull MessagingRpcMessage

request) { + if (request.type() != MessagingRpcMessage.Type.REQUEST) { + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST); } ServiceRegistration registration; synchronized (lock) { if (closed) { - return RpcMessage.failure(RpcMessage.Error.UNKNOWN_SERVICE); + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_SERVICE); } registration = topic.registration; } if (registration == null) { - return RpcMessage.failure(RpcMessage.Error.UNKNOWN_SERVICE); + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_SERVICE); } Method operation = registration.contract.operation(Objects.requireNonNull(request.operation())); if (operation == null) { - return RpcMessage.failure(RpcMessage.Error.UNKNOWN_OPERATION); + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION); } Object argument; try { argument = registration.codec.decode(request.payload(), operation.getGenericParameterTypes()[0]); } catch (Exception e) { restoreInterruption(e); - return RpcMessage.failure(RpcMessage.Error.CODEC_FAILED, RpcExceptionDetails.capture(e)); + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.CODEC_FAILED, MessagingRpcExceptionDetails.capture(e)); } - if (!RpcContract.accepts(operation.getParameterTypes()[0], argument)) { - return RpcMessage.failure(RpcMessage.Error.INVALID_REQUEST); + if (!MessagingRpcContract.accepts(operation.getParameterTypes()[0], argument)) { + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST); } Object result; try { result = operation.invoke(registration.implementation, argument); } catch (InvocationTargetException e) { restoreInterruption(e.getCause()); - return RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED, - RpcExceptionDetails.capture(Objects.requireNonNullElse(e.getCause(), e))); + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED, + MessagingRpcExceptionDetails.capture(Objects.requireNonNullElse(e.getCause(), e))); } catch (ReflectiveOperationException | RuntimeException e) { - return RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED, RpcExceptionDetails.capture(e)); + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED, MessagingRpcExceptionDetails.capture(e)); } if (operation.getReturnType() == void.class) { - return RpcMessage.response(null); + return MessagingRpcMessage.response(null); } try { - return RpcMessage.response(registration.codec.encode(result, operation.getGenericReturnType())); + return MessagingRpcMessage.response(registration.codec.encode(result, operation.getGenericReturnType())); } catch (Exception e) { restoreInterruption(e); - return RpcMessage.failure(RpcMessage.Error.CODEC_FAILED, RpcExceptionDetails.capture(e)); + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.CODEC_FAILED, MessagingRpcExceptionDetails.capture(e)); } } @NotNull @SuppressWarnings("unchecked") - private RpcCodec

bindingCodec(@NotNull RpcCodec codec) { - return (RpcCodec

) Objects.requireNonNull(codec); + private MessagingRpcCodec

bindingCodec(@NotNull MessagingRpcCodec codec) { + return (MessagingRpcCodec

) Objects.requireNonNull(codec); } private static void requireTopic(@NotNull String topic) { @@ -356,17 +360,17 @@ private Topic topic(@NotNull String name) { return ready; } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new RpcException(RpcException.Reason.INTERRUPTED, e); + throw new MessagingRpcException(MessagingRpcException.Reason.INTERRUPTED, e); } catch (TimeoutException e) { - throw new RpcException(RpcException.Reason.TIMEOUT, e); + throw new MessagingRpcException(MessagingRpcException.Reason.TIMEOUT, e); } catch (ExecutionException e) { - if (e.getCause() instanceof RpcException failure) { + if (e.getCause() instanceof MessagingRpcException failure) { throw failure; } if (e.getCause() instanceof Error failure) { throw failure; } - throw new RpcException(RpcException.Reason.SEND_FAILED, e.getCause()); + throw new MessagingRpcException(MessagingRpcException.Reason.SEND_FAILED, e.getCause()); } } @@ -377,9 +381,9 @@ private void open(@NotNull Topic topic) { return; } } - RequestReplyEndpoint> requests = null; + MessagingRequestReplyEndpoint> requests = null; try { - requests = RequestReplyEndpoint.open(Objects.requireNonNull(transport.forTopic(topic.name)), options); + requests = MessagingRequestReplyEndpoint.open(Objects.requireNonNull(transport.forTopic(topic.name)), options); requests.handle(request -> dispatch(topic, request)); synchronized (lock) { topic.requests = requests; @@ -401,14 +405,15 @@ private void open(@NotNull Topic topic) { } synchronized (lock) { finish(topic); - topic.result.completeExceptionally(e instanceof RequestReplyException failure ? new RpcException(failure) : e); + topic.result.completeExceptionally( + e instanceof MessagingRequestReplyException failure ? new MessagingRpcException(failure) : e); } return; } try { close(topic); } catch (RuntimeException e) { - throw new RpcException(RpcException.Reason.CLOSED, e); + throw new MessagingRpcException(MessagingRpcException.Reason.CLOSED, e); } } @@ -418,11 +423,11 @@ private void finish(@NotNull Topic topic) { } @NotNull - private static RpcException codecFailure(@NotNull Exception cause) { + private static MessagingRpcException codecFailure(@NotNull Exception cause) { restoreInterruption(cause); - RpcException.Reason reason = cause instanceof InterruptedException - ? RpcException.Reason.INTERRUPTED : RpcException.Reason.CODEC_FAILED; - return new RpcException(reason, cause); + MessagingRpcException.Reason reason = cause instanceof InterruptedException + ? MessagingRpcException.Reason.INTERRUPTED : MessagingRpcException.Reason.CODEC_FAILED; + return new MessagingRpcException(reason, cause); } private static void restoreInterruption(@Nullable Throwable failure) { @@ -433,7 +438,7 @@ private static void restoreInterruption(@Nullable Throwable failure) { private void requireOpen() { if (closed) { - throw new RpcException(RpcException.Reason.CLOSED); + throw new MessagingRpcException(MessagingRpcException.Reason.CLOSED); } } @@ -459,7 +464,7 @@ private void close() { stopped = topics.values().stream().filter(topic -> !topic.opening).toList(); topics.values().forEach(topic -> { topic.registration = null; - topic.result.completeExceptionally(new RpcException(RpcException.Reason.CLOSED)); + topic.result.completeExceptionally(new MessagingRpcException(MessagingRpcException.Reason.CLOSED)); }); closeComplete = topics.isEmpty(); } @@ -503,7 +508,7 @@ private final class Topic { private final String name; private final CompletableFuture result = new CompletableFuture<>(); @Nullable - private RequestReplyEndpoint> requests; + private MessagingRequestReplyEndpoint> requests; @Nullable private ServiceRegistration registration; private boolean opening = true; @@ -514,17 +519,17 @@ private Topic(@NotNull String name) { } } - private final class ServiceRegistration implements MessageSubscription { + private final class ServiceRegistration implements MessagingSubscription { private final Topic topic; - private final RpcContract contract; + private final MessagingRpcContract contract; private final Object implementation; - private final RpcCodec

codec; + private final MessagingRpcCodec

codec; private ServiceRegistration( @NotNull Topic topic, - @NotNull RpcContract contract, + @NotNull MessagingRpcContract contract, @NotNull Object implementation, - @NotNull RpcCodec

codec + @NotNull MessagingRpcCodec

codec ) { this.topic = topic; this.contract = contract; diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcException.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcException.java similarity index 73% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcException.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcException.java index bc8a965..c90341e 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcException.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcException.java @@ -16,39 +16,39 @@ */ package com.dbeaver.messaging.rpc; -import com.dbeaver.messaging.requestreply.RequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; import java.util.Objects; -public class RpcException extends RuntimeException { +public class MessagingRpcException extends RuntimeException { public enum Reason { CLOSED, DISCONNECTED, OVERLOADED, SEND_FAILED, TIMEOUT, INTERRUPTED, CODEC_FAILED, INVALID_RESPONSE, REMOTE_ERROR } private final Reason reason; @Nullable - private final RpcMessage.Error remoteError; + private final MessagingRpcMessage.Error remoteError; @Nullable - private final RpcExceptionDetails remoteException; + private final MessagingRpcExceptionDetails remoteException; - public RpcException(@NotNull Reason reason) { + public MessagingRpcException(@NotNull Reason reason) { this(reason, null); } - public RpcException(@NotNull Reason reason, @Nullable Throwable cause) { + public MessagingRpcException(@NotNull Reason reason, @Nullable Throwable cause) { super("RPC call failed: " + Objects.requireNonNull(reason), cause); this.reason = reason; this.remoteError = null; this.remoteException = null; } - public RpcException(@NotNull RpcMessage.Error remoteError) { + public MessagingRpcException(@NotNull MessagingRpcMessage.Error remoteError) { this(remoteError, null); } - public RpcException(@NotNull RpcMessage.Error remoteError, @Nullable RpcExceptionDetails remoteException) { + public MessagingRpcException(@NotNull MessagingRpcMessage.Error remoteError, @Nullable MessagingRpcExceptionDetails remoteException) { super("Remote RPC call failed: " + Objects.requireNonNull(remoteError) + (remoteException == null ? "" : ": " + remoteException.type() + (remoteException.message() == null ? "" : ": " + remoteException.message())), @@ -58,7 +58,7 @@ public RpcException(@NotNull RpcMessage.Error remoteError, @Nullable RpcExceptio this.remoteException = remoteException; } - RpcException(@NotNull RequestReplyException cause) { + MessagingRpcException(@NotNull MessagingRequestReplyException cause) { this(switch (cause.reason()) { case CLOSED -> Reason.CLOSED; case DISCONNECTED -> Reason.DISCONNECTED; @@ -75,17 +75,17 @@ public Reason reason() { } @Nullable - public RpcMessage.Error remoteError() { + public MessagingRpcMessage.Error remoteError() { return remoteError; } @Nullable - public RpcExceptionDetails remoteException() { + public MessagingRpcExceptionDetails remoteException() { return remoteException; } private static final class RemoteException extends Exception { - private RemoteException(@NotNull RpcExceptionDetails details) { + private RemoteException(@NotNull MessagingRpcExceptionDetails details) { super("Remote stack trace" + (details.truncated() ? " (truncated)" : "") + ":\n" + details.stackTrace(), null, false, false); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcExceptionDetails.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetails.java similarity index 94% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcExceptionDetails.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetails.java index 5c2fecb..ddcae1e 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcExceptionDetails.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetails.java @@ -23,7 +23,7 @@ import java.io.Writer; import java.util.Objects; -public record RpcExceptionDetails( +public record MessagingRpcExceptionDetails( @NotNull String type, @Nullable String message, @NotNull String stackTrace, @@ -33,7 +33,7 @@ public record RpcExceptionDetails( public static final int MAX_MESSAGE_LENGTH = 1024; public static final int MAX_STACK_TRACE_LENGTH = 8192; - public RpcExceptionDetails { + public MessagingRpcExceptionDetails { Objects.requireNonNull(type); Objects.requireNonNull(stackTrace); if (type.isBlank() || type.length() > MAX_TYPE_LENGTH || message != null && message.length() > MAX_MESSAGE_LENGTH @@ -43,7 +43,7 @@ public record RpcExceptionDetails( } @NotNull - static RpcExceptionDetails capture(@NotNull Throwable failure) { + static MessagingRpcExceptionDetails capture(@NotNull Throwable failure) { String originalType = Objects.requireNonNull(failure).getClass().getName(); String originalMessage; boolean incomplete = false; @@ -70,7 +70,7 @@ static RpcExceptionDetails capture(@NotNull Throwable failure) { if (stackTrace.isEmpty()) { stackTrace = type + (message == null ? "" : ": " + message); } - return new RpcExceptionDetails(type, message, stackTrace, + return new MessagingRpcExceptionDetails(type, message, stackTrace, incomplete || !type.equals(originalType) || !Objects.equals(message, originalMessage)); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java similarity index 70% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java index 6040884..0b3a897 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcMessage.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java @@ -21,12 +21,12 @@ import java.util.Objects; -public record RpcMessage

( +public record MessagingRpcMessage

( @NotNull Type type, @Nullable String operation, @Nullable P payload, @Nullable Error error, - @Nullable RpcExceptionDetails exception + @Nullable MessagingRpcExceptionDetails exception ) { public enum Type { REQUEST, RESPONSE, ERROR @@ -36,7 +36,7 @@ public enum Error { UNKNOWN_SERVICE, UNKNOWN_OPERATION, INVALID_REQUEST, HANDLER_FAILED, CODEC_FAILED } - public RpcMessage { + public MessagingRpcMessage { Objects.requireNonNull(type); if (exception != null && type != Type.ERROR) { throw new IllegalArgumentException("RPC exception details require an error response"); @@ -52,22 +52,22 @@ public enum Error { } @NotNull - public static

RpcMessage

request(@NotNull String operation, @Nullable P payload) { - return new RpcMessage<>(Type.REQUEST, operation, payload, null, null); + public static

MessagingRpcMessage

request(@NotNull String operation, @Nullable P payload) { + return new MessagingRpcMessage<>(Type.REQUEST, operation, payload, null, null); } @NotNull - public static

RpcMessage

response(@Nullable P payload) { - return new RpcMessage<>(Type.RESPONSE, null, payload, null, null); + public static

MessagingRpcMessage

response(@Nullable P payload) { + return new MessagingRpcMessage<>(Type.RESPONSE, null, payload, null, null); } @NotNull - public static

RpcMessage

failure(@NotNull Error error) { + public static

MessagingRpcMessage

failure(@NotNull Error error) { return failure(error, null); } @NotNull - public static

RpcMessage

failure(@NotNull Error error, @Nullable RpcExceptionDetails exception) { - return new RpcMessage<>(Type.ERROR, null, null, Objects.requireNonNull(error), exception); + public static

MessagingRpcMessage

failure(@NotNull Error error, @Nullable MessagingRpcExceptionDetails exception) { + return new MessagingRpcMessage<>(Type.ERROR, null, null, Objects.requireNonNull(error), exception); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcTransport.java similarity index 71% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcTransport.java index 436ea56..d5f6735 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/RpcTransport.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcTransport.java @@ -16,13 +16,13 @@ */ package com.dbeaver.messaging.rpc; -import com.dbeaver.messaging.requestreply.RequestReplyException; -import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; import org.jkiss.code.NotNull; /** Opens topic-scoped RPC transports for a selected peer. */ @FunctionalInterface -public interface RpcTransport

{ +public interface MessagingRpcTransport

{ @NotNull - RequestReplyTransport> forTopic(@NotNull String topic) throws RequestReplyException; + MessagingRequestReplyTransport> forTopic(@NotNull String topic) throws MessagingRequestReplyException; } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java index 8bdccaf..7292466 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java @@ -25,11 +25,11 @@ import com.dbeaver.datadam.external.api.transport.DDMessageChannel; import com.dbeaver.datadam.external.api.transport.DDMessageListener; import com.dbeaver.datadam.external.api.transport.DDSubscription; -import com.dbeaver.messaging.requestreply.MessageSubscription; -import com.dbeaver.messaging.requestreply.RequestReplyEndpoint; -import com.dbeaver.messaging.requestreply.RequestReplyException; -import com.dbeaver.messaging.requestreply.RequestReplyMessage; -import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingSubscription; import com.google.gson.JsonNull; import com.google.gson.JsonObject; import org.jkiss.code.NotNull; @@ -66,12 +66,12 @@ void exchangesRequestsInBothDirectionsThroughExistingMessageChannels() throws Ex handlerChannel.forward = message -> callerChannel.deliver(CONNECTION, message); JsonObject payload = new JsonObject(); payload.addProperty("value", "request"); - try (var handler = RequestReplyEndpoint.open(DDRequestReplyTransport.forChannel(handlerChannel, TOPIC)); + try (var handler = MessagingRequestReplyEndpoint.open(DDRequestReplyTransport.forChannel(handlerChannel, TOPIC)); var handlerRegistration = handler.handle(request -> { request.addProperty("handled", true); return request; }); - var caller = RequestReplyEndpoint.open(DDRequestReplyTransport.forChannel(callerChannel, TOPIC)); + var caller = MessagingRequestReplyEndpoint.open(DDRequestReplyTransport.forChannel(callerChannel, TOPIC)); var callerRegistration = caller.handle(request -> null)) { JsonObject response = caller.request(payload, Duration.ofSeconds(2)); @@ -108,7 +108,7 @@ void routesRequestsAndRepliesToUniqueEndpointTopics() throws Exception { Assertions.assertTrue(secondReplyTopic.endsWith(secondId.toString())); Assertions.assertEquals(TOPIC, channel.subscriptions.get(1).topic); Assertions.assertEquals(TOPIC, channel.subscriptions.get(3).topic); - RequestReplyMessage request = request(firstId); + MessagingRequestReplyMessage request = request(firstId); first.send(request).toCompletableFuture().join(); DDMessage sent = channel.sent.getLast(); Assertions.assertEquals(TOPIC, sent.topic()); @@ -120,11 +120,11 @@ void routesRequestsAndRepliesToUniqueEndpointTopics() throws Exception { channel.deliver(CONNECTION, channel.sent.getLast()); Assertions.assertEquals(List.of(request, request.response(null)), firstReceived.messages); Assertions.assertEquals(List.of(request), secondReceived.messages); - RequestReplyMessage otherRequest = request(secondId); - first.send(otherRequest.failure(RequestReplyMessage.Error.NO_HANDLER)).toCompletableFuture().join(); + MessagingRequestReplyMessage otherRequest = request(secondId); + first.send(otherRequest.failure(MessagingRequestReplyMessage.Error.NO_HANDLER)).toCompletableFuture().join(); Assertions.assertEquals(secondReplyTopic, channel.sent.getLast().topic()); channel.deliver(CONNECTION, channel.sent.getLast()); - Assertions.assertEquals(otherRequest.failure(RequestReplyMessage.Error.NO_HANDLER), secondReceived.messages.getLast()); + Assertions.assertEquals(otherRequest.failure(MessagingRequestReplyMessage.Error.NO_HANDLER), secondReceived.messages.getLast()); } Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active)); Assertions.assertTrue(channel.observers.isEmpty()); @@ -149,14 +149,14 @@ void rollsBackReplySubscriptionAndStateObservationWhenRequestSubscriptionFails() FakeChannel channel = new FakeChannel(); channel.failTopic = TOPIC; DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); - RequestReplyException failure = Assertions.assertThrows(RequestReplyException.class, + MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); Assertions.assertSame(channel.subscriptionFailure, failure.getCause()); Assertions.assertFalse(channel.subscriptions.getFirst().active); Assertions.assertTrue(channel.observers.isEmpty()); Assertions.assertFalse(channel.closed); Assertions.assertThrows(IllegalStateException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); - assertFailure(RequestReplyException.Reason.CLOSED, adapter.send(request(UUID.randomUUID()))); + assertFailure(MessagingRequestReplyException.Reason.CLOSED, adapter.send(request(UUID.randomUUID()))); } @Test @@ -170,7 +170,7 @@ void scopedSubscriberPreservesCheckedOpeningFailureAndRollsBack() { } return closed::incrementAndGet; }); - RequestReplyException failure = Assertions.assertThrows(RequestReplyException.class, + MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); Assertions.assertSame(unavailable, failure.getCause()); Assertions.assertEquals(1, closed.get()); @@ -182,9 +182,9 @@ void rejectsDisconnectedAndClosedChannelsAtOpening() { FakeChannel channel = new FakeChannel(); channel.setStatus(new DDChannelStatus(state, null)); DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); - RequestReplyException failure = Assertions.assertThrows(RequestReplyException.class, + MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); - Assertions.assertEquals(RequestReplyException.Reason.DISCONNECTED, failure.reason()); + Assertions.assertEquals(MessagingRequestReplyException.Reason.DISCONNECTED, failure.reason()); Assertions.assertTrue(channel.subscriptions.isEmpty()); Assertions.assertTrue(channel.observers.isEmpty()); } @@ -200,9 +200,9 @@ void rollsBackAllResourcesAfterDisconnectDuringOpeningEvenIfTheChannelReconnects } }; DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); - RequestReplyException failure = Assertions.assertThrows(RequestReplyException.class, + MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); - Assertions.assertEquals(RequestReplyException.Reason.DISCONNECTED, failure.reason()); + Assertions.assertEquals(MessagingRequestReplyException.Reason.DISCONNECTED, failure.reason()); Assertions.assertEquals(2, channel.subscriptions.size()); Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active)); Assertions.assertTrue(channel.observers.isEmpty()); @@ -223,7 +223,7 @@ void propagatesDisconnectAndAcceptsOnlyTheCurrentPhysicalSource() throws Excepti Assertions.assertTrue(received.messages.isEmpty()); channel.setStatus(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); Assertions.assertEquals(1, received.disconnects); - assertFailure(RequestReplyException.Reason.DISCONNECTED, adapter.send(request(endpoint))); + assertFailure(MessagingRequestReplyException.Reason.DISCONNECTED, adapter.send(request(endpoint))); channel.deliver(CONNECTION, wire); Assertions.assertTrue(received.messages.isEmpty()); DDConnectionInfo replacement = new DDConnectionInfo("desktop", "new-run", "new-socket"); @@ -246,9 +246,9 @@ void completesMessageDeliveryBeforeNotifyingAConcurrentConnectionReplacement() t CountDownLatch messageEntered = new CountDownLatch(1); CountDownLatch releaseMessage = new CountDownLatch(1); List delivered = new CopyOnWriteArrayList<>(); - try (var subscription = adapter.subscribe(endpoint, new RequestReplyTransport.Listener<>() { + try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { @Override - public void onMessage(@NotNull RequestReplyMessage message) { + public void onMessage(@NotNull MessagingRequestReplyMessage message) { messageEntered.countDown(); await(releaseMessage); delivered.add("message"); @@ -298,9 +298,9 @@ void notifiesConnectionReplacementBeforeDeliveringANewSourceMessage() throws Exc CountDownLatch disconnectEntered = new CountDownLatch(1); CountDownLatch releaseDisconnect = new CountDownLatch(1); List delivered = new CopyOnWriteArrayList<>(); - try (var subscription = adapter.subscribe(endpoint, new RequestReplyTransport.Listener<>() { + try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { @Override - public void onMessage(@NotNull RequestReplyMessage message) { + public void onMessage(@NotNull MessagingRequestReplyMessage message) { delivered.add("message"); } @@ -345,9 +345,9 @@ void defersReentrantStateNotificationsUntilTheCurrentMessageReturns() throws Exc DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); List delivered = new ArrayList<>(); - try (var subscription = adapter.subscribe(endpoint, new RequestReplyTransport.Listener<>() { + try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { @Override - public void onMessage(@NotNull RequestReplyMessage message) { + public void onMessage(@NotNull MessagingRequestReplyMessage message) { channel.setStatus(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); delivered.add("message"); } @@ -371,9 +371,9 @@ void establishesTheNewGenerationBeforeMessagesWhenStateObserversAreDelayed() thr DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); List delivered = new ArrayList<>(); - try (var subscription = adapter.subscribe(endpoint, new RequestReplyTransport.Listener<>() { + try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { @Override - public void onMessage(@NotNull RequestReplyMessage message) { + public void onMessage(@NotNull MessagingRequestReplyMessage message) { delivered.add("message"); } @@ -441,9 +441,9 @@ void boundsQueuedCallbacksAndPreservesDisconnectWhenTheQueueIsFull() throws Exce CountDownLatch release = new CountDownLatch(1); AtomicInteger messages = new AtomicInteger(); AtomicInteger disconnects = new AtomicInteger(); - try (var subscription = adapter.subscribe(endpoint, new RequestReplyTransport.Listener<>() { + try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { @Override - public void onMessage(@NotNull RequestReplyMessage message) { + public void onMessage(@NotNull MessagingRequestReplyMessage message) { entered.countDown(); await(release); messages.incrementAndGet(); @@ -481,9 +481,9 @@ void releasesReceivingResourcesWhenAListenerFails() throws Exception { DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); IllegalStateException failure = new IllegalStateException("Listener failed"); - try (var subscription = adapter.subscribe(endpoint, new RequestReplyTransport.Listener<>() { + try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { @Override - public void onMessage(@NotNull RequestReplyMessage message) { + public void onMessage(@NotNull MessagingRequestReplyMessage message) { throw failure; } @@ -496,7 +496,7 @@ public void onDisconnect() { Assertions.assertSame(failure, Assertions.assertThrows(IllegalStateException.class, () -> channel.deliver(CONNECTION, channel.sent.getLast()))); - assertFailure(RequestReplyException.Reason.CLOSED, adapter.send(request(endpoint))); + assertFailure(MessagingRequestReplyException.Reason.CLOSED, adapter.send(request(endpoint))); Assertions.assertTrue(channel.subscriptions.stream().noneMatch(value -> value.active)); Assertions.assertTrue(channel.observers.isEmpty()); Assertions.assertFalse(channel.closed); @@ -510,7 +510,7 @@ void rejectsMalformedRecognizedFramesAndWrongTypeTopicAndOrigin() throws Excepti UUID endpoint = UUID.randomUUID(); Received received = new Received(); try (var subscription = adapter.subscribe(endpoint, received)) { - RequestReplyMessage request = request(endpoint); + MessagingRequestReplyMessage request = request(endpoint); adapter.send(request).toCompletableFuture().join(); DDMessage requestWire = channel.sent.getLast(); adapter.send(request.response(new JsonObject())).toCompletableFuture().join(); @@ -555,7 +555,7 @@ void rejectsMalformedRecognizedFramesAndWrongTypeTopicAndOrigin() throws Excepti JsonObject wrongOrigin = responseWire.payload(); wrongOrigin.addProperty("replyTo", UUID.randomUUID().toString()); channel.deliver(CONNECTION, new DDMessage(responseWire.topic(), responseWire.type(), wrongOrigin)); - adapter.send(request.failure(RequestReplyMessage.Error.HANDLER_FAILED)).toCompletableFuture().join(); + adapter.send(request.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)).toCompletableFuture().join(); DDMessage errorWire = channel.sent.getLast(); JsonObject errorData = errorWire.payload(); errorData.add("data", new JsonObject()); @@ -576,7 +576,7 @@ void rejectsMalformedRecognizedFramesAndWrongTypeTopicAndOrigin() throws Excepti channel.deliver(CONNECTION, responseWire); channel.deliver(CONNECTION, errorWire); Assertions.assertEquals(List.of(request, request.response(new JsonObject()), - request.failure(RequestReplyMessage.Error.HANDLER_FAILED)), received.messages); + request.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)), received.messages); } } @@ -586,13 +586,13 @@ void closeIsIdempotentAndLeavesBorrowedSubscriptionsIntact() throws Exception { final DDSubscription borrowed = channel.subscribe("other", (connection, message) -> { }); DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); - final MessageSubscription subscription = adapter.subscribe(endpoint, new Received()); + final MessagingSubscription subscription = adapter.subscribe(endpoint, new Received()); Assertions.assertThrows(IllegalStateException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); Assertions.assertThrows(CompletionException.class, () -> adapter.send(request(UUID.randomUUID())).toCompletableFuture().join()); adapter.close(); subscription.close(); - assertFailure(RequestReplyException.Reason.CLOSED, adapter.send(request(endpoint))); + assertFailure(MessagingRequestReplyException.Reason.CLOSED, adapter.send(request(endpoint))); Assertions.assertTrue(channel.subscriptions.getFirst().active); Assertions.assertFalse(channel.closed); borrowed.close(); @@ -612,13 +612,13 @@ void boundsOutstandingSendsAndReleasesBudgetOnCompletionAndFailure() throws Exce for (int i = 0; i < 128; i++) { sends.add(adapter.send(request(endpoint))); } - assertFailure(RequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); Assertions.assertEquals(128, pending.size()); pending.getFirst().complete(null); sends.getFirst().toCompletableFuture().get(2, TimeUnit.SECONDS); adapter.send(request(endpoint)); Assertions.assertEquals(129, pending.size()); - assertFailure(RequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); IllegalStateException unavailable = new IllegalStateException("Unavailable"); pending.get(1).completeExceptionally(unavailable); ExecutionException failure = Assertions.assertThrows(ExecutionException.class, @@ -626,13 +626,13 @@ void boundsOutstandingSendsAndReleasesBudgetOnCompletionAndFailure() throws Exce Assertions.assertSame(unavailable, failure.getCause()); adapter.send(request(endpoint)); Assertions.assertEquals(130, pending.size()); - assertFailure(RequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); Assertions.assertTrue(sends.get(2).toCompletableFuture().cancel(false)); - assertFailure(RequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); pending.get(2).complete(null); adapter.send(request(endpoint)); Assertions.assertEquals(131, pending.size()); - assertFailure(RequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); + assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint))); } finally { pending.forEach(result -> result.complete(null)); } @@ -661,14 +661,14 @@ void releasesSendBudgetAfterSynchronousSenderFailure() throws Exception { } @NotNull - private static RequestReplyMessage request(@NotNull UUID endpoint) { - return RequestReplyMessage.request(UUID.randomUUID(), endpoint, new JsonObject()); + private static MessagingRequestReplyMessage request(@NotNull UUID endpoint) { + return MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, new JsonObject()); } - private static void assertFailure(@NotNull RequestReplyException.Reason reason, @NotNull CompletionStage result) { + private static void assertFailure(@NotNull MessagingRequestReplyException.Reason reason, @NotNull CompletionStage result) { ExecutionException failure = Assertions.assertThrows(ExecutionException.class, () -> result.toCompletableFuture().get(2, TimeUnit.SECONDS)); - Assertions.assertEquals(reason, Assertions.assertInstanceOf(RequestReplyException.class, failure.getCause()).reason()); + Assertions.assertEquals(reason, Assertions.assertInstanceOf(MessagingRequestReplyException.class, failure.getCause()).reason()); } @NotNull @@ -679,15 +679,15 @@ private static FutureTask async(@NotNull Runnable action) { } @NotNull - private static RequestReplyTransport.Listener inlineReplies( + private static MessagingRequestReplyTransport.Listener inlineReplies( @NotNull DDRequestReplyTransport adapter, @NotNull CountDownLatch requests, @NotNull AtomicInteger replies ) { - return new RequestReplyTransport.Listener<>() { + return new MessagingRequestReplyTransport.Listener<>() { @Override - public void onMessage(@NotNull RequestReplyMessage message) { - if (message.type() == RequestReplyMessage.Type.REQUEST) { + public void onMessage(@NotNull MessagingRequestReplyMessage message) { + if (message.type() == MessagingRequestReplyMessage.Type.REQUEST) { requests.countDown(); await(requests); adapter.send(message.response(null)).toCompletableFuture().join(); @@ -711,12 +711,12 @@ private static void await(@NotNull CountDownLatch latch) { } } - private static final class Received implements RequestReplyTransport.Listener { - private final List> messages = new ArrayList<>(); + private static final class Received implements MessagingRequestReplyTransport.Listener { + private final List> messages = new ArrayList<>(); private int disconnects; @Override - public void onMessage(@NotNull RequestReplyMessage message) { + public void onMessage(@NotNull MessagingRequestReplyMessage message) { messages.add(message); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java index b5fc338..ca0eeaf 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java @@ -24,11 +24,11 @@ import com.dbeaver.datadam.external.api.transport.DDMessageChannel; import com.dbeaver.datadam.external.api.transport.DDMessageListener; import com.dbeaver.datadam.external.api.transport.DDSubscription; -import com.dbeaver.messaging.requestreply.RequestReplyOptions; -import com.dbeaver.messaging.rpc.RpcCodec; -import com.dbeaver.messaging.rpc.RpcEndpoint; -import com.dbeaver.messaging.rpc.RpcException; -import com.dbeaver.messaging.rpc.RpcMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions; +import com.dbeaver.messaging.rpc.MessagingRpcCodec; +import com.dbeaver.messaging.rpc.MessagingRpcEndpoint; +import com.dbeaver.messaging.rpc.MessagingRpcException; +import com.dbeaver.messaging.rpc.MessagingRpcMessage; import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; @@ -49,7 +49,7 @@ @Timeout(10) class DDRpcTopicsIntegrationTest { private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("desktop", "run", "physical-connection"); - private static final RequestReplyOptions OPTIONS = new RequestReplyOptions(Duration.ofSeconds(2), 16, 8); + private static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 16, 8); @Test void routesExactDeveloperTopicsBidirectionallyWithIndependentBindingCodecs() { @@ -68,9 +68,9 @@ void routesExactDeveloperTopicsBidirectionallyWithIndependentBindingCodecs() { var serviceCodec = new PrefixCodec("response:", "argument:"); var firstProxyCodec = new PrefixCodec("first:", "one:"); var secondProxyCodec = new PrefixCodec("second:", "two:"); - try (var desktopRpc = RpcEndpoint.open(DDRpcTransport.forChannel(desktop), codec, OPTIONS); - var firstRpc = RpcEndpoint.open(DDRpcTransport.forChannel(firstBackend), OPTIONS); - var secondRpc = RpcEndpoint.open(DDRpcTransport.forChannel(secondBackend), OPTIONS); + try (var desktopRpc = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(desktop), codec, OPTIONS); + var firstRpc = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(firstBackend), OPTIONS); + var secondRpc = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(secondBackend), OPTIONS); var firstGreeting = firstRpc.register(firstTopic, Greeting.class, value -> value, serviceCodec); var secondGreeting = secondRpc.register(secondTopic, Greeting.class, value -> "other:" + value, codec); var reverseGreeting = desktopRpc.register(firstTopic, Greeting.class, value -> "desktop:" + value)) { @@ -95,9 +95,9 @@ void routesExactDeveloperTopicsBidirectionallyWithIndependentBindingCodecs() { } } firstGreeting.close(); - RpcException missing = Assertions.assertThrows(RpcException.class, + MessagingRpcException missing = Assertions.assertThrows(MessagingRpcException.class, () -> first.hello("closed")); - Assertions.assertEquals(RpcMessage.Error.UNKNOWN_SERVICE, missing.remoteError()); + Assertions.assertEquals(MessagingRpcMessage.Error.UNKNOWN_SERVICE, missing.remoteError()); Assertions.assertEquals("other:still", desktopRpc.proxy(secondTopic, Greeting.class).hello("still")); } for (Channel channel : List.of(desktop, firstBackend, secondBackend)) { @@ -113,7 +113,7 @@ public interface Greeting { String hello(@NotNull String value); } - private record PrefixCodec(@NotNull String encodedPrefix, @NotNull String decodedPrefix) implements RpcCodec { + private record PrefixCodec(@NotNull String encodedPrefix, @NotNull String decodedPrefix) implements MessagingRpcCodec { @Override @NotNull public JsonElement encode(@Nullable Object value, @NotNull Type type) { diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java index c859aef..077410d 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java @@ -26,17 +26,17 @@ import com.dbeaver.datadam.external.api.transport.DDMessageChannel; import com.dbeaver.datadam.external.api.transport.DDMessageListener; import com.dbeaver.datadam.external.api.transport.DDSubscription; -import com.dbeaver.messaging.requestreply.MessageSubscription; -import com.dbeaver.messaging.requestreply.RequestReplyEndpoint; -import com.dbeaver.messaging.requestreply.RequestReplyException; -import com.dbeaver.messaging.requestreply.RequestReplyMessage; -import com.dbeaver.messaging.requestreply.RequestReplyOptions; -import com.dbeaver.messaging.requestreply.RequestReplyTransport; -import com.dbeaver.messaging.rpc.RpcCodec; -import com.dbeaver.messaging.rpc.RpcEndpoint; -import com.dbeaver.messaging.rpc.RpcException; -import com.dbeaver.messaging.rpc.RpcExceptionDetails; -import com.dbeaver.messaging.rpc.RpcMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingSubscription; +import com.dbeaver.messaging.rpc.MessagingRpcCodec; +import com.dbeaver.messaging.rpc.MessagingRpcEndpoint; +import com.dbeaver.messaging.rpc.MessagingRpcException; +import com.dbeaver.messaging.rpc.MessagingRpcExceptionDetails; +import com.dbeaver.messaging.rpc.MessagingRpcMessage; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -78,8 +78,8 @@ public class DDRpcTransportTest { private static final String TOPIC = "custom.files"; private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("desktop", "run", "socket"); - private static final RequestReplyOptions OPTIONS = new RequestReplyOptions(Duration.ofSeconds(2), 16, 8); - private static final RpcCodec CODEC = new GsonCodec(); + private static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 16, 8); + private static final MessagingRpcCodec CODEC = new GsonCodec(); @Test void delegatesRequestExecutionUnlessAnExplicitExecutorIsSupplied() { @@ -113,7 +113,7 @@ void channelRequestsRunInlineByDefaultAndCanUseABorrowedExecutor() throws Except FakeChannel channel = new FakeChannel(); channel.forward = channel::deliver; AtomicReference handlerThread = new AtomicReference<>(); - try (var endpoint = RpcEndpoint.open(topic -> supplied + try (var endpoint = MessagingRpcEndpoint.open(topic -> supplied ? DDRpcTransport.forChannel(channel, topic, executor) : DDRpcTransport.forChannel(channel, topic), CODEC, OPTIONS); var service = endpoint.register(TOPIC, Text.class, value -> { @@ -146,18 +146,18 @@ void mapsSuppliedExecutorRejectionToRemoteOverloadAndAllowsFreshCalls() { } request.run(); }; - var options = new RequestReplyOptions(Duration.ofSeconds(2), 1, 1); - try (var endpoint = RpcEndpoint.open(topic -> DDRpcTransport.forChannel(channel, topic, executor), CODEC, options); + var options = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 1, 1); + try (var endpoint = MessagingRpcEndpoint.open(topic -> DDRpcTransport.forChannel(channel, topic, executor), CODEC, options); var service = endpoint.register(TOPIC, Text.class, value -> { calls.incrementAndGet(); return value; })) { Text proxy = endpoint.proxy(TOPIC, Text.class); - RpcException failure = Assertions.assertThrows(RpcException.class, () -> proxy.apply("rejected")); - Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, failure.reason()); + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> proxy.apply("rejected")); + Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason()); Assertions.assertNull(failure.remoteError()); - RequestReplyException outer = Assertions.assertInstanceOf(RequestReplyException.class, failure.getCause()); - Assertions.assertEquals(RequestReplyMessage.Error.OVERLOADED, outer.remoteError()); + MessagingRequestReplyException outer = Assertions.assertInstanceOf(MessagingRequestReplyException.class, failure.getCause()); + Assertions.assertEquals(MessagingRequestReplyMessage.Error.OVERLOADED, outer.remoteError()); Assertions.assertEquals(0, calls.get()); rejecting.set(false); @@ -174,8 +174,8 @@ void supportsSynchronousCallbacksWithBorrowedRequestExecutorsAndIndependentChann FakeChannel right = new FakeChannel(); left.forward = message -> deliveries.execute(() -> right.deliver(message)); right.forward = message -> deliveries.execute(() -> left.deliver(message)); - try (var caller = RpcEndpoint.open(topic -> DDRpcTransport.forChannel(left, topic, requests), CODEC, OPTIONS); - var handler = RpcEndpoint.open(topic -> DDRpcTransport.forChannel(right, topic, requests), CODEC, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(topic -> DDRpcTransport.forChannel(left, topic, requests), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open(topic -> DDRpcTransport.forChannel(right, topic, requests), CODEC, OPTIONS); var callback = caller.register(TOPIC, Text.class, value -> "callback:" + value); var service = handler.register(TOPIC, Text.class, value -> handler.proxy(TOPIC, Text.class).apply(value))) { Assertions.assertEquals("callback:value", caller.proxy(TOPIC, Text.class).apply("value")); @@ -196,8 +196,9 @@ void servesIndependentBidirectionalTopicsWithoutClosingTheBorrowedChannel() thro AtomicInteger nullArguments = new AtomicInteger(); AtomicInteger notifications = new AtomicInteger(); final DDSubscription borrowed = left.subscribe("other", (connection, message) -> { }); - try (var caller = RpcEndpoint.open(topic -> new DDRpcTransport(DDRequestReplyTransport.forChannel(left, topic)), CODEC, OPTIONS); - var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); + try (var caller = MessagingRpcEndpoint.open( + topic -> new DDRpcTransport(DDRequestReplyTransport.forChannel(left, topic)), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); var leftNames = caller.register("names", Names.class, values -> List.of("left", values.getFirst())); var rightNames = handler.register("names", Names.class, values -> List.of("right", values.getFirst())); var otherNames = handler.register("other-names", Names.class, values -> List.of("other")); @@ -223,9 +224,9 @@ void servesIndependentBidirectionalTopicsWithoutClosingTheBorrowedChannel() thro Assertions.assertEquals("notifications", left.subscriptions.getLast().topic); Assertions.assertEquals("notifications", right.subscriptions.getLast().topic); otherNames.close(); - RpcException missing = Assertions.assertThrows(RpcException.class, + MessagingRpcException missing = Assertions.assertThrows(MessagingRpcException.class, () -> caller.proxy("other-names", Names.class).apply(List.of())); - Assertions.assertEquals(RpcMessage.Error.UNKNOWN_SERVICE, missing.remoteError()); + Assertions.assertEquals(MessagingRpcMessage.Error.UNKNOWN_SERVICE, missing.remoteError()); Assertions.assertEquals(List.of("right", "still-open"), caller.proxy("names", Names.class).apply(List.of("still-open"))); } @@ -247,8 +248,8 @@ void isolatesGsonArgumentsAndResultsAcrossRealMessageChannels() throws Exception right.forward = left::deliver; AtomicReference received = new AtomicReference<>(); Item argument = new Item("argument", new ArrayList<>(List.of("initial"))); - try (var caller = RpcEndpoint.open(DDRpcTransport.forChannel(left), CODEC, OPTIONS); - var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(left), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); var service = handler.register("records", Records.class, value -> { received.set(value); value.tags().add("handler"); @@ -272,14 +273,14 @@ void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { DDRpcTransport adapter = new DDRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); Received received = new Received(); - RequestReplyMessage> request = request(endpoint, new JsonPrimitive("argument")); + MessagingRequestReplyMessage> request = request(endpoint, new JsonPrimitive("argument")); try (var subscription = adapter.subscribe(endpoint, received)) { Assertions.assertEquals(endpoint, delegate.endpoint); adapter.send(request).toCompletableFuture().join(); - RequestReplyMessage sent = delegate.sent.getLast(); + MessagingRequestReplyMessage sent = delegate.sent.getLast(); Assertions.assertEquals(request.requestId(), sent.requestId()); Assertions.assertEquals(request.replyTo(), sent.replyTo()); - Assertions.assertEquals(RequestReplyMessage.Type.REQUEST, sent.type()); + Assertions.assertEquals(MessagingRequestReplyMessage.Type.REQUEST, sent.type()); Assertions.assertEquals("dbeaver.rpc.v2", Objects.requireNonNull(sent.payload()).get("protocol").getAsString()); JsonObject expectedRequest = requestEnvelope(); expectedRequest.addProperty("data", "argument"); @@ -287,20 +288,22 @@ void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { Assertions.assertFalse(sent.payload().has("topic")); delegate.deliver(sent); Assertions.assertEquals(request, received.messages.getLast()); - RequestReplyMessage> response = request.response(RpcMessage.response(new JsonPrimitive(42))); + MessagingRequestReplyMessage> response = + request.response(MessagingRpcMessage.response(new JsonPrimitive(42))); adapter.send(response).toCompletableFuture().join(); JsonObject expectedResponse = responseEnvelope(); expectedResponse.addProperty("data", 42); Assertions.assertEquals(expectedResponse, delegate.sent.getLast().payload()); delegate.deliver(delegate.sent.getLast()); Assertions.assertEquals(response, received.messages.getLast()); - for (RpcMessage.Error error : RpcMessage.Error.values()) { - RequestReplyMessage> failure = request.response(RpcMessage.failure(error)); + for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { + MessagingRequestReplyMessage> failure = + request.response(MessagingRpcMessage.failure(error)); adapter.send(failure).toCompletableFuture().join(); - RequestReplyMessage wire = delegate.sent.getLast(); + MessagingRequestReplyMessage wire = delegate.sent.getLast(); Assertions.assertEquals(request.requestId(), wire.requestId()); Assertions.assertEquals(request.replyTo(), wire.replyTo()); - Assertions.assertEquals(RequestReplyMessage.Type.RESPONSE, wire.type()); + Assertions.assertEquals(MessagingRequestReplyMessage.Type.RESPONSE, wire.type()); Assertions.assertNull(wire.error()); Assertions.assertFalse(Objects.requireNonNull(wire.payload()).has("data")); Assertions.assertEquals(error.name(), wire.payload().get("error").getAsString()); @@ -309,10 +312,10 @@ void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { delegate.deliver(wire); Assertions.assertEquals(failure, received.messages.getLast()); } - for (RequestReplyMessage.Error error : RequestReplyMessage.Error.values()) { - RequestReplyMessage> failure = request.failure(error); + for (MessagingRequestReplyMessage.Error error : MessagingRequestReplyMessage.Error.values()) { + MessagingRequestReplyMessage> failure = request.failure(error); adapter.send(failure).toCompletableFuture().join(); - RequestReplyMessage wire = delegate.sent.getLast(); + MessagingRequestReplyMessage wire = delegate.sent.getLast(); Assertions.assertNull(wire.payload()); Assertions.assertEquals(error, wire.error()); delegate.deliver(wire); @@ -331,21 +334,22 @@ void roundTripsStructuredDiagnosticsAndExplicitNullMessagesWithoutChangingOuterF DDRpcTransport adapter = new DDRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); Received received = new Received(); - RequestReplyMessage> request = request(endpoint, null); - List diagnostics = List.of( - new RpcExceptionDetails("application.Failure", "quoted \"message\"\n雪😀", "trace\r\n\t\0\\雪😀", false), - new RpcExceptionDetails("application.Failure", null, "", false), - new RpcExceptionDetails("application.Failure", "", "bounded trace", true) + MessagingRequestReplyMessage> request = request(endpoint, null); + List diagnostics = List.of( + new MessagingRpcExceptionDetails("application.Failure", "quoted \"message\"\n雪😀", "trace\r\n\t\0\\雪😀", false), + new MessagingRpcExceptionDetails("application.Failure", null, "", false), + new MessagingRpcExceptionDetails("application.Failure", "", "bounded trace", true) ); try (var subscription = adapter.subscribe(endpoint, received)) { - for (RpcMessage.Error error : RpcMessage.Error.values()) { - for (RpcExceptionDetails details : diagnostics) { - RequestReplyMessage> failure = request.response(RpcMessage.failure(error, details)); + for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { + for (MessagingRpcExceptionDetails details : diagnostics) { + MessagingRequestReplyMessage> failure = + request.response(MessagingRpcMessage.failure(error, details)); adapter.send(failure).toCompletableFuture().join(); - RequestReplyMessage wire = delegate.sent.getLast(); + MessagingRequestReplyMessage wire = delegate.sent.getLast(); Assertions.assertEquals(request.requestId(), wire.requestId()); Assertions.assertEquals(request.replyTo(), wire.replyTo()); - Assertions.assertEquals(RequestReplyMessage.Type.RESPONSE, wire.type()); + Assertions.assertEquals(MessagingRequestReplyMessage.Type.RESPONSE, wire.type()); Assertions.assertNull(wire.error()); JsonObject envelope = Objects.requireNonNull(wire.payload()); Assertions.assertEquals(Set.of("protocol", "type", "error", "exception"), envelope.keySet()); @@ -357,12 +361,12 @@ void roundTripsStructuredDiagnosticsAndExplicitNullMessagesWithoutChangingOuterF exception.addProperty("truncated", details.truncated()); expected.add("exception", exception); Assertions.assertEquals(expected, envelope); - delegate.deliver(new RequestReplyMessage<>(wire.requestId(), wire.replyTo(), wire.type(), + delegate.deliver(new MessagingRequestReplyMessage<>(wire.requestId(), wire.replyTo(), wire.type(), JsonParser.parseString(envelope.toString()).getAsJsonObject(), wire.error())); Assertions.assertEquals(failure, received.messages.getLast()); } } - Assertions.assertEquals(RpcMessage.Error.values().length * diagnostics.size(), received.messages.size()); + Assertions.assertEquals(MessagingRpcMessage.Error.values().length * diagnostics.size(), received.messages.size()); } } @@ -372,18 +376,18 @@ void maximumDiagnosticsFitTheFullFramedMessageBudgetUnderEscapingAndUnicode() th DDRpcTransport adapter = DDRpcTransport.forChannel(channel, "x".repeat(256)); UUID endpoint = UUID.randomUUID(); Received received = new Received(); - RequestReplyMessage> request = request(endpoint, null); + MessagingRequestReplyMessage> request = request(endpoint, null); try (var subscription = adapter.subscribe(endpoint, received)) { for (String unit : List.of("\0", "\"\\\n\r\t\b\f\37", "\u2028x\u2029x", "雪", "😀")) { - RpcExceptionDetails details = new RpcExceptionDetails( - unit.repeat(RpcExceptionDetails.MAX_TYPE_LENGTH / unit.length()), - unit.repeat(RpcExceptionDetails.MAX_MESSAGE_LENGTH / unit.length()), - unit.repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH / unit.length()), true); + MessagingRpcExceptionDetails details = new MessagingRpcExceptionDetails( + unit.repeat(MessagingRpcExceptionDetails.MAX_TYPE_LENGTH / unit.length()), + unit.repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH / unit.length()), + unit.repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH / unit.length()), true); Assertions.assertEquals(256, details.type().length()); Assertions.assertEquals(1024, Objects.requireNonNull(details.message()).length()); Assertions.assertEquals(8192, details.stackTrace().length()); - RequestReplyMessage> failure = request.response( - RpcMessage.failure(RpcMessage.Error.UNKNOWN_OPERATION, details)); + MessagingRequestReplyMessage> failure = request.response( + MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION, details)); adapter.send(failure).toCompletableFuture().join(); DDMessage wire = channel.sent.getLast(); Assertions.assertEquals("datadam.requestreply.v1", wire.type()); @@ -409,10 +413,11 @@ void rejectsMalformedExceptionDetailsInErrorEnvelopes() throws Exception { DDRpcTransport adapter = new DDRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); Received received = new Received(); - RequestReplyMessage request = RequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope()); + MessagingRequestReplyMessage request = + MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope()); List malformed = malformedExceptions(); try (var subscription = adapter.subscribe(endpoint, received)) { - for (RpcMessage.Error error : RpcMessage.Error.values()) { + for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { for (JsonElement details : malformed) { JsonObject envelope = errorEnvelope(error); envelope.add("exception", details); @@ -434,16 +439,17 @@ void acceptsArbitraryJsonValuesAndNormalizesEncodedNull() throws Exception { new JsonPrimitive(true), JsonNull.INSTANCE); try (var subscription = adapter.subscribe(endpoint, received)) { for (JsonElement value : values) { - RequestReplyMessage> request = request(endpoint, value); + MessagingRequestReplyMessage> request = request(endpoint, value); adapter.send(request).toCompletableFuture().join(); - RequestReplyMessage wire = delegate.sent.getLast(); + MessagingRequestReplyMessage wire = delegate.sent.getLast(); Assertions.assertEquals(value, Objects.requireNonNull(wire.payload()).get("data")); delegate.deliver(wire); Assertions.assertEquals(value.isJsonNull() ? null : value, Objects.requireNonNull(received.messages.getLast().payload()).payload()); - adapter.send(request.response(RpcMessage.response(value))).toCompletableFuture().join(); + adapter.send(request.response(MessagingRpcMessage.response(value))).toCompletableFuture().join(); delegate.deliver(delegate.sent.getLast()); - Assertions.assertEquals(RpcMessage.Type.RESPONSE, Objects.requireNonNull(received.messages.getLast().payload()).type()); + Assertions.assertEquals(MessagingRpcMessage.Type.RESPONSE, + Objects.requireNonNull(received.messages.getLast().payload()).type()); Assertions.assertEquals(value.isJsonNull() ? null : value, received.messages.getLast().payload().payload()); } } @@ -456,10 +462,12 @@ void isolatesSnapshotsEvenWhenTheBorrowedDelegateDoesNotCopy() throws Exception UUID endpoint = UUID.randomUUID(); Received received = new Received(); try (var subscription = adapter.subscribe(endpoint, received)) { - for (RequestReplyMessage.Type type : List.of(RequestReplyMessage.Type.REQUEST, RequestReplyMessage.Type.RESPONSE)) { + for (MessagingRequestReplyMessage.Type type : List.of( + MessagingRequestReplyMessage.Type.REQUEST, MessagingRequestReplyMessage.Type.RESPONSE)) { JsonObject argument = JsonParser.parseString("{\"items\":[\"original\"]}").getAsJsonObject(); - RequestReplyMessage> request = request(endpoint, argument); - adapter.send(type == RequestReplyMessage.Type.REQUEST ? request : request.response(RpcMessage.response(argument))); + MessagingRequestReplyMessage> request = request(endpoint, argument); + adapter.send(type == MessagingRequestReplyMessage.Type.REQUEST + ? request : request.response(MessagingRpcMessage.response(argument))); JsonObject wire = Objects.requireNonNull(delegate.sent.getLast().payload()); argument.getAsJsonArray("items").add("caller"); Assertions.assertEquals(1, wire.getAsJsonObject("data").getAsJsonArray("items").size()); @@ -481,18 +489,19 @@ void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception DDRpcTransport adapter = new DDRpcTransport(delegate); Received received = new Received(); UUID endpoint = UUID.randomUUID(); - RequestReplyMessage request = RequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope()); + MessagingRequestReplyMessage request = + MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope()); List nonStrings = List.of(new JsonPrimitive(1), new JsonPrimitive(true), JsonNull.INSTANCE, new JsonObject(), new JsonArray()); try (var subscription = adapter.subscribe(endpoint, received)) { for (String field : List.of("protocol", "type", "operation")) { JsonObject missing = requestEnvelope(); missing.remove(field); - delegate.deliver(RequestReplyMessage.request(request.requestId(), endpoint, missing)); + delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, missing)); for (JsonElement value : nonStrings) { JsonObject invalid = requestEnvelope(); invalid.add(field, value); - delegate.deliver(RequestReplyMessage.request(request.requestId(), endpoint, invalid)); + delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, invalid)); } } List> malformed = List.of( @@ -513,14 +522,14 @@ void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception for (Consumer mutation : malformed) { JsonObject invalid = requestEnvelope(); mutation.accept(invalid); - delegate.deliver(RequestReplyMessage.request(request.requestId(), endpoint, invalid)); + delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, invalid)); } Assertions.assertTrue(received.messages.isEmpty()); JsonObject boundary = requestEnvelope(); boundary.addProperty("operation", "x".repeat(1024)); - delegate.deliver(RequestReplyMessage.request(request.requestId(), endpoint, boundary)); + delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, boundary)); Assertions.assertEquals(1, received.messages.size()); - RpcMessage accepted = Objects.requireNonNull(received.messages.getLast().payload()); + MessagingRpcMessage accepted = Objects.requireNonNull(received.messages.getLast().payload()); Assertions.assertEquals("x".repeat(1024), accepted.operation()); Assertions.assertNull(accepted.payload()); } @@ -532,7 +541,8 @@ void dropsMalformedResponseAndNestedErrorShapes() throws Exception { DDRpcTransport adapter = new DDRpcTransport(delegate); Received received = new Received(); UUID endpoint = UUID.randomUUID(); - RequestReplyMessage request = RequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope()); + MessagingRequestReplyMessage request = + MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope()); try (var subscription = adapter.subscribe(endpoint, received)) { List> malformedResponses = List.of( envelope -> envelope.remove("data"), @@ -566,7 +576,7 @@ void dropsMalformedResponseAndNestedErrorShapes() throws Exception { ); for (Consumer mutation : malformedErrors) { for (JsonElement details : List.of(JsonNull.INSTANCE, exceptionEnvelope())) { - JsonObject invalid = errorEnvelope(RpcMessage.Error.HANDLER_FAILED); + JsonObject invalid = errorEnvelope(MessagingRpcMessage.Error.HANDLER_FAILED); invalid.add("exception", details); mutation.accept(invalid); delegate.deliver(request.response(invalid)); @@ -574,7 +584,7 @@ void dropsMalformedResponseAndNestedErrorShapes() throws Exception { } Assertions.assertTrue(received.messages.isEmpty()); delegate.deliver(request.response(responseEnvelope())); - delegate.deliver(request.response(errorEnvelope(RpcMessage.Error.HANDLER_FAILED))); + delegate.deliver(request.response(errorEnvelope(MessagingRpcMessage.Error.HANDLER_FAILED))); Assertions.assertEquals(2, received.messages.size()); } } @@ -602,37 +612,38 @@ void requiresObjectEnvelopesThroughExistingDataDamFraming() throws Exception { @Test void neutralRpcDistinguishesNestedErrorsOuterErrorsAndInvalidResponses() throws Exception { MemoryTransport delegate = new MemoryTransport(); - try (var endpoint = RpcEndpoint.open(topic -> new DDRpcTransport(delegate), CODEC, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(topic -> new DDRpcTransport(delegate), CODEC, OPTIONS)) { Text proxy = endpoint.proxy("text", Text.class); - for (RpcMessage.Error error : RpcMessage.Error.values()) { + for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { delegate.forward = request -> delegate.deliver(request.response(errorEnvelope(error))); - RpcException failure = Assertions.assertThrows(RpcException.class, () -> proxy.apply("request")); - Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, failure.reason()); + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> proxy.apply("request")); + Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason()); Assertions.assertEquals(error, failure.remoteError()); Assertions.assertNull(failure.remoteException()); Assertions.assertNull(failure.getCause()); Assertions.assertEquals("Remote RPC call failed: " + error, failure.getMessage()); } - for (RequestReplyMessage.Error error : RequestReplyMessage.Error.values()) { + for (MessagingRequestReplyMessage.Error error : MessagingRequestReplyMessage.Error.values()) { delegate.forward = request -> delegate.deliver(request.failure(error)); - RpcException failure = Assertions.assertThrows(RpcException.class, () -> proxy.apply("request")); - Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, failure.reason()); + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> proxy.apply("request")); + Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason()); Assertions.assertNull(failure.remoteError()); Assertions.assertNull(failure.remoteException()); - RequestReplyException outer = Assertions.assertInstanceOf(RequestReplyException.class, failure.getCause()); + MessagingRequestReplyException outer = + Assertions.assertInstanceOf(MessagingRequestReplyException.class, failure.getCause()); Assertions.assertEquals(error, outer.remoteError()); } delegate.forward = request -> delegate.deliver(request.response(null)); - RpcException invalid = Assertions.assertThrows(RpcException.class, () -> proxy.apply("request")); - Assertions.assertEquals(RpcException.Reason.INVALID_RESPONSE, invalid.reason()); + MessagingRpcException invalid = Assertions.assertThrows(MessagingRpcException.class, () -> proxy.apply("request")); + Assertions.assertEquals(MessagingRpcException.Reason.INVALID_RESPONSE, invalid.reason()); } } @Test void malformedAndUnknownVersionRepliesRemainUnansweredUntilTimeout() throws Exception { MemoryTransport delegate = new MemoryTransport(); - RequestReplyOptions options = new RequestReplyOptions(Duration.ofMillis(100), 4, 2); - try (var endpoint = RpcEndpoint.open(topic -> new DDRpcTransport(delegate), CODEC, options)) { + MessagingRequestReplyOptions options = new MessagingRequestReplyOptions(Duration.ofMillis(100), 4, 2); + try (var endpoint = MessagingRpcEndpoint.open(topic -> new DDRpcTransport(delegate), CODEC, options)) { for (Consumer mutation : List.>of( envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v1"), envelope -> envelope.addProperty("type", "UNKNOWN"), @@ -641,9 +652,9 @@ void malformedAndUnknownVersionRepliesRemainUnansweredUntilTimeout() throws Exce JsonObject invalid = responseEnvelope(); mutation.accept(invalid); delegate.forward = request -> delegate.deliver(request.response(invalid)); - RpcException failure = Assertions.assertThrows(RpcException.class, + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> endpoint.proxy("text", Text.class).apply("request")); - Assertions.assertEquals(RpcException.Reason.TIMEOUT, failure.reason()); + Assertions.assertEquals(MessagingRpcException.Reason.TIMEOUT, failure.reason()); } Assertions.assertEquals(3, delegate.sent.size()); } @@ -655,29 +666,30 @@ void dispatchesRealRpcFailuresInsideSuccessfulRequestReplyResponses() throws Exc FakeChannel right = new FakeChannel(); left.forward = right::deliver; right.forward = left::deliver; - try (var caller = RequestReplyEndpoint.open(DDRpcTransport.forChannel(left, TOPIC), OPTIONS); - var missingCaller = RequestReplyEndpoint.open(DDRpcTransport.forChannel(left, "missing"), OPTIONS); - var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); + try (var caller = MessagingRequestReplyEndpoint.open(DDRpcTransport.forChannel(left, TOPIC), OPTIONS); + var missingCaller = MessagingRequestReplyEndpoint.open(DDRpcTransport.forChannel(left, "missing"), OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); var service = handler.register(TOPIC, Text.class, value -> { throw new IllegalStateException("Handler failed"); })) { handler.proxy("missing", Text.class); - RpcMessage missingService = missingCaller.request(RpcMessage.request("apply", null)); - Assertions.assertEquals(RpcMessage.failure(RpcMessage.Error.UNKNOWN_SERVICE), missingService); - RpcMessage missingOperation = caller.request(RpcMessage.request("missing", null)); - Assertions.assertEquals(RpcMessage.failure(RpcMessage.Error.UNKNOWN_OPERATION), missingOperation); + MessagingRpcMessage missingService = missingCaller.request(MessagingRpcMessage.request("apply", null)); + Assertions.assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_SERVICE), missingService); + MessagingRpcMessage missingOperation = caller.request(MessagingRpcMessage.request("missing", null)); + Assertions.assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION), missingOperation); String operation = "apply(" + String.class.getTypeName() + ")"; final JsonSyntaxException expectedCodecFailure = Assertions.assertThrows(JsonSyntaxException.class, () -> CODEC.decode(new JsonObject(), String.class)); - RpcMessage codecFailure = caller.request(RpcMessage.request(operation, new JsonObject())); - Assertions.assertEquals(RpcMessage.Error.CODEC_FAILED, codecFailure.error()); - RpcExceptionDetails codecDetails = Objects.requireNonNull(codecFailure.exception()); + MessagingRpcMessage codecFailure = caller.request(MessagingRpcMessage.request(operation, new JsonObject())); + Assertions.assertEquals(MessagingRpcMessage.Error.CODEC_FAILED, codecFailure.error()); + MessagingRpcExceptionDetails codecDetails = Objects.requireNonNull(codecFailure.exception()); Assertions.assertEquals(expectedCodecFailure.getClass().getName(), codecDetails.type()); Assertions.assertEquals(expectedCodecFailure.getMessage(), codecDetails.message()); Assertions.assertTrue(codecDetails.stackTrace().startsWith(expectedCodecFailure + System.lineSeparator())); - RpcMessage handlerFailure = caller.request(RpcMessage.request(operation, new JsonPrimitive("value"))); - Assertions.assertEquals(RpcMessage.Error.HANDLER_FAILED, handlerFailure.error()); - RpcExceptionDetails handlerDetails = Objects.requireNonNull(handlerFailure.exception()); + MessagingRpcMessage handlerFailure = + caller.request(MessagingRpcMessage.request(operation, new JsonPrimitive("value"))); + Assertions.assertEquals(MessagingRpcMessage.Error.HANDLER_FAILED, handlerFailure.error()); + MessagingRpcExceptionDetails handlerDetails = Objects.requireNonNull(handlerFailure.exception()); Assertions.assertEquals(IllegalStateException.class.getName(), handlerDetails.type()); Assertions.assertEquals("Handler failed", handlerDetails.message()); Assertions.assertTrue(handlerDetails.stackTrace().contains("IllegalStateException: Handler failed")); @@ -705,16 +717,17 @@ void proxyFailuresExposeApplicationDiagnosticsWithNestedAndSuppressedTracesAcros CountingCodec callerCodec = new CountingCodec(); CountingCodec handlerCodec = new CountingCodec(); AtomicInteger calls = new AtomicInteger(); - try (var caller = RpcEndpoint.open(DDRpcTransport.forChannel(left), callerCodec, OPTIONS); - var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right), handlerCodec, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(left), callerCodec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(right), handlerCodec, OPTIONS); var service = handler.register(TOPIC, Text.class, value -> { calls.incrementAndGet(); throw application; })) { - RpcException failure = Assertions.assertThrows(RpcException.class, () -> caller.proxy(TOPIC, Text.class).apply("request")); - Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, failure.reason()); - Assertions.assertEquals(RpcMessage.Error.HANDLER_FAILED, failure.remoteError()); - Assertions.assertEquals(new RpcExceptionDetails(application.getClass().getName(), application.getMessage(), + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, + () -> caller.proxy(TOPIC, Text.class).apply("request")); + Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason()); + Assertions.assertEquals(MessagingRpcMessage.Error.HANDLER_FAILED, failure.remoteError()); + Assertions.assertEquals(new MessagingRpcExceptionDetails(application.getClass().getName(), application.getMessage(), trace.toString(), false), failure.remoteException()); Assertions.assertTrue(failure.getMessage().contains(application.getClass().getName())); Assertions.assertTrue(failure.getMessage().contains(application.getMessage())); @@ -757,7 +770,7 @@ void remoteArgumentAndResultCodecFailuresCarryDiagnosticsWithoutEncodingErrorsOr AtomicInteger decodes = new AtomicInteger(); AtomicInteger calls = new AtomicInteger(); IllegalStateException unavailable = new IllegalStateException(encoding ? "Result encoding failed" : "Argument decoding failed"); - RpcCodec handlerCodec = new RpcCodec<>() { + MessagingRpcCodec handlerCodec = new MessagingRpcCodec<>() { @Override @Nullable public JsonElement encode(@Nullable Object value, @NotNull Type type) { @@ -775,17 +788,17 @@ public Object decode(@Nullable JsonElement payload, @NotNull Type type) throws E return CODEC.decode(payload, type); } }; - try (var caller = RpcEndpoint.open(DDRpcTransport.forChannel(left), callerCodec, OPTIONS); - var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right), handlerCodec, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(left), callerCodec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(right), handlerCodec, OPTIONS); var service = handler.register(TOPIC, Text.class, value -> { calls.incrementAndGet(); return value; })) { - RpcException failure = Assertions.assertThrows(RpcException.class, + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> caller.proxy(TOPIC, Text.class).apply("request")); - Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, failure.reason()); - Assertions.assertEquals(RpcMessage.Error.CODEC_FAILED, failure.remoteError()); - RpcExceptionDetails details = Objects.requireNonNull(failure.remoteException()); + Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason()); + Assertions.assertEquals(MessagingRpcMessage.Error.CODEC_FAILED, failure.remoteError()); + MessagingRpcExceptionDetails details = Objects.requireNonNull(failure.remoteException()); Assertions.assertEquals(IllegalStateException.class.getName(), details.type()); Assertions.assertEquals(unavailable.getMessage(), details.message()); Assertions.assertTrue(details.stackTrace().contains("IllegalStateException: " + unavailable.getMessage())); @@ -806,13 +819,14 @@ void rejectsMismatchedOutgoingFramesBeforeCallingTheDelegate() { MemoryTransport delegate = new MemoryTransport(); DDRpcTransport adapter = new DDRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); - RequestReplyMessage> request = request(endpoint, null); - List>> malformed = List.of( - RequestReplyMessage.request(request.requestId(), endpoint, RpcMessage.response(null)), - RequestReplyMessage.request(request.requestId(), endpoint, RpcMessage.failure(RpcMessage.Error.INVALID_REQUEST)), + MessagingRequestReplyMessage> request = request(endpoint, null); + List>> malformed = List.of( + MessagingRequestReplyMessage.request(request.requestId(), endpoint, MessagingRpcMessage.response(null)), + MessagingRequestReplyMessage.request(request.requestId(), endpoint, + MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST)), request.response(request.payload()) ); - for (RequestReplyMessage> message : malformed) { + for (MessagingRequestReplyMessage> message : malformed) { ExecutionException failure = Assertions.assertThrows(ExecutionException.class, () -> adapter.send(message).toCompletableFuture().get(2, TimeUnit.SECONDS)); Assertions.assertInstanceOf(IllegalArgumentException.class, failure.getCause()); @@ -824,16 +838,17 @@ void rejectsMismatchedOutgoingFramesBeforeCallingTheDelegate() { void preservesAsynchronousAndSynchronousSendFailuresWithoutRetriesOrWrapping() throws Exception { MemoryTransport delegate = new MemoryTransport(); DDRpcTransport adapter = new DDRpcTransport(delegate); - RequestReplyMessage> request = request(UUID.randomUUID(), null); + MessagingRequestReplyMessage> request = request(UUID.randomUUID(), null); CompletableFuture pending = new CompletableFuture<>(); delegate.sendResult = pending; CompletionStage pendingResult = adapter.send(request); Assertions.assertFalse(pendingResult.toCompletableFuture().isDone()); pending.complete(null); Assertions.assertNull(pendingResult.toCompletableFuture().get(2, TimeUnit.SECONDS)); - for (RequestReplyException.Reason reason : List.of(RequestReplyException.Reason.DISCONNECTED, - RequestReplyException.Reason.CLOSED, RequestReplyException.Reason.OVERLOADED, RequestReplyException.Reason.SEND_FAILED)) { - RequestReplyException unavailable = new RequestReplyException(reason); + for (MessagingRequestReplyException.Reason reason : List.of(MessagingRequestReplyException.Reason.DISCONNECTED, + MessagingRequestReplyException.Reason.CLOSED, MessagingRequestReplyException.Reason.OVERLOADED, + MessagingRequestReplyException.Reason.SEND_FAILED)) { + MessagingRequestReplyException unavailable = new MessagingRequestReplyException(reason); delegate.sendResult = CompletableFuture.failedFuture(unavailable); CompletionStage result = adapter.send(request); ExecutionException failure = Assertions.assertThrows(ExecutionException.class, @@ -865,8 +880,8 @@ public JsonElement deepCopy() { return new JsonPrimitive("snapshot"); } }; - RequestReplyMessage> request = request(UUID.randomUUID(), value); - RequestReplyMessage> response = request.response(RpcMessage.response(value)); + MessagingRequestReplyMessage> request = request(UUID.randomUUID(), value); + MessagingRequestReplyMessage> response = request.response(MessagingRpcMessage.response(value)); List> pending = new ArrayList<>(); List> results = new ArrayList<>(); try { @@ -881,7 +896,7 @@ public JsonElement deepCopy() { Assertions.assertTrue(results.stream().noneMatch(CompletableFuture::isDone)); assertOverloaded(adapter.send(response)); assertOverloaded(adapter.send(request)); - assertOverloaded(adapter.send(request.failure(RequestReplyMessage.Error.OVERLOADED))); + assertOverloaded(adapter.send(request.failure(MessagingRequestReplyMessage.Error.OVERLOADED))); Assertions.assertEquals(128, delegate.sent.size()); Assertions.assertEquals(128, copies.get()); @@ -894,7 +909,8 @@ public JsonElement deepCopy() { Assertions.assertEquals(129, delegate.sent.size()); assertOverloaded(adapter.send(response)); - RequestReplyException unavailable = new RequestReplyException(RequestReplyException.Reason.DISCONNECTED); + MessagingRequestReplyException unavailable = + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED); pending.get(1).completeExceptionally(unavailable); assertSendFailure(unavailable, results.get(1)); CompletableFuture afterFailure = new CompletableFuture<>(); @@ -936,7 +952,7 @@ public JsonElement deepCopy() { } }; UUID endpoint = UUID.randomUUID(); - RequestReplyMessage> request = request(endpoint, value); + MessagingRequestReplyMessage> request = request(endpoint, value); for (int i = 0; i < 129; i++) { CompletionStage result = Assertions.assertDoesNotThrow(() -> adapter.send(request)); assertSendFailure(unavailable, result); @@ -951,16 +967,16 @@ void forwardsDisconnectsAndDelegatesSubscriptionOwnership() throws Exception { MemoryTransport delegate = new MemoryTransport(); DDRpcTransport adapter = new DDRpcTransport(delegate); Received received = new Received(); - MessageSubscription subscription = adapter.subscribe(UUID.randomUUID(), received); + MessagingSubscription subscription = adapter.subscribe(UUID.randomUUID(), received); Assertions.assertSame(delegate.subscription, subscription); Objects.requireNonNull(delegate.listener).onDisconnect(); Assertions.assertEquals(1, received.disconnects); subscription.close(); Assertions.assertTrue(delegate.closed); - RequestReplyException unavailable = new RequestReplyException(RequestReplyException.Reason.DISCONNECTED); + MessagingRequestReplyException unavailable = new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED); MemoryTransport failing = new MemoryTransport(); failing.subscribeFailure = unavailable; - RequestReplyException failure = Assertions.assertThrows(RequestReplyException.class, + MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, () -> new DDRpcTransport(failing).subscribe(UUID.randomUUID(), new Received())); Assertions.assertSame(unavailable, failure); } @@ -970,7 +986,7 @@ void rollsBackDelegatedChannelSubscriptionsWithoutClosingTheBorrowedChannel() { FakeChannel channel = new FakeChannel(); channel.failTopic = TOPIC; DDRpcTransport adapter = DDRpcTransport.forChannel(channel, TOPIC); - RequestReplyException failure = Assertions.assertThrows(RequestReplyException.class, + MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); Assertions.assertSame(channel.subscriptionFailure, failure.getCause()); Assertions.assertEquals(1, channel.subscriptions.size()); @@ -984,9 +1000,9 @@ void rollsBackDelegatedChannelSubscriptionsWithoutClosingTheBorrowedChannel() { void failedLazyTopicSubscriptionRollsBackAndCanBeRetried() { FakeChannel channel = new FakeChannel(); channel.failTopic = TOPIC; - try (var endpoint = RpcEndpoint.open(DDRpcTransport.forChannel(channel), CODEC, OPTIONS)) { - RpcException failure = Assertions.assertThrows(RpcException.class, () -> endpoint.proxy(TOPIC, Text.class)); - Assertions.assertEquals(RpcException.Reason.SEND_FAILED, failure.reason()); + try (var endpoint = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(channel), CODEC, OPTIONS)) { + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> endpoint.proxy(TOPIC, Text.class)); + Assertions.assertEquals(MessagingRpcException.Reason.SEND_FAILED, failure.reason()); Assertions.assertSame(channel.subscriptionFailure, failure.getCause().getCause()); Assertions.assertTrue(channel.observers.isEmpty()); Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active)); @@ -1005,7 +1021,7 @@ void incompatibleCodecPayloadsUseNormalSendAndRemoteCodecFailures() { FakeChannel right = new FakeChannel(); left.forward = right::deliver; right.forward = left::deliver; - RpcCodec incompatible = new RpcCodec<>() { + MessagingRpcCodec incompatible = new MessagingRpcCodec<>() { @Override @Nullable public String encode(@Nullable Object value, @NotNull Type type) { @@ -1018,20 +1034,20 @@ public Object decode(@Nullable String payload, @NotNull Type type) { return payload; } }; - try (var caller = RpcEndpoint.open(DDRpcTransport.forChannel(left), CODEC, OPTIONS); - var handler = RpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(left), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); var service = handler.register(TOPIC, Text.class, value -> value)) { - RpcException sendFailure = Assertions.assertThrows(RpcException.class, + MessagingRpcException sendFailure = Assertions.assertThrows(MessagingRpcException.class, () -> caller.proxy(TOPIC, Text.class, incompatible).apply("incompatible")); - Assertions.assertEquals(RpcException.Reason.SEND_FAILED, sendFailure.reason()); + Assertions.assertEquals(MessagingRpcException.Reason.SEND_FAILED, sendFailure.reason()); Assertions.assertEquals("compatible", caller.proxy(TOPIC, Text.class).apply("compatible")); service.close(); try (var replacement = handler.register(TOPIC, Text.class, value -> value, incompatible)) { - RpcException codecFailure = Assertions.assertThrows(RpcException.class, + MessagingRpcException codecFailure = Assertions.assertThrows(MessagingRpcException.class, () -> caller.proxy(TOPIC, Text.class).apply("incompatible")); - Assertions.assertEquals(RpcException.Reason.REMOTE_ERROR, codecFailure.reason()); - Assertions.assertEquals(RpcMessage.Error.CODEC_FAILED, codecFailure.remoteError()); - RpcExceptionDetails details = Objects.requireNonNull(codecFailure.remoteException()); + Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, codecFailure.reason()); + Assertions.assertEquals(MessagingRpcMessage.Error.CODEC_FAILED, codecFailure.remoteError()); + MessagingRpcExceptionDetails details = Objects.requireNonNull(codecFailure.remoteException()); Assertions.assertEquals(ClassCastException.class.getName(), details.type()); Assertions.assertTrue(details.stackTrace().contains(ClassCastException.class.getName())); Assertions.assertTrue(Objects.requireNonNull(codecFailure.getCause()).getMessage() @@ -1044,10 +1060,10 @@ public Object decode(@Nullable String payload, @NotNull Type type) { void channelDisconnectTerminatesPendingRpcAndClosingLeavesTheChannelIntact() throws Exception { FakeChannel channel = new FakeChannel(); channel.forward = message -> channel.setStatus(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); - try (var endpoint = RpcEndpoint.open(DDRpcTransport.forChannel(channel), CODEC, OPTIONS)) { - RpcException failure = Assertions.assertThrows(RpcException.class, + try (var endpoint = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(channel), CODEC, OPTIONS)) { + MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> endpoint.proxy("text", Text.class).apply("request")); - Assertions.assertEquals(RpcException.Reason.DISCONNECTED, failure.reason()); + Assertions.assertEquals(MessagingRpcException.Reason.DISCONNECTED, failure.reason()); } Assertions.assertFalse(channel.closed); Assertions.assertTrue(channel.observers.isEmpty()); @@ -1055,8 +1071,11 @@ void channelDisconnectTerminatesPendingRpcAndClosingLeavesTheChannelIntact() thr } @NotNull - private static RequestReplyMessage> request(@NotNull UUID endpoint, @Nullable JsonElement value) { - return RequestReplyMessage.request(UUID.randomUUID(), endpoint, RpcMessage.request("apply", value)); + private static MessagingRequestReplyMessage> request( + @NotNull UUID endpoint, + @Nullable JsonElement value + ) { + return MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, MessagingRpcMessage.request("apply", value)); } private static void assertSendFailure(@NotNull Throwable expected, @NotNull CompletionStage result) { @@ -1068,8 +1087,8 @@ private static void assertSendFailure(@NotNull Throwable expected, @NotNull Comp private static void assertOverloaded(@NotNull CompletionStage result) { ExecutionException failure = Assertions.assertThrows(ExecutionException.class, () -> result.toCompletableFuture().get(2, TimeUnit.SECONDS)); - RequestReplyException unavailable = Assertions.assertInstanceOf(RequestReplyException.class, failure.getCause()); - Assertions.assertEquals(RequestReplyException.Reason.OVERLOADED, unavailable.reason()); + MessagingRequestReplyException unavailable = Assertions.assertInstanceOf(MessagingRequestReplyException.class, failure.getCause()); + Assertions.assertEquals(MessagingRequestReplyException.Reason.OVERLOADED, unavailable.reason()); } @NotNull @@ -1084,7 +1103,7 @@ private static JsonObject responseEnvelope() { } @NotNull - private static JsonObject errorEnvelope(@NotNull RpcMessage.Error error) { + private static JsonObject errorEnvelope(@NotNull MessagingRpcMessage.Error error) { JsonObject result = JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v2\",\"type\":\"ERROR\"}").getAsJsonObject(); result.addProperty("error", error.name()); result.add("exception", JsonNull.INSTANCE); @@ -1125,12 +1144,12 @@ private static List malformedExceptions() { List> malformed = List.of( exception -> exception.addProperty("type", ""), exception -> exception.addProperty("type", " \t\n\u2003"), - exception -> exception.addProperty("type", "x".repeat(RpcExceptionDetails.MAX_TYPE_LENGTH + 1)), - exception -> exception.addProperty("message", "x".repeat(RpcExceptionDetails.MAX_MESSAGE_LENGTH + 1)), - exception -> exception.addProperty("stackTrace", "x".repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH + 1)), - exception -> exception.addProperty("type", "😀".repeat(RpcExceptionDetails.MAX_TYPE_LENGTH / 2 + 1)), - exception -> exception.addProperty("message", "😀".repeat(RpcExceptionDetails.MAX_MESSAGE_LENGTH / 2 + 1)), - exception -> exception.addProperty("stackTrace", "😀".repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH / 2 + 1)), + exception -> exception.addProperty("type", "x".repeat(MessagingRpcExceptionDetails.MAX_TYPE_LENGTH + 1)), + exception -> exception.addProperty("message", "x".repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH + 1)), + exception -> exception.addProperty("stackTrace", "x".repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH + 1)), + exception -> exception.addProperty("type", "😀".repeat(MessagingRpcExceptionDetails.MAX_TYPE_LENGTH / 2 + 1)), + exception -> exception.addProperty("message", "😀".repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH / 2 + 1)), + exception -> exception.addProperty("stackTrace", "😀".repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH / 2 + 1)), exception -> exception.addProperty("extra", true) ); for (Consumer mutation : malformed) { @@ -1167,7 +1186,7 @@ public interface Notification { public record Item(@NotNull String name, @NotNull List tags) { } - private static final class GsonCodec implements RpcCodec { + private static final class GsonCodec implements MessagingRpcCodec { private final Gson gson = new Gson(); @Override @@ -1184,7 +1203,7 @@ public Object decode(@Nullable JsonElement payload, @NotNull Type type) { } } - private static final class CountingCodec implements RpcCodec { + private static final class CountingCodec implements MessagingRpcCodec { private final AtomicInteger encodes = new AtomicInteger(); private final AtomicInteger decodes = new AtomicInteger(); @@ -1203,12 +1222,12 @@ public Object decode(@Nullable JsonElement payload, @NotNull Type type) throws E } } - private static final class Received implements RequestReplyTransport.Listener> { - private final List>> messages = new ArrayList<>(); + private static final class Received implements MessagingRequestReplyTransport.Listener> { + private final List>> messages = new ArrayList<>(); private int disconnects; @Override - public void onMessage(@NotNull RequestReplyMessage> message) { + public void onMessage(@NotNull MessagingRequestReplyMessage> message) { messages.add(message); } @@ -1218,9 +1237,9 @@ public void onDisconnect() { } } - private static final class MemoryTransport implements RequestReplyTransport { - private final List> sent = new ArrayList<>(); - private final MessageSubscription subscription = () -> this.closed = true; + private static final class MemoryTransport implements MessagingRequestReplyTransport { + private final List> sent = new ArrayList<>(); + private final MessagingSubscription subscription = () -> this.closed = true; private CompletionStage sendResult = CompletableFuture.completedFuture(null); private Executor requestExecutor = Runnable::run; @Nullable @@ -1228,17 +1247,17 @@ private static final class MemoryTransport implements RequestReplyTransport listener; @Nullable - private Consumer> forward; + private Consumer> forward; @Nullable - private RequestReplyException subscribeFailure; + private MessagingRequestReplyException subscribeFailure; @Nullable private RuntimeException sendFailure; private boolean closed; @Override @NotNull - public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener receiver) - throws RequestReplyException { + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener receiver) + throws MessagingRequestReplyException { if (subscribeFailure != null) { throw subscribeFailure; } @@ -1249,7 +1268,7 @@ public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener @Override @NotNull - public CompletionStage send(@NotNull RequestReplyMessage message) { + public CompletionStage send(@NotNull MessagingRequestReplyMessage message) { sent.add(message); if (sendFailure != null) { throw sendFailure; @@ -1265,7 +1284,7 @@ public void executeRequest(@NotNull Runnable request) { requestExecutor.execute(request); } - private void deliver(@NotNull RequestReplyMessage message) { + private void deliver(@NotNull MessagingRequestReplyMessage message) { Objects.requireNonNull(listener).onMessage(message); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyDeadlineTest.java similarity index 66% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyDeadlineTest.java index 69b884f..09e56be 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyDeadlineTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyDeadlineTest.java @@ -31,7 +31,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -class RequestReplyDeadlineTest { +class MessagingRequestReplyDeadlineTest { @Test void rejectsAnExpiredResponseBeforeTheCallerStartsWaiting() throws Exception { AtomicLong nanos = new AtomicLong(); @@ -41,10 +41,10 @@ void rejectsAnExpiredResponseBeforeTheCallerStartsWaiting() throws Exception { transport.listener.onMessage(request.response("late")); }; - try (var endpoint = new RequestReplyEndpoint<>(transport, RequestReplyOptions.DEFAULT, nanos::get)) { - RequestReplyException failure = assertThrows(RequestReplyException.class, + try (var endpoint = new MessagingRequestReplyEndpoint<>(transport, MessagingRequestReplyOptions.DEFAULT, nanos::get)) { + MessagingRequestReplyException failure = assertThrows(MessagingRequestReplyException.class, () -> endpoint.request("request", Duration.ofSeconds(1))); - assertEquals(RequestReplyException.Reason.TIMEOUT, failure.reason()); + assertEquals(MessagingRequestReplyException.Reason.TIMEOUT, failure.reason()); } } @@ -57,7 +57,7 @@ void preservesAResponseAcceptedBeforeTheDeadlineWhenTheCallerResumesLater() thro nanos.set(Duration.ofSeconds(2).toNanos()); }; - try (var endpoint = new RequestReplyEndpoint<>(transport, RequestReplyOptions.DEFAULT, nanos::get)) { + try (var endpoint = new MessagingRequestReplyEndpoint<>(transport, MessagingRequestReplyOptions.DEFAULT, nanos::get)) { assertEquals("on time", endpoint.request("request", Duration.ofSeconds(1))); } } @@ -70,11 +70,11 @@ void doesNotSendWhenTheDeadlineExpiresBeforeSending() throws Exception { throw new AssertionError("Expired request was sent"); }; - try (var endpoint = new RequestReplyEndpoint<>(transport, RequestReplyOptions.DEFAULT, + try (var endpoint = new MessagingRequestReplyEndpoint<>(transport, MessagingRequestReplyOptions.DEFAULT, () -> nanos.getAndAdd(Duration.ofSeconds(2).toNanos()))) { - RequestReplyException failure = assertThrows(RequestReplyException.class, + MessagingRequestReplyException failure = assertThrows(MessagingRequestReplyException.class, () -> endpoint.request("request", Duration.ofSeconds(1))); - assertEquals(RequestReplyException.Reason.TIMEOUT, failure.reason()); + assertEquals(MessagingRequestReplyException.Reason.TIMEOUT, failure.reason()); } } @@ -86,52 +86,52 @@ void doesNotSendAPendingRequestAlreadyFailedByDisconnect() throws Exception { throw new AssertionError("Disconnected request was sent"); }; - try (var endpoint = new RequestReplyEndpoint<>(transport, RequestReplyOptions.DEFAULT, () -> { + try (var endpoint = new MessagingRequestReplyEndpoint<>(transport, MessagingRequestReplyOptions.DEFAULT, () -> { if (reads.incrementAndGet() == 2) { transport.listener.onDisconnect(); } return 0; })) { - RequestReplyException failure = assertThrows(RequestReplyException.class, () -> endpoint.request("request")); - assertEquals(RequestReplyException.Reason.DISCONNECTED, failure.reason()); + MessagingRequestReplyException failure = assertThrows(MessagingRequestReplyException.class, () -> endpoint.request("request")); + assertEquals(MessagingRequestReplyException.Reason.DISCONNECTED, failure.reason()); } } @Test void doesNotSendAPendingRequestAlreadyFailedByClosure() throws Exception { AtomicInteger reads = new AtomicInteger(); - AtomicReference> current = new AtomicReference<>(); + AtomicReference> current = new AtomicReference<>(); Transport transport = new Transport(); transport.sending = request -> { throw new AssertionError("Closed request was sent"); }; - try (var endpoint = new RequestReplyEndpoint<>(transport, RequestReplyOptions.DEFAULT, () -> { + try (var endpoint = new MessagingRequestReplyEndpoint<>(transport, MessagingRequestReplyOptions.DEFAULT, () -> { if (reads.incrementAndGet() == 2) { current.get().close(); } return 0; })) { current.set(endpoint); - RequestReplyException failure = assertThrows(RequestReplyException.class, () -> endpoint.request("request")); - assertEquals(RequestReplyException.Reason.CLOSED, failure.reason()); + MessagingRequestReplyException failure = assertThrows(MessagingRequestReplyException.class, () -> endpoint.request("request")); + assertEquals(MessagingRequestReplyException.Reason.CLOSED, failure.reason()); } } - private static final class Transport implements RequestReplyTransport { + private static final class Transport implements MessagingRequestReplyTransport { private Listener listener; - private Consumer> sending; + private Consumer> sending; @Override @NotNull - public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener receiver) { + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener receiver) { listener = receiver; return () -> { }; } @Override @NotNull - public CompletionStage send(@NotNull RequestReplyMessage message) { + public CompletionStage send(@NotNull MessagingRequestReplyMessage message) { sending.accept(message); return CompletableFuture.completedFuture(null); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpointTest.java similarity index 73% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpointTest.java index ce715ba..39fd0a2 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyEndpointTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpointTest.java @@ -64,7 +64,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @Timeout(30) -class RequestReplyEndpointTest { +class MessagingRequestReplyEndpointTest { private static final Duration WAIT = Duration.ofSeconds(5); private final List callers = new CopyOnWriteArrayList<>(); @@ -80,7 +80,7 @@ void stopCallers() throws InterruptedException { void subscribesBeforeReturningAndAcceptsAReplyBeforeSendCompletes() throws Exception { var transport = new MemoryTransport(); var acknowledgement = new CompletableFuture(); - try (var endpoint = RequestReplyEndpoint.open(transport)) { + try (var endpoint = MessagingRequestReplyEndpoint.open(transport)) { assertEquals(1, transport.listeners.size()); transport.sender = message -> { assertTrue(transport.listeners.containsKey(message.replyTo())); @@ -90,7 +90,7 @@ void subscribesBeforeReturningAndAcceptsAReplyBeforeSendCompletes() throws Excep assertEquals("immediate reply", endpoint.request("request")); assertFalse(acknowledgement.isDone()); - assertEquals(RequestReplyMessage.Type.REQUEST, transport.next().type()); + assertEquals(MessagingRequestReplyMessage.Type.REQUEST, transport.next().type()); assertTrue(transport.sent.isEmpty()); assertTrue(acknowledgement.completeExceptionally(new IllegalStateException("late send failure"))); @@ -106,15 +106,15 @@ void failsOnAnAsynchronousSendErrorBeforeTheReplyAndReleasesCapacity() throws Ex var acknowledgement = new CompletableFuture(); var cause = new IllegalStateException("send failure"); transport.sender = message -> acknowledgement; - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { Future pending = async(() -> endpoint.request("request")); - RequestReplyMessage request = transport.next(); + MessagingRequestReplyMessage request = transport.next(); acknowledgement.completeExceptionally(cause); - assertSame(cause, failure(RequestReplyException.Reason.SEND_FAILED, pending).getCause()); + assertSame(cause, failure(MessagingRequestReplyException.Reason.SEND_FAILED, pending).getCause()); transport.deliver(request.response("too late")); - transport.deliver(request.failure(RequestReplyMessage.Error.HANDLER_FAILED)); + transport.deliver(request.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)); assertTrue(transport.sent.isEmpty()); transport.sender = message -> { @@ -133,8 +133,8 @@ void convertsSynchronousSendFailuresWithoutRetryingOrLeakingCapacity() throws Ex transport.sender = message -> { throw cause; }; - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { - assertSame(cause, failure(RequestReplyException.Reason.SEND_FAILED, () -> endpoint.request("request")).getCause()); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { + assertSame(cause, failure(MessagingRequestReplyException.Reason.SEND_FAILED, () -> endpoint.request("request")).getCause()); assertEquals("request", transport.next().payload()); assertTrue(transport.sent.isEmpty()); @@ -149,21 +149,21 @@ void convertsSynchronousSendFailuresWithoutRetryingOrLeakingCapacity() throws Ex @Test void preservesTypedAsynchronousSendFailuresAndReleasesCapacityBeforeLateNotifications() throws Exception { - for (RequestReplyException expected : List.of( - new RequestReplyException(RequestReplyException.Reason.OVERLOADED), - new RequestReplyException(RequestReplyException.Reason.DISCONNECTED), - new RequestReplyException(RequestReplyException.Reason.CLOSED), - new RequestReplyException(RequestReplyException.Reason.TIMEOUT), - new RequestReplyException(RequestReplyException.Reason.SEND_FAILED), - new RequestReplyException(RequestReplyMessage.Error.OVERLOADED))) { + for (MessagingRequestReplyException expected : List.of( + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.OVERLOADED), + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED), + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.CLOSED), + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.TIMEOUT), + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED), + new MessagingRequestReplyException(MessagingRequestReplyMessage.Error.OVERLOADED))) { for (Throwable error : List.of(expected, new CompletionException(expected), new ExecutionException(expected), new CompletionException(new ExecutionException(new CompletionException(expected))))) { var transport = new MemoryTransport(); var acknowledgement = new CompletableFuture(); transport.sender = message -> acknowledgement; - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { Future pending = async(() -> endpoint.request("request")); - final RequestReplyMessage request = transport.next(); + final MessagingRequestReplyMessage request = transport.next(); acknowledgement.completeExceptionally(error); @@ -184,18 +184,18 @@ void preservesTypedAsynchronousSendFailuresAndReleasesCapacityBeforeLateNotifica @Test void preservesTypedSynchronousSendFailuresWrappedByCompletionStages() throws Exception { - for (RequestReplyException expected : List.of( - new RequestReplyException(RequestReplyException.Reason.OVERLOADED), - new RequestReplyException(RequestReplyException.Reason.DISCONNECTED), - new RequestReplyException(RequestReplyException.Reason.CLOSED), - new RequestReplyException(RequestReplyMessage.Error.HANDLER_FAILED))) { + for (MessagingRequestReplyException expected : List.of( + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.OVERLOADED), + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED), + new MessagingRequestReplyException(MessagingRequestReplyException.Reason.CLOSED), + new MessagingRequestReplyException(MessagingRequestReplyMessage.Error.HANDLER_FAILED))) { for (CompletionException error : List.of(new CompletionException(expected), new CompletionException(new ExecutionException(expected)))) { var transport = new MemoryTransport(); transport.sender = message -> { throw error; }; - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { assertSame(expected, failure(expected.reason(), () -> endpoint.request("first"))); assertSame(expected, failure(expected.reason(), () -> endpoint.request("second"))); assertEquals(2, transport.sent.size()); @@ -209,7 +209,7 @@ void keepsUntypedAndUnrelatedWrappedSendErrorsAsSendFailures() throws Exception var cause = new IllegalStateException("untyped send failure"); for (RuntimeException error : List.of(cause, new CompletionException(cause), new CompletionException(new ExecutionException(cause)), new CompletionException((Throwable) null), - new IllegalStateException(new RequestReplyException(RequestReplyException.Reason.DISCONNECTED)))) { + new IllegalStateException(new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED)))) { for (boolean asynchronous : List.of(false, true)) { var transport = new MemoryTransport(); transport.sender = message -> { @@ -218,8 +218,9 @@ void keepsUntypedAndUnrelatedWrappedSendErrorsAsSendFailures() throws Exception } throw error; }; - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { - assertSame(error, failure(RequestReplyException.Reason.SEND_FAILED, () -> endpoint.request("request")).getCause()); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { + assertSame(error, + failure(MessagingRequestReplyException.Reason.SEND_FAILED, () -> endpoint.request("request")).getCause()); assertEquals(1, transport.sent.size()); } } @@ -229,25 +230,25 @@ void keepsUntypedAndUnrelatedWrappedSendErrorsAsSendFailures() throws Exception @Test void correlatesConcurrentResponsesDeliveredInReverseOrder() throws Exception { var transport = new MemoryTransport(); - try (var endpoint = RequestReplyEndpoint.open(transport, options(8, 1))) { + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(8, 1))) { Map> results = new HashMap<>(); for (int index = 0; index < 8; index++) { String payload = "request-" + index; results.put(payload, async(() -> endpoint.request(payload))); } - List> requests = new ArrayList<>(); + List> requests = new ArrayList<>(); Set requestIds = new HashSet<>(); Set replyTargets = new HashSet<>(); for (int index = 0; index < results.size(); index++) { - RequestReplyMessage request = transport.next(); + MessagingRequestReplyMessage request = transport.next(); requests.add(request); - assertEquals(RequestReplyMessage.Type.REQUEST, request.type()); + assertEquals(MessagingRequestReplyMessage.Type.REQUEST, request.type()); assertTrue(requestIds.add(request.requestId())); replyTargets.add(request.replyTo()); } assertEquals(1, replyTargets.size()); for (int index = requests.size() - 1; index >= 0; index--) { - RequestReplyMessage request = requests.get(index); + MessagingRequestReplyMessage request = requests.get(index); transport.deliver(request.response("reply:" + request.payload())); } @@ -263,7 +264,7 @@ void invokesTheHandlerInlineOnTheReceivingThreadAndReturnsANullResult() throws E var transport = new MemoryTransport(); var handlerThread = new AtomicReference(); var handled = new AtomicReference(); - try (var endpoint = RequestReplyEndpoint.open(transport); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport); var registration = endpoint.handle(payload -> { handlerThread.set(Thread.currentThread()); handled.set(payload); @@ -283,23 +284,25 @@ void reportsMissingAndFailingHandlersWithoutExposingHandlerDetails() throws Exce var clientTransport = new MemoryTransport(); var serverTransport = new MemoryTransport(); connect(clientTransport, serverTransport); - try (var client = RequestReplyEndpoint.open(clientTransport); - var server = RequestReplyEndpoint.open(serverTransport)) { - RequestReplyException missing = failure(RequestReplyException.Reason.REMOTE_ERROR, () -> client.request("missing")); - assertEquals(RequestReplyMessage.Error.NO_HANDLER, missing.remoteError()); + try (var client = MessagingRequestReplyEndpoint.open(clientTransport); + var server = MessagingRequestReplyEndpoint.open(serverTransport)) { + MessagingRequestReplyException missing = failure(MessagingRequestReplyException.Reason.REMOTE_ERROR, + () -> client.request("missing")); + assertEquals(MessagingRequestReplyMessage.Error.NO_HANDLER, missing.remoteError()); assertNull(missing.getCause()); - assertEquals(clientTransport.next().failure(RequestReplyMessage.Error.NO_HANDLER), serverTransport.next()); + assertEquals(clientTransport.next().failure(MessagingRequestReplyMessage.Error.NO_HANDLER), serverTransport.next()); try (var registration = server.handle(payload -> { throw new IllegalStateException("private handler details: " + payload); })) { - RequestReplyException failed = failure(RequestReplyException.Reason.REMOTE_ERROR, () -> client.request("secret")); + MessagingRequestReplyException failed = failure(MessagingRequestReplyException.Reason.REMOTE_ERROR, + () -> client.request("secret")); - assertEquals(RequestReplyMessage.Error.HANDLER_FAILED, failed.remoteError()); + assertEquals(MessagingRequestReplyMessage.Error.HANDLER_FAILED, failed.remoteError()); assertNull(failed.getCause()); assertFalse(failed.toString().contains("private")); assertFalse(failed.toString().contains("secret")); - assertEquals(clientTransport.next().failure(RequestReplyMessage.Error.HANDLER_FAILED), serverTransport.next()); + assertEquals(clientTransport.next().failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED), serverTransport.next()); } } } @@ -307,7 +310,7 @@ void reportsMissingAndFailingHandlersWithoutExposingHandlerDetails() throws Exce @Test void releasesHandlerCapacityWhenReplySendingFails() throws Exception { var transport = new MemoryTransport(); - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1)); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1)); var registration = endpoint.handle(payload -> "reply:" + payload)) { for (boolean asynchronous : List.of(false, true)) { transport.sender = message -> { @@ -338,14 +341,14 @@ void mapsTransportExecutionRejectionToOverloadAndReleasesAdmission() throws Exce rejected.set(request); throw new RejectedExecutionException("Request execution unavailable"); }; - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1)); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1)); var registration = endpoint.handle(payload -> { calls.incrementAndGet(); return "reply:" + payload; })) { var request = request("rejected"); assertDoesNotThrow(() -> transport.deliver(request)); - assertEquals(request.failure(RequestReplyMessage.Error.OVERLOADED), transport.next()); + assertEquals(request.failure(MessagingRequestReplyMessage.Error.OVERLOADED), transport.next()); assertEquals(0, calls.get()); rejected.get().run(); assertEquals(0, calls.get()); @@ -365,7 +368,7 @@ void countsQueuedRequestsAndCancelsThemBeforeExecutionOnDisconnectOrClose() thro var queued = new LinkedBlockingQueue(); var calls = new AtomicInteger(); transport.requestExecutor = queued::add; - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1)); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1)); var registration = endpoint.handle(payload -> { calls.incrementAndGet(); return "reply:" + payload; @@ -376,7 +379,7 @@ void countsQueuedRequestsAndCancelsThemBeforeExecutionOnDisconnectOrClose() thro assertEquals(1, queued.size()); var excess = request("excess"); transport.deliver(excess); - assertEquals(excess.failure(RequestReplyMessage.Error.OVERLOADED), transport.next()); + assertEquals(excess.failure(MessagingRequestReplyMessage.Error.OVERLOADED), transport.next()); assertEquals(0, calls.get()); if (closing) { @@ -402,27 +405,27 @@ void countsQueuedRequestsAndCancelsThemBeforeExecutionOnDisconnectOrClose() thro @Test void isolatesReplyTargetsAndIgnoresUnknownAndDuplicateResponses() throws Exception { var transport = new MemoryTransport(); - try (var first = RequestReplyEndpoint.open(transport); - var second = RequestReplyEndpoint.open(transport)) { + try (var first = MessagingRequestReplyEndpoint.open(transport); + var second = MessagingRequestReplyEndpoint.open(transport)) { final Future firstResult = async(() -> first.request("first")); - RequestReplyMessage firstRequest = transport.next(); + MessagingRequestReplyMessage firstRequest = transport.next(); final Future secondResult = async(() -> second.request("second")); - RequestReplyMessage secondRequest = transport.next(); + MessagingRequestReplyMessage secondRequest = transport.next(); assertNotEquals(firstRequest.replyTo(), secondRequest.replyTo()); - transport.deliver(new RequestReplyMessage<>(firstRequest.requestId(), secondRequest.replyTo(), - RequestReplyMessage.Type.RESPONSE, "wrong endpoint", null)); - transport.deliver(new RequestReplyMessage<>(secondRequest.requestId(), firstRequest.replyTo(), - RequestReplyMessage.Type.ERROR, null, RequestReplyMessage.Error.HANDLER_FAILED)); - var unknown = RequestReplyMessage.request(UUID.randomUUID(), firstRequest.replyTo(), "unknown"); + transport.deliver(new MessagingRequestReplyMessage<>(firstRequest.requestId(), secondRequest.replyTo(), + MessagingRequestReplyMessage.Type.RESPONSE, "wrong endpoint", null)); + transport.deliver(new MessagingRequestReplyMessage<>(secondRequest.requestId(), firstRequest.replyTo(), + MessagingRequestReplyMessage.Type.ERROR, null, MessagingRequestReplyMessage.Error.HANDLER_FAILED)); + var unknown = MessagingRequestReplyMessage.request(UUID.randomUUID(), firstRequest.replyTo(), "unknown"); transport.deliver(unknown.response("unknown response")); - transport.deliver(unknown.failure(RequestReplyMessage.Error.NO_HANDLER)); + transport.deliver(unknown.failure(MessagingRequestReplyMessage.Error.NO_HANDLER)); assertFalse(firstResult.isDone()); assertFalse(secondResult.isDone()); transport.deliver(firstRequest.response("first reply")); transport.deliver(firstRequest.response("duplicate")); - transport.deliver(firstRequest.failure(RequestReplyMessage.Error.HANDLER_FAILED)); + transport.deliver(firstRequest.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)); assertEquals("first reply", result(firstResult)); assertFalse(secondResult.isDone()); transport.deliver(secondRequest.response("second reply")); @@ -434,16 +437,16 @@ void isolatesReplyTargetsAndIgnoresUnknownAndDuplicateResponses() throws Excepti @Test void usesTheDefaultTimeoutAndReleasesCapacityBeforeIgnoringLateReplies() throws Exception { var transport = new MemoryTransport(); - var options = new RequestReplyOptions(Duration.ofMillis(100), 1, 1); - try (var endpoint = RequestReplyEndpoint.open(transport, options)) { - failure(RequestReplyException.Reason.TIMEOUT, () -> endpoint.request("timed out")); - RequestReplyMessage expired = transport.next(); + var options = new MessagingRequestReplyOptions(Duration.ofMillis(100), 1, 1); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options)) { + failure(MessagingRequestReplyException.Reason.TIMEOUT, () -> endpoint.request("timed out")); + MessagingRequestReplyMessage expired = transport.next(); assertTrue(transport.sent.isEmpty()); Future next = async(() -> endpoint.request("next", WAIT)); - final RequestReplyMessage request = transport.next(); + final MessagingRequestReplyMessage request = transport.next(); transport.deliver(expired.response("late response")); - transport.deliver(expired.failure(RequestReplyMessage.Error.HANDLER_FAILED)); + transport.deliver(expired.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)); assertFalse(next.isDone()); transport.deliver(request.response("next reply")); @@ -460,8 +463,8 @@ void timesOutWhileAnIndependentReceivingThreadIsStillHandlingTheRequest() throws var release = new CountDownLatch(1); var handlerThread = new AtomicReference(); connect(clientTransport, serverTransport); - try (var client = RequestReplyEndpoint.open(clientTransport, options(1, 1)); - var server = RequestReplyEndpoint.open(serverTransport, options(1, 1)); + try (var client = MessagingRequestReplyEndpoint.open(clientTransport, options(1, 1)); + var server = MessagingRequestReplyEndpoint.open(serverTransport, options(1, 1)); var registration = server.handle(payload -> { handlerThread.set(Thread.currentThread()); entered.countDown(); @@ -471,7 +474,7 @@ void timesOutWhileAnIndependentReceivingThreadIsStillHandlingTheRequest() throws try { Future expired = async(() -> client.request("expired", Duration.ofMillis(100))); await(entered); - failure(RequestReplyException.Reason.TIMEOUT, expired); + failure(MessagingRequestReplyException.Reason.TIMEOUT, expired); assertTrue(handlerThread.get().isAlive()); release.countDown(); join(handlerThread.get()); @@ -488,15 +491,16 @@ void timesOutWhileAnIndependentReceivingThreadIsStillHandlingTheRequest() throws void rejectsRepliesAtTheDeadlineEvenWhenTheyArriveBeforeSendReturns() throws Exception { var transport = new MemoryTransport(); var clock = new AtomicLong(); - try (var endpoint = new RequestReplyEndpoint<>(transport, options(1, 1), clock::get)) { + try (var endpoint = new MessagingRequestReplyEndpoint<>(transport, options(1, 1), clock::get)) { for (boolean error : List.of(false, true)) { transport.sender = message -> { clock.addAndGet(WAIT.toNanos()); - transport.deliver(error ? message.failure(RequestReplyMessage.Error.HANDLER_FAILED) : message.response("late")); + transport.deliver(error + ? message.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED) : message.response("late")); return CompletableFuture.completedFuture(null); }; - failure(RequestReplyException.Reason.TIMEOUT, () -> endpoint.request("expired")); + failure(MessagingRequestReplyException.Reason.TIMEOUT, () -> endpoint.request("expired")); assertEquals("expired", transport.next().payload()); assertTrue(transport.sent.isEmpty()); @@ -512,7 +516,7 @@ void rejectsRepliesAtTheDeadlineEvenWhenTheyArriveBeforeSendReturns() throws Exc @Test void doesNotSendOrConsumeCapacityWhenTheCallerIsAlreadyInterrupted() throws Exception { var transport = new MemoryTransport(); - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { Future pending = async(() -> { Thread.currentThread().interrupt(); return endpoint.request("already interrupted"); @@ -532,10 +536,10 @@ void doesNotSendOrConsumeCapacityWhenTheCallerIsAlreadyInterrupted() throws Exce @Test void propagatesInterruptionAndReleasesThePendingSlotWithoutSendingCancellation() throws Exception { var transport = new MemoryTransport(); - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { var pending = new FutureTask<>(() -> endpoint.request("interrupted")); Thread caller = start(pending); - final RequestReplyMessage interrupted = transport.next(); + final MessagingRequestReplyMessage interrupted = transport.next(); caller.interrupt(); @@ -544,7 +548,7 @@ void propagatesInterruptionAndReleasesThePendingSlotWithoutSendingCancellation() join(caller); assertTrue(transport.sent.isEmpty()); Future next = async(() -> endpoint.request("next")); - RequestReplyMessage request = transport.next(); + MessagingRequestReplyMessage request = transport.next(); transport.deliver(interrupted.response("too late")); assertFalse(next.isDone()); transport.deliver(request.response("next reply")); @@ -555,19 +559,19 @@ void propagatesInterruptionAndReleasesThePendingSlotWithoutSendingCancellation() @Test void rejectsExcessPendingRequestsBeforeSendingAndReusesReleasedSlots() throws Exception { var transport = new MemoryTransport(); - try (var endpoint = RequestReplyEndpoint.open(transport, options(2, 1))) { + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(2, 1))) { final Future first = async(() -> endpoint.request("first")); - final RequestReplyMessage firstRequest = transport.next(); + final MessagingRequestReplyMessage firstRequest = transport.next(); final Future second = async(() -> endpoint.request("second")); - RequestReplyMessage secondRequest = transport.next(); + MessagingRequestReplyMessage secondRequest = transport.next(); - failure(RequestReplyException.Reason.OVERLOADED, () -> endpoint.request("excess")); + failure(MessagingRequestReplyException.Reason.OVERLOADED, () -> endpoint.request("excess")); assertTrue(transport.sent.isEmpty()); transport.deliver(secondRequest.response("second reply")); assertEquals("second reply", result(second)); final Future third = async(() -> endpoint.request("third")); - RequestReplyMessage thirdRequest = transport.next(); + MessagingRequestReplyMessage thirdRequest = transport.next(); assertFalse(first.isDone()); transport.deliver(thirdRequest.response("third reply")); transport.deliver(firstRequest.response("first reply")); @@ -584,7 +588,7 @@ void rejectsExcessHandlersWithoutQueueingAndReusesTheSlotAfterExit() throws Exce var release = new CountDownLatch(1); var calls = new AtomicInteger(); var handlerThread = new AtomicReference(); - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1)); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1)); var registration = endpoint.handle(payload -> { calls.incrementAndGet(); handlerThread.set(Thread.currentThread()); @@ -600,7 +604,7 @@ void rejectsExcessHandlersWithoutQueueingAndReusesTheSlotAfterExit() throws Exce var excess = request("excess"); transport.deliver(excess); - assertEquals(excess.failure(RequestReplyMessage.Error.OVERLOADED), transport.next()); + assertEquals(excess.failure(MessagingRequestReplyMessage.Error.OVERLOADED), transport.next()); transport.deliver(first); assertEquals(1, calls.get()); assertTrue(transport.sent.isEmpty()); @@ -627,7 +631,7 @@ void treatsIncomingRequestIdentityAsThePairOfOriginAndRequestId() throws Excepti var release = new CountDownLatch(1); var calls = new AtomicInteger(); var handlerThreads = new ConcurrentHashMap(); - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 2)); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 2)); var registration = endpoint.handle(payload -> { calls.incrementAndGet(); handlerThreads.put(payload, Thread.currentThread()); @@ -637,8 +641,8 @@ void treatsIncomingRequestIdentityAsThePairOfOriginAndRequestId() throws Excepti })) { try { UUID requestId = UUID.randomUUID(); - var first = RequestReplyMessage.request(requestId, UUID.randomUUID(), "first origin"); - var second = RequestReplyMessage.request(requestId, UUID.randomUUID(), "second origin"); + var first = MessagingRequestReplyMessage.request(requestId, UUID.randomUUID(), "first origin"); + var second = MessagingRequestReplyMessage.request(requestId, UUID.randomUUID(), "second origin"); Thread firstDelivery = start(() -> transport.deliver(first)); Thread secondDelivery = start(() -> transport.deliver(second)); await(entered); @@ -669,7 +673,7 @@ void closingAnOldRegistrationCannotRemoveItsReplacementOrCancelAcceptedWork() th var transport = new MemoryTransport(); var entered = new CountDownLatch(1); var release = new CountDownLatch(1); - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 2))) { + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 2))) { try (var original = endpoint.handle(payload -> { entered.countDown(); await(release); @@ -694,7 +698,7 @@ void closingAnOldRegistrationCannotRemoveItsReplacementOrCancelAcceptedWork() th } var unhandled = request("unhandled"); transport.deliver(unhandled); - assertEquals(unhandled.failure(RequestReplyMessage.Error.NO_HANDLER), transport.next()); + assertEquals(unhandled.failure(MessagingRequestReplyMessage.Error.NO_HANDLER), transport.next()); } finally { release.countDown(); } @@ -705,8 +709,8 @@ void closingAnOldRegistrationCannotRemoveItsReplacementOrCancelAcceptedWork() th @Test void closingFailsPendingWorkCancelsHandlersAndOnlyClosesItsOwnSubscription() throws Exception { try (var transport = new MemoryTransport(); - var endpoint = RequestReplyEndpoint.open(transport); - var other = RequestReplyEndpoint.open(transport)) { + var endpoint = MessagingRequestReplyEndpoint.open(transport); + var other = MessagingRequestReplyEndpoint.open(transport)) { var entered = new CountDownLatch(1); var interrupted = new CountDownLatch(1); var release = new CountDownLatch(1); @@ -723,22 +727,22 @@ void closingFailsPendingWorkCancelsHandlersAndOnlyClosesItsOwnSubscription() thr })) { try { final Future pending = async(() -> endpoint.request("pending")); - RequestReplyMessage outgoing = transport.next(); - RequestReplyTransport.Listener oldListener = transport.listeners.get(outgoing.replyTo()); + MessagingRequestReplyMessage outgoing = transport.next(); + MessagingRequestReplyTransport.Listener oldListener = transport.listeners.get(outgoing.replyTo()); assertNotNull(oldListener); start(() -> oldListener.onMessage(request("incoming"))); await(entered); endpoint.close(); - failure(RequestReplyException.Reason.CLOSED, pending); + failure(MessagingRequestReplyException.Reason.CLOSED, pending); await(interrupted); join(handlerThread.get()); assertEquals(1, transport.subscriptionCloses.get()); assertEquals(1, transport.listeners.size()); assertFalse(transport.closed); assertFalse(transport.listeners.containsKey(outgoing.replyTo())); - failure(RequestReplyException.Reason.CLOSED, () -> endpoint.request("closed")); + failure(MessagingRequestReplyException.Reason.CLOSED, () -> endpoint.request("closed")); assertThrows(IllegalStateException.class, () -> endpoint.handle(payload -> payload)); oldListener.onMessage(request("after close")); oldListener.onMessage(outgoing.response("late reply")); @@ -748,7 +752,7 @@ void closingFailsPendingWorkCancelsHandlersAndOnlyClosesItsOwnSubscription() thr assertTrue(transport.sent.isEmpty()); Future surviving = async(() -> other.request("surviving endpoint")); - RequestReplyMessage request = transport.next(); + MessagingRequestReplyMessage request = transport.next(); transport.deliver(request.response("still open")); assertEquals("still open", result(surviving)); } finally { @@ -761,23 +765,23 @@ void closingFailsPendingWorkCancelsHandlersAndOnlyClosesItsOwnSubscription() thr @Test void disconnectFailsAllPendingRequestsAndAllowsFreshWorkWithoutReplayingOldRequests() throws Exception { var transport = new MemoryTransport(); - try (var endpoint = RequestReplyEndpoint.open(transport, options(2, 1))) { + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(2, 1))) { Future first = async(() -> endpoint.request("first")); - final RequestReplyMessage firstRequest = transport.next(); + final MessagingRequestReplyMessage firstRequest = transport.next(); Future second = async(() -> endpoint.request("second")); - final RequestReplyMessage secondRequest = transport.next(); + final MessagingRequestReplyMessage secondRequest = transport.next(); transport.disconnect(); - failure(RequestReplyException.Reason.DISCONNECTED, first); - failure(RequestReplyException.Reason.DISCONNECTED, second); + failure(MessagingRequestReplyException.Reason.DISCONNECTED, first); + failure(MessagingRequestReplyException.Reason.DISCONNECTED, second); assertTrue(transport.sent.isEmpty()); final Future fresh = async(() -> endpoint.request("fresh")); - RequestReplyMessage freshRequest = transport.next(); + MessagingRequestReplyMessage freshRequest = transport.next(); assertEquals("fresh", freshRequest.payload()); assertEquals(firstRequest.replyTo(), freshRequest.replyTo()); transport.deliver(firstRequest.response("old first reply")); - transport.deliver(secondRequest.failure(RequestReplyMessage.Error.HANDLER_FAILED)); + transport.deliver(secondRequest.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)); assertFalse(fresh.isDone()); transport.deliver(freshRequest.response("fresh reply")); @@ -800,7 +804,7 @@ void disconnectKeepsInterruptIgnoringHandlersCountedAndInvalidatesTheirReplies() var release = new CountDownLatch(1); var calls = new AtomicInteger(); var oldHandler = new AtomicReference(); - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1)); + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1)); var registration = endpoint.handle(payload -> { calls.incrementAndGet(); if ("old".equals(payload)) { @@ -812,20 +816,20 @@ void disconnectKeepsInterruptIgnoringHandlersCountedAndInvalidatesTheirReplies() })) { try { Future pending = async(() -> endpoint.request("old")); - final RequestReplyMessage oldRequest = transport.next(); + final MessagingRequestReplyMessage oldRequest = transport.next(); await(entered); transport.disconnect(); - failure(RequestReplyException.Reason.DISCONNECTED, pending); + failure(MessagingRequestReplyException.Reason.DISCONNECTED, pending); await(interrupted); for (int index = 0; index < 4; index++) { transport.disconnect(); - RequestReplyException failure = failure(RequestReplyException.Reason.REMOTE_ERROR, + MessagingRequestReplyException failure = failure(MessagingRequestReplyException.Reason.REMOTE_ERROR, () -> endpoint.request("excess")); - assertEquals(RequestReplyMessage.Error.OVERLOADED, failure.remoteError()); - RequestReplyMessage excess = transport.next(); - assertEquals(excess.failure(RequestReplyMessage.Error.OVERLOADED), transport.next()); + assertEquals(MessagingRequestReplyMessage.Error.OVERLOADED, failure.remoteError()); + MessagingRequestReplyMessage excess = transport.next(); + assertEquals(excess.failure(MessagingRequestReplyMessage.Error.OVERLOADED), transport.next()); assertEquals(1, calls.get()); assertTrue(oldHandler.get().isAlive()); } @@ -835,7 +839,7 @@ void disconnectKeepsInterruptIgnoringHandlersCountedAndInvalidatesTheirReplies() assertTrue(transport.sent.isEmpty()); transport.deliver(oldRequest.response("late reply")); assertEquals("reply:fresh", endpoint.request("fresh")); - RequestReplyMessage fresh = transport.next(); + MessagingRequestReplyMessage fresh = transport.next(); assertEquals(fresh.response("reply:fresh"), transport.next()); assertEquals(2, calls.get()); assertEquals(1, transport.listeners.size()); @@ -848,7 +852,7 @@ void disconnectKeepsInterruptIgnoringHandlersCountedAndInvalidatesTheirReplies() @Test void rejectsInvalidRequestArgumentsBeforeSendingOrConsumingCapacity() throws Exception { var transport = new MemoryTransport(); - try (var endpoint = RequestReplyEndpoint.open(transport, options(1, 1))) { + try (var endpoint = MessagingRequestReplyEndpoint.open(transport, options(1, 1))) { assertThrows(NullPointerException.class, () -> endpoint.request(null)); assertThrows(NullPointerException.class, () -> endpoint.request("request", null)); assertThrows(NullPointerException.class, () -> endpoint.handle(null)); @@ -866,35 +870,35 @@ void rejectsInvalidRequestArgumentsBeforeSendingOrConsumingCapacity() throws Exc @Test void propagatesSubscriptionFailureBeforePublishingAnEndpoint() { - var expected = new RequestReplyException(RequestReplyException.Reason.DISCONNECTED); - var transport = new RequestReplyTransport() { + var expected = new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED); + var transport = new MessagingRequestReplyTransport() { @NotNull @Override - public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener listener) - throws RequestReplyException { + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener listener) + throws MessagingRequestReplyException { throw expected; } @NotNull @Override - public CompletionStage send(@NotNull RequestReplyMessage message) { + public CompletionStage send(@NotNull MessagingRequestReplyMessage message) { throw new AssertionError("Subscription failure must prevent sending"); } }; - assertSame(expected, assertThrows(RequestReplyException.class, () -> RequestReplyEndpoint.open(transport))); - assertThrows(NullPointerException.class, () -> RequestReplyEndpoint.open(null)); - assertThrows(NullPointerException.class, () -> RequestReplyEndpoint.open(new MemoryTransport(), null)); + assertSame(expected, assertThrows(MessagingRequestReplyException.class, () -> MessagingRequestReplyEndpoint.open(transport))); + assertThrows(NullPointerException.class, () -> MessagingRequestReplyEndpoint.open(null)); + assertThrows(NullPointerException.class, () -> MessagingRequestReplyEndpoint.open(new MemoryTransport(), null)); } @NotNull - private static RequestReplyOptions options(int pending, int handlers) { - return new RequestReplyOptions(WAIT, pending, handlers); + private static MessagingRequestReplyOptions options(int pending, int handlers) { + return new MessagingRequestReplyOptions(WAIT, pending, handlers); } @NotNull - private static RequestReplyMessage request(@NotNull String payload) { - return RequestReplyMessage.request(UUID.randomUUID(), UUID.randomUUID(), payload); + private static MessagingRequestReplyMessage request(@NotNull String payload) { + return MessagingRequestReplyMessage.request(UUID.randomUUID(), UUID.randomUUID(), payload); } @NotNull @@ -917,16 +921,22 @@ private static T result(@NotNull Future future) throws InterruptedExcepti } @NotNull - private static RequestReplyException failure(@NotNull RequestReplyException.Reason reason, @NotNull Executable action) { - RequestReplyException failure = assertThrows(RequestReplyException.class, action); + private static MessagingRequestReplyException failure( + @NotNull MessagingRequestReplyException.Reason reason, + @NotNull Executable action + ) { + MessagingRequestReplyException failure = assertThrows(MessagingRequestReplyException.class, action); assertEquals(reason, failure.reason()); return failure; } @NotNull - private static RequestReplyException failure(@NotNull RequestReplyException.Reason reason, @NotNull Future future) { + private static MessagingRequestReplyException failure( + @NotNull MessagingRequestReplyException.Reason reason, + @NotNull Future future + ) { ExecutionException wrapper = assertThrows(ExecutionException.class, () -> result(future)); - RequestReplyException failure = assertInstanceOf(RequestReplyException.class, wrapper.getCause()); + MessagingRequestReplyException failure = assertInstanceOf(MessagingRequestReplyException.class, wrapper.getCause()); assertEquals(reason, failure.reason()); return failure; } @@ -963,11 +973,11 @@ private void connect(@NotNull MemoryTransport first, @NotNull MemoryTransport se }; } - private static final class MemoryTransport implements RequestReplyTransport, AutoCloseable { + private static final class MemoryTransport implements MessagingRequestReplyTransport, AutoCloseable { private final Map> listeners = new ConcurrentHashMap<>(); - private final BlockingQueue> sent = new LinkedBlockingQueue<>(); + private final BlockingQueue> sent = new LinkedBlockingQueue<>(); private final AtomicInteger subscriptionCloses = new AtomicInteger(); - private volatile Function, CompletionStage> sender = + private volatile Function, CompletionStage> sender = message -> CompletableFuture.completedFuture(null); @Nullable private Executor requestExecutor; @@ -975,7 +985,7 @@ private static final class MemoryTransport implements RequestReplyTransport listener) { + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener listener) { assertFalse(closed); assertNull(listeners.putIfAbsent(endpointId, listener)); return () -> { @@ -987,7 +997,7 @@ public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener @NotNull @Override - public CompletionStage send(@NotNull RequestReplyMessage message) { + public CompletionStage send(@NotNull MessagingRequestReplyMessage message) { assertFalse(closed); sent.add(message); return sender.apply(message); @@ -996,13 +1006,13 @@ public CompletionStage send(@NotNull RequestReplyMessage message) @Override public void executeRequest(@NotNull Runnable request) { if (requestExecutor == null) { - RequestReplyTransport.super.executeRequest(request); + MessagingRequestReplyTransport.super.executeRequest(request); } else { requestExecutor.execute(request); } } - private void deliver(@NotNull RequestReplyMessage message) { + private void deliver(@NotNull MessagingRequestReplyMessage message) { List.copyOf(listeners.values()).forEach(listener -> listener.onMessage(message)); } @@ -1011,8 +1021,8 @@ private void disconnect() { } @NotNull - private RequestReplyMessage next() throws InterruptedException { - RequestReplyMessage message = sent.poll(WAIT.toMillis(), TimeUnit.MILLISECONDS); + private MessagingRequestReplyMessage next() throws InterruptedException { + MessagingRequestReplyMessage message = sent.poll(WAIT.toMillis(), TimeUnit.MILLISECONDS); assertNotNull(message, "No message was sent"); return message; } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessageTest.java similarity index 53% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessageTest.java index 3ef5d16..064d93d 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyMessageTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessageTest.java @@ -28,27 +28,27 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @Timeout(10) -class RequestReplyMessageTest { +class MessagingRequestReplyMessageTest { private static final UUID REQUEST_ID = UUID.randomUUID(); private static final UUID ENDPOINT_ID = UUID.randomUUID(); @Test void preservesTheOpaquePayloadAndCorrelationWhenCreatingReplies() { Object payload = new Object(); - RequestReplyMessage request = RequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, payload); + MessagingRequestReplyMessage request = MessagingRequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, payload); assertEquals(REQUEST_ID, request.requestId()); assertEquals(ENDPOINT_ID, request.replyTo()); - assertEquals(RequestReplyMessage.Type.REQUEST, request.type()); + assertEquals(MessagingRequestReplyMessage.Type.REQUEST, request.type()); assertSame(payload, request.payload()); assertNull(request.error()); Object result = new Object(); - RequestReplyMessage response = request.response(result); + MessagingRequestReplyMessage response = request.response(result); assertEquals(REQUEST_ID, response.requestId()); assertEquals(ENDPOINT_ID, response.replyTo()); - assertEquals(RequestReplyMessage.Type.RESPONSE, response.type()); + assertEquals(MessagingRequestReplyMessage.Type.RESPONSE, response.type()); assertSame(result, response.payload()); assertNull(response.error()); assertSame(payload, request.payload()); @@ -56,24 +56,25 @@ void preservesTheOpaquePayloadAndCorrelationWhenCreatingReplies() { @Test void permitsANullResponseWithoutTurningItIntoAnError() { - RequestReplyMessage response = RequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, "request").response(null); + MessagingRequestReplyMessage response = + MessagingRequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, "request").response(null); assertEquals(REQUEST_ID, response.requestId()); assertEquals(ENDPOINT_ID, response.replyTo()); - assertEquals(RequestReplyMessage.Type.RESPONSE, response.type()); + assertEquals(MessagingRequestReplyMessage.Type.RESPONSE, response.type()); assertNull(response.payload()); assertNull(response.error()); } @Test void exposesOnlyTheErrorCodeAndOriginalCorrelationInFailures() { - var request = RequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, "private request"); - for (RequestReplyMessage.Error error : RequestReplyMessage.Error.values()) { - RequestReplyMessage failure = request.failure(error); + var request = MessagingRequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, "private request"); + for (MessagingRequestReplyMessage.Error error : MessagingRequestReplyMessage.Error.values()) { + MessagingRequestReplyMessage failure = request.failure(error); assertEquals(REQUEST_ID, failure.requestId()); assertEquals(ENDPOINT_ID, failure.replyTo()); - assertEquals(RequestReplyMessage.Type.ERROR, failure.type()); + assertEquals(MessagingRequestReplyMessage.Type.ERROR, failure.type()); assertEquals(error, failure.error()); assertNull(failure.payload()); } @@ -82,43 +83,46 @@ void exposesOnlyTheErrorCodeAndOriginalCorrelationInFailures() { @Test void rejectsMissingCorrelationAndType() { assertThrows(NullPointerException.class, - () -> new RequestReplyMessage<>(null, ENDPOINT_ID, RequestReplyMessage.Type.RESPONSE, null, null)); + () -> new MessagingRequestReplyMessage<>(null, ENDPOINT_ID, MessagingRequestReplyMessage.Type.RESPONSE, null, null)); assertThrows(NullPointerException.class, - () -> new RequestReplyMessage<>(REQUEST_ID, null, RequestReplyMessage.Type.RESPONSE, null, null)); + () -> new MessagingRequestReplyMessage<>(REQUEST_ID, null, MessagingRequestReplyMessage.Type.RESPONSE, null, null)); assertThrows(NullPointerException.class, - () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, null, null, null)); - assertThrows(NullPointerException.class, () -> RequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, null)); + () -> new MessagingRequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, null, null, null)); + assertThrows(NullPointerException.class, () -> MessagingRequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, null)); } @Test void rejectsMalformedPayloadAndErrorCombinations() { assertThrows(IllegalArgumentException.class, - () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, RequestReplyMessage.Type.REQUEST, null, null)); + () -> new MessagingRequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, MessagingRequestReplyMessage.Type.REQUEST, null, null)); assertThrows(IllegalArgumentException.class, - () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, RequestReplyMessage.Type.ERROR, null, null)); - for (RequestReplyMessage.Error error : RequestReplyMessage.Error.values()) { + () -> new MessagingRequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, MessagingRequestReplyMessage.Type.ERROR, null, null)); + for (MessagingRequestReplyMessage.Error error : MessagingRequestReplyMessage.Error.values()) { assertThrows(IllegalArgumentException.class, - () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, RequestReplyMessage.Type.REQUEST, "request", error)); + () -> new MessagingRequestReplyMessage<>( + REQUEST_ID, ENDPOINT_ID, MessagingRequestReplyMessage.Type.REQUEST, "request", error)); assertThrows(IllegalArgumentException.class, - () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, RequestReplyMessage.Type.REQUEST, null, error)); + () -> new MessagingRequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, MessagingRequestReplyMessage.Type.REQUEST, null, error)); assertThrows(IllegalArgumentException.class, - () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, RequestReplyMessage.Type.RESPONSE, "response", error)); + () -> new MessagingRequestReplyMessage<>( + REQUEST_ID, ENDPOINT_ID, MessagingRequestReplyMessage.Type.RESPONSE, "response", error)); assertThrows(IllegalArgumentException.class, - () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, RequestReplyMessage.Type.RESPONSE, null, error)); + () -> new MessagingRequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, MessagingRequestReplyMessage.Type.RESPONSE, null, error)); assertThrows(IllegalArgumentException.class, - () -> new RequestReplyMessage<>(REQUEST_ID, ENDPOINT_ID, RequestReplyMessage.Type.ERROR, "private payload", error)); + () -> new MessagingRequestReplyMessage<>( + REQUEST_ID, ENDPOINT_ID, MessagingRequestReplyMessage.Type.ERROR, "private payload", error)); } } @Test void onlyAllowsRequestsToProduceReplies() { - var request = RequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, "request"); + var request = MessagingRequestReplyMessage.request(REQUEST_ID, ENDPOINT_ID, "request"); assertThrows(NullPointerException.class, () -> request.failure(null)); - for (RequestReplyMessage reply : List.of( - request.response("response"), request.response(null), request.failure(RequestReplyMessage.Error.NO_HANDLER) + for (MessagingRequestReplyMessage reply : List.of( + request.response("response"), request.response(null), request.failure(MessagingRequestReplyMessage.Error.NO_HANDLER) )) { assertThrows(IllegalStateException.class, () -> reply.response("another response")); - assertThrows(IllegalStateException.class, () -> reply.failure(RequestReplyMessage.Error.HANDLER_FAILED)); + assertThrows(IllegalStateException.class, () -> reply.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)); } } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptionsTest.java similarity index 76% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptionsTest.java index 0403d4e..e47d930 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/RequestReplyOptionsTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptionsTest.java @@ -27,21 +27,21 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @Timeout(10) -class RequestReplyOptionsTest { +class MessagingRequestReplyOptionsTest { @Test void acceptsPositiveLimitsAndRepresentableTimeoutBoundaries() { for (Duration timeout : List.of(Duration.ofNanos(1), Duration.ofNanos(Long.MAX_VALUE))) { for (int limit : List.of(1, Integer.MAX_VALUE)) { - var options = new RequestReplyOptions(timeout, limit, limit); + var options = new MessagingRequestReplyOptions(timeout, limit, limit); assertEquals(timeout, options.timeout()); assertEquals(limit, options.maxPendingRequests()); assertEquals(limit, options.maxConcurrentHandlers()); } } - assertTrue(RequestReplyOptions.DEFAULT.timeout().isPositive()); - assertTrue(RequestReplyOptions.DEFAULT.maxPendingRequests() > 0); - assertTrue(RequestReplyOptions.DEFAULT.maxConcurrentHandlers() > 0); + assertTrue(MessagingRequestReplyOptions.DEFAULT.timeout().isPositive()); + assertTrue(MessagingRequestReplyOptions.DEFAULT.maxPendingRequests() > 0); + assertTrue(MessagingRequestReplyOptions.DEFAULT.maxConcurrentHandlers() > 0); } @Test @@ -50,16 +50,16 @@ void rejectsNonPositiveAndOverflowingTimeouts() { Duration.ZERO, Duration.ofNanos(-1), Duration.ofSeconds(-1), Duration.ofSeconds(Long.MAX_VALUE), Duration.ofSeconds(Long.MIN_VALUE) )) { - assertThrows(IllegalArgumentException.class, () -> new RequestReplyOptions(timeout, 1, 1)); + assertThrows(IllegalArgumentException.class, () -> new MessagingRequestReplyOptions(timeout, 1, 1)); } - assertThrows(NullPointerException.class, () -> new RequestReplyOptions(null, 1, 1)); + assertThrows(NullPointerException.class, () -> new MessagingRequestReplyOptions(null, 1, 1)); } @Test void rejectsNonPositivePendingAndHandlerLimitsIndependently() { for (int limit : List.of(0, -1, Integer.MIN_VALUE)) { - assertThrows(IllegalArgumentException.class, () -> new RequestReplyOptions(Duration.ofSeconds(1), limit, 1)); - assertThrows(IllegalArgumentException.class, () -> new RequestReplyOptions(Duration.ofSeconds(1), 1, limit)); + assertThrows(IllegalArgumentException.class, () -> new MessagingRequestReplyOptions(Duration.ofSeconds(1), limit, 1)); + assertThrows(IllegalArgumentException.class, () -> new MessagingRequestReplyOptions(Duration.ofSeconds(1), 1, limit)); } } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java similarity index 92% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java index 21e546d..b1400d6 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcContractTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java @@ -34,12 +34,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @Timeout(10) -public class RpcContractTest extends RpcTestSupport { +public class MessagingRpcContractTest extends MessagingRpcTestSupport { @Test void rejectsInvalidContractsBeforeSendingAndLeavesTheTopicAvailable() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { for (Class type : List.of( String.class, HiddenService.class, SealedService.class, GenericService.class, RawGenericService.class, EmptyService.class, StaticOnlyService.class, ObjectOnlyService.class, @@ -60,7 +60,7 @@ void rejectsInvalidContractsBeforeSendingAndLeavesTheTopicAvailable() throws Exc @Test void rejectsUnresolvedTypeVariablesInParameterizedOwnerTypes() { - assertThrows(IllegalArgumentException.class, () -> RpcContract.create(RawOwnedGenericService.class)); + assertThrows(IllegalArgumentException.class, () -> MessagingRpcContract.create(RawOwnedGenericService.class)); } @Test @@ -70,7 +70,7 @@ void rejectsGenericParentBridgesWhoseErasedArgumentHasNoConcreteOperation() thro assertTrue(bridge.isSynthetic()); var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("bridge", GenericBridgeChild.class, codec)); assertThrows(IllegalArgumentException.class, () -> endpoint.register("bridge", GenericBridgeChild.class, value -> value, codec)); @@ -84,7 +84,7 @@ void rejectsGenericParentBridgesWhoseErasedArgumentHasNoConcreteOperation() thro void rejectsUnrelatedParentsWithAmbiguousGenericParameterOrReturnTypesBeforeSending() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { for (Class type : List.of(AmbiguousListCounter.class, AmbiguousListLoader.class)) { assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("ambiguous", type, codec), type.getName()); assertThrows(IllegalArgumentException.class, () -> endpoint.register("ambiguous", type, null, codec), type.getName()); @@ -102,7 +102,7 @@ void selectsTheUniquelyNarrowerGenericReturnAcrossAllParentDeclarationOrders() t for (Class type : List.of(WideNarrowArrayLoader.class, WideArrayNarrowLoader.class, NarrowWideArrayLoader.class, NarrowArrayWideLoader.class, ArrayWideNarrowLoader.class, ArrayNarrowWideLoader.class)) { - var operation = RpcContract.create(type).operation("load(java.lang.String)"); + var operation = MessagingRpcContract.create(type).operation("load(java.lang.String)"); assertNotNull(operation, type.getName()); assertEquals(ArrayList.class, operation.getReturnType(), type.getName()); @@ -114,15 +114,15 @@ void selectsTheUniquelyNarrowerGenericReturnAcrossAllParentDeclarationOrders() t void explicitChildRedeclarationsResolveGenericTypesForCallsThroughParentAndChildReferences() throws Exception { var count = ResolvedListCounter.class.getDeclaredMethod("count", List.class); var load = ResolvedListLoader.class.getDeclaredMethod("load", String.class); - assertEquals(count, RpcContract.create(ResolvedListCounter.class).operation("count(java.util.List)")); - assertEquals(load, RpcContract.create(ResolvedListLoader.class).operation("load(java.lang.String)")); + assertEquals(count, MessagingRpcContract.create(ResolvedListCounter.class).operation("count(java.util.List)")); + assertEquals(load, MessagingRpcContract.create(ResolvedListLoader.class).operation("load(java.lang.String)")); var clientTransport = new MemoryTransport(); var serverTransport = new MemoryTransport(); var clientCodec = new TestCodec(); var serverCodec = new TestCodec(); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, OPTIONS); - var server = RpcEndpoint.open(serverTransport, OPTIONS); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); var counter = server.register("count", ResolvedListCounter.class, List::size, serverCodec); var loader = server.register("load", ResolvedListLoader.class, List::of, serverCodec)) { ResolvedListCounter childCounter = client.proxy("count", ResolvedListCounter.class, clientCodec); @@ -150,11 +150,11 @@ void explicitChildRedeclarationsResolveGenericTypesForCallsThroughParentAndChild @Test void ignoresStaticMethodsAndKeepsConcreteDeclaredTypes() throws Exception { - RpcContract contract = RpcContract.create(StaticService.class); + MessagingRpcContract contract = MessagingRpcContract.create(StaticService.class); assertEquals(StaticService.class.getMethod("echo", String.class), contract.operation("echo(java.lang.String)")); assertNull(contract.operation("ignored()")); - RpcContract parameterized = RpcContract.create(ParameterizedService.class); + MessagingRpcContract parameterized = MessagingRpcContract.create(ParameterizedService.class); var method = ParameterizedService.class.getMethod("echo", List.class); assertEquals(method, parameterized.operation("echo(java.util.List)")); assertEquals(method.getGenericParameterTypes()[0], parameterized.operation("echo(java.util.List)").getGenericParameterTypes()[0]); @@ -163,7 +163,7 @@ void ignoresStaticMethodsAndKeepsConcreteDeclaredTypes() throws Exception { @Test void prefersTheConcreteParameterizedReturnRedeclaredByAChildContract() throws Exception { - RpcContract contract = RpcContract.create(NumberService.class); + MessagingRpcContract contract = MessagingRpcContract.create(NumberService.class); assertEquals(NumberService.class.getDeclaredMethod("numbers", String.class), contract.operation("numbers(java.lang.String)")); assertEquals("java.util.List", diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java similarity index 81% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java index f8a42cb..01f78c5 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointFacadeTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java @@ -16,11 +16,11 @@ */ package com.dbeaver.messaging.rpc; -import com.dbeaver.messaging.requestreply.MessageSubscription; -import com.dbeaver.messaging.requestreply.RequestReplyException; -import com.dbeaver.messaging.requestreply.RequestReplyMessage; -import com.dbeaver.messaging.requestreply.RequestReplyOptions; -import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingSubscription; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; import org.junit.jupiter.api.Test; @@ -47,14 +47,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @Timeout(30) -public class RpcEndpointFacadeTest extends RpcTestSupport { +public class MessagingRpcEndpointFacadeTest extends MessagingRpcTestSupport { @Test void usesTheSameFacadeForTextAndBinaryPayloadRepresentations() throws Exception { var textTransport = new LoopbackTransport(() -> { }); var binaryTransport = new LoopbackTransport(() -> { }); - try (RpcEndpoint text = RpcEndpoint.open(textTransport, new TextCodec()); - RpcEndpoint binary = RpcEndpoint.open(binaryTransport, new BinaryCodec(), OPTIONS)) { - for (RpcEndpoint endpoint : List.of(text, binary)) { + try (MessagingRpcEndpoint text = MessagingRpcEndpoint.open(textTransport, new TextCodec()); + MessagingRpcEndpoint binary = MessagingRpcEndpoint.open(binaryTransport, new BinaryCodec(), OPTIONS)) { + for (MessagingRpcEndpoint endpoint : List.of(text, binary)) { assertFalse(endpoint.isClosed()); assertFalse(endpoint.isCloseComplete()); try (var registration = endpoint.register("text", TextService.class, value -> value)) { @@ -64,7 +64,7 @@ void usesTheSameFacadeForTextAndBinaryPayloadRepresentations() throws Exception endpoint.close(); assertTrue(endpoint.isClosed()); assertTrue(endpoint.isCloseComplete()); - failure(RpcException.Reason.CLOSED, () -> proxy.apply("closed")); + failure(MessagingRpcException.Reason.CLOSED, () -> proxy.apply("closed")); } } } @@ -83,7 +83,7 @@ void publishesClosedStateBeforeCleanupCompletesAndAllowsAFreshEndpoint() throws throw new AssertionError(e); } }); - try (RpcEndpoint endpoint = RpcEndpoint.open(transport, new TextCodec(), OPTIONS)) { + try (MessagingRpcEndpoint endpoint = MessagingRpcEndpoint.open(transport, new TextCodec(), OPTIONS)) { endpoint.proxy("text", TextService.class); assertFalse(endpoint.isClosed()); Future closing = async(() -> { @@ -95,7 +95,7 @@ void publishesClosedStateBeforeCleanupCompletesAndAllowsAFreshEndpoint() throws assertTrue(endpoint.isClosed()); assertFalse(endpoint.isCloseComplete()); assertFalse(closing.isDone()); - failure(RpcException.Reason.CLOSED, () -> endpoint.proxy("text", TextService.class)); + failure(MessagingRpcException.Reason.CLOSED, () -> endpoint.proxy("text", TextService.class)); endpoint.close(); } finally { releaseCleanup.countDown(); @@ -104,7 +104,7 @@ void publishesClosedStateBeforeCleanupCompletesAndAllowsAFreshEndpoint() throws assertTrue(endpoint.isClosed()); assertTrue(endpoint.isCloseComplete()); } - try (RpcEndpoint reopened = RpcEndpoint.open(transport, new TextCodec(), OPTIONS); + try (MessagingRpcEndpoint reopened = MessagingRpcEndpoint.open(transport, new TextCodec(), OPTIONS); var registration = reopened.register("text", TextService.class, value -> "fresh:" + value)) { assertFalse(reopened.isClosed()); assertEquals("fresh:value", reopened.proxy("text", TextService.class).apply("value")); @@ -117,7 +117,7 @@ void publishesCleanupCompletionEvenWhenTheTransportCleanupFails() throws Excepti var transport = new LoopbackTransport(() -> { throw failure; }); - RpcEndpoint endpoint = RpcEndpoint.open(transport, new TextCodec(), OPTIONS); + MessagingRpcEndpoint endpoint = MessagingRpcEndpoint.open(transport, new TextCodec(), OPTIONS); endpoint.proxy("text", TextService.class); assertSame(failure, assertThrows(IllegalStateException.class, endpoint::close)); @@ -144,7 +144,7 @@ void defaultsAndIndependentBindingCodecsNeverReplaceEachOther() { first.decoder = (value, type) -> "one:" + value; second.encoder = (value, type) -> "second:" + value; second.decoder = (value, type) -> "two:" + value; - try (var endpoint = RpcEndpoint.open(transport, fallback, OPTIONS); + try (var endpoint = MessagingRpcEndpoint.open(transport, fallback, OPTIONS); var defaultService = endpoint.register("default", TextService.class, value -> value); var customService = endpoint.register("custom", TextService.class, value -> value, service)) { TextService defaultProxy = endpoint.proxy("default", TextService.class); @@ -172,25 +172,25 @@ void defaultsAndIndependentBindingCodecsNeverReplaceEachOther() { void shorterProxyTimeoutDoesNotChangeOtherProxiesSharingTheTopic() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, codec, OPTIONS)) { TextService first = endpoint.proxy("text", TextService.class); TextService expiring = endpoint.proxy("text", TextService.class, Duration.ofMillis(100)); TextService second = endpoint.proxy("text", TextService.class); Future firstCall = async(() -> first.apply("first")); - var firstRequest = transport.next(); + final var firstRequest = transport.next(); Future secondCall = async(() -> second.apply("second")); - var secondRequest = transport.next(); + final var secondRequest = transport.next(); - failure(RpcException.Reason.TIMEOUT, () -> expiring.apply("expire")); + failure(MessagingRpcException.Reason.TIMEOUT, () -> expiring.apply("expire")); var expiredRequest = transport.next(); assertFalse(firstCall.isDone()); assertFalse(secondCall.isDone()); - transport.deliver(expiredRequest.response(RpcMessage.response("late"))); + transport.deliver(expiredRequest.response(MessagingRpcMessage.response("late"))); assertFalse(firstCall.isDone()); assertFalse(secondCall.isDone()); - transport.deliver(firstRequest.response(RpcMessage.response("first reply"))); - transport.deliver(secondRequest.response(RpcMessage.response("second reply"))); + transport.deliver(firstRequest.response(MessagingRpcMessage.response("first reply"))); + transport.deliver(secondRequest.response(MessagingRpcMessage.response("second reply"))); assertEquals("first reply", result(firstCall)); assertEquals("second reply", result(secondCall)); assertEquals(3, codec.encoded.size()); @@ -208,25 +208,25 @@ void longerProxyTimeoutAndExplicitCodecDoNotChangeTheEndpointDefaults() throws E var custom = new TestCodec(); custom.encoder = (value, type) -> "custom:" + value; custom.decoder = (value, type) -> "decoded:" + value; - var options = new RequestReplyOptions(Duration.ofMillis(100), 16, 16); - try (var endpoint = RpcEndpoint.open(transport, fallback, options)) { + var options = new MessagingRequestReplyOptions(Duration.ofMillis(100), 16, 16); + try (var endpoint = MessagingRpcEndpoint.open(transport, fallback, options)) { TextService first = endpoint.proxy("text", TextService.class); TextService extended = endpoint.proxy("text", TextService.class, custom, WAIT); TextService second = endpoint.proxy("text", TextService.class, fallback); - Future extendedCall = async(() -> extended.apply("extended")); + final Future extendedCall = async(() -> extended.apply("extended")); var extendedRequest = transport.next(); assertEquals("custom:extended", extendedRequest.payload().payload()); - failure(RpcException.Reason.TIMEOUT, () -> first.apply("first")); + failure(MessagingRpcException.Reason.TIMEOUT, () -> first.apply("first")); var firstRequest = transport.next(); - failure(RpcException.Reason.TIMEOUT, () -> second.apply("second")); + failure(MessagingRpcException.Reason.TIMEOUT, () -> second.apply("second")); var secondRequest = transport.next(); assertFalse(extendedCall.isDone()); - transport.deliver(firstRequest.response(RpcMessage.response("first late reply"))); - transport.deliver(secondRequest.response(RpcMessage.response("second late reply"))); + transport.deliver(firstRequest.response(MessagingRpcMessage.response("first late reply"))); + transport.deliver(secondRequest.response(MessagingRpcMessage.response("second late reply"))); assertFalse(extendedCall.isDone()); - transport.deliver(extendedRequest.response(RpcMessage.response("extended reply"))); + transport.deliver(extendedRequest.response(MessagingRpcMessage.response("extended reply"))); assertEquals("decoded:extended reply", result(extendedCall)); assertEquals(1, custom.encoded.size()); assertEquals(1, custom.decoded.size()); @@ -242,7 +242,7 @@ void longerProxyTimeoutAndExplicitCodecDoNotChangeTheEndpointDefaults() throws E void validatesProxyTimeoutsBeforeOpeningAnyTopic() { var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, codec, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, codec, OPTIONS)) { for (Duration timeout : List.of( Duration.ZERO, Duration.ofNanos(-1), Duration.ofSeconds(-1), Duration.ofSeconds(Long.MAX_VALUE), Duration.ofSeconds(Long.MIN_VALUE) @@ -271,7 +271,7 @@ void closesEveryTopicWhenOneSubscriptionCleanupFails() { throw failure; } }); - var endpoint = RpcEndpoint.open(transport, new TextCodec()); + var endpoint = MessagingRpcEndpoint.open(transport, new TextCodec()); endpoint.proxy("one", TextService.class); endpoint.proxy("two", TextService.class); endpoint.proxy("three", TextService.class); @@ -284,7 +284,7 @@ void closesEveryTopicWhenOneSubscriptionCleanupFails() { @Test void missingDefaultFailsBeforeOpeningAnyTopic() { var openings = new AtomicInteger(); - try (var endpoint = RpcEndpoint.open(topic -> { + try (var endpoint = MessagingRpcEndpoint.open(topic -> { openings.incrementAndGet(); return new MemoryTransport().forTopic(topic); })) { @@ -300,9 +300,9 @@ void missingDefaultFailsBeforeOpeningAnyTopic() { @Test void incompatibleBindingPayloadsFailThroughTheCodecErrorBoundary() { var transport = new LoopbackTransport(() -> { }); - try (var endpoint = RpcEndpoint.open(transport, new TextCodec()); + try (var endpoint = MessagingRpcEndpoint.open(transport, new TextCodec()); var registration = endpoint.register("text", TextService.class, value -> value)) { - remoteFailure(RpcMessage.Error.CODEC_FAILED, + remoteFailure(MessagingRpcMessage.Error.CODEC_FAILED, () -> endpoint.proxy("text", TextService.class, new BinaryCodec()).apply("incompatible")); assertEquals("compatible", endpoint.proxy("text", TextService.class).apply("compatible")); } @@ -314,7 +314,7 @@ void concurrentBindingsShareOneOpeningWithoutBlockingOtherTopics() throws Except var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var openings = new AtomicInteger(); - try (var endpoint = RpcEndpoint.open(topic -> { + try (var endpoint = MessagingRpcEndpoint.open(topic -> { openings.incrementAndGet(); if (topic.equals("blocked")) { entered.countDown(); @@ -322,7 +322,7 @@ void concurrentBindingsShareOneOpeningWithoutBlockingOtherTopics() throws Except await(release); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, e); + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e); } } return transport.forTopic(topic); @@ -360,13 +360,13 @@ void closeCompletionWaitsForLateOpeningAndItsSubscriptionRollback() throws Excep throw new AssertionError(e); } }); - try (var endpoint = RpcEndpoint.open(topic -> { + try (var endpoint = MessagingRpcEndpoint.open(topic -> { opening.countDown(); try { await(releaseOpening); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, e); + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e); } return transport.forTopic(topic); }, OPTIONS)) { @@ -381,7 +381,7 @@ void closeCompletionWaitsForLateOpeningAndItsSubscriptionRollback() throws Excep endpoint.close(); assertFalse(endpoint.isCloseComplete()); releaseCleanup.countDown(); - failure(RpcException.Reason.CLOSED, pending); + failure(MessagingRpcException.Reason.CLOSED, pending); assertTrue(endpoint.isCloseComplete()); assertEquals(1, closes.get()); assertTrue(transport.listeners.isEmpty()); @@ -395,7 +395,7 @@ void closeCompletionWaitsForLateOpeningAndItsSubscriptionRollback() throws Excep @Test void supportsMoreThanSixtyFourReceiverTopicsAndReusesSharedSubscriptions() { var transport = new MemoryTransport(); - try (var endpoint = RpcEndpoint.open(transport, new TestCodec())) { + try (var endpoint = MessagingRpcEndpoint.open(transport, new TestCodec())) { for (int index = 0; index < 128; index++) { endpoint.register("topic." + index, TextService.class, value -> value); endpoint.proxy("topic." + index, TextService.class); @@ -420,7 +420,7 @@ void supportsMoreThanSixtyFourProxyTopicsAndWaitsForLateOpeningRollback() throws var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var openings = new AtomicInteger(); - try (var endpoint = RpcEndpoint.open(topic -> { + try (var endpoint = MessagingRpcEndpoint.open(topic -> { openings.incrementAndGet(); if (topic.equals("reserved")) { entered.countDown(); @@ -428,7 +428,7 @@ void supportsMoreThanSixtyFourProxyTopicsAndWaitsForLateOpeningRollback() throws await(release); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new RequestReplyException(RequestReplyException.Reason.SEND_FAILED, e); + throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e); } } return transport.forTopic(topic); @@ -449,7 +449,7 @@ void supportsMoreThanSixtyFourProxyTopicsAndWaitsForLateOpeningRollback() throws assertEquals(129, transport.subscriptionCloses.get()); assertTrue(transport.listeners.isEmpty()); release.countDown(); - failure(RpcException.Reason.CLOSED, pending); + failure(MessagingRpcException.Reason.CLOSED, pending); assertTrue(endpoint.isCloseComplete()); assertEquals(130, transport.subscriptionCloses.get()); assertTrue(transport.listeners.isEmpty()); @@ -465,7 +465,7 @@ public interface TextService { String apply(@Nullable String value); } - private static final class TextCodec implements RpcCodec { + private static final class TextCodec implements MessagingRpcCodec { @Nullable @Override public String encode(@Nullable Object value, @NotNull Type type) { @@ -479,7 +479,7 @@ public Object decode(@Nullable String payload, @NotNull Type type) { } } - private static final class BinaryCodec implements RpcCodec { + private static final class BinaryCodec implements MessagingRpcCodec { @Nullable @Override public byte[] encode(@Nullable Object value, @NotNull Type type) { @@ -493,8 +493,8 @@ public Object decode(@Nullable byte[] payload, @NotNull Type type) { } } - private static final class LoopbackTransport

implements RpcTransport

{ - private final Map>> listeners = new ConcurrentHashMap<>(); + private static final class LoopbackTransport

implements MessagingRpcTransport

{ + private final Map>> listeners = new ConcurrentHashMap<>(); private final Map topics = new ConcurrentHashMap<>(); private final Runnable onClose; @@ -504,11 +504,11 @@ private LoopbackTransport(@NotNull Runnable onClose) { @NotNull @Override - public RequestReplyTransport> forTopic(@NotNull String topic) { - return new RequestReplyTransport<>() { + public MessagingRequestReplyTransport> forTopic(@NotNull String topic) { + return new MessagingRequestReplyTransport<>() { @NotNull @Override - public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) { + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) { listeners.put(endpointId, listener); topics.put(endpointId, topic); return () -> { @@ -520,7 +520,7 @@ public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener @NotNull @Override - public CompletionStage send(@NotNull RequestReplyMessage> message) { + public CompletionStage send(@NotNull MessagingRequestReplyMessage> message) { List.copyOf(listeners.entrySet()).stream().filter(entry -> topic.equals(topics.get(entry.getKey()))) .forEach(entry -> entry.getValue().onMessage(message)); return CompletableFuture.completedFuture(null); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java similarity index 77% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java index bee500d..0d41f10 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcEndpointTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java @@ -16,12 +16,12 @@ */ package com.dbeaver.messaging.rpc; -import com.dbeaver.messaging.requestreply.MessageSubscription; -import com.dbeaver.messaging.requestreply.RequestReplyEndpoint; -import com.dbeaver.messaging.requestreply.RequestReplyException; -import com.dbeaver.messaging.requestreply.RequestReplyMessage; -import com.dbeaver.messaging.requestreply.RequestReplyOptions; -import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingSubscription; import com.dbeaver.messaging.rpc.fixtures.PublicInheritedService; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; @@ -60,7 +60,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @Timeout(30) -public class RpcEndpointTest extends RpcTestSupport { +public class MessagingRpcEndpointTest extends MessagingRpcTestSupport { @Test void supportsSynchronousReentrantCallbacksOnIndependentReceivingThreads() throws Exception { var leftTransport = new MemoryTransport(); @@ -68,8 +68,8 @@ void supportsSynchronousReentrantCallbacksOnIndependentReceivingThreads() throws var serviceThread = new AtomicReference(); var callbackThread = new AtomicReference(); connect(leftTransport, rightTransport); - try (var left = RpcEndpoint.open(leftTransport, OPTIONS); - var right = RpcEndpoint.open(rightTransport, OPTIONS); + try (var left = MessagingRpcEndpoint.open(leftTransport, OPTIONS); + var right = MessagingRpcEndpoint.open(rightTransport, OPTIONS); var callback = left.register("echo", EchoService.class, value -> { callbackThread.set(Thread.currentThread()); return "callback:" + value; @@ -90,8 +90,8 @@ void servesIndependentServicesInBothDirectionsAndAcrossTopics() throws Exception var leftTransport = new MemoryTransport(); var rightTransport = new MemoryTransport(); connect(leftTransport, rightTransport); - try (var left = RpcEndpoint.open(leftTransport); - var right = RpcEndpoint.open(rightTransport, OPTIONS); + try (var left = MessagingRpcEndpoint.open(leftTransport); + var right = MessagingRpcEndpoint.open(rightTransport, OPTIONS); var leftService = left.register("echo", EchoService.class, value -> "left:" + value, new TestCodec()); var rightService = right.register("echo", EchoService.class, value -> "right:" + value, new TestCodec()); var otherService = right.register("other", EchoService.class, value -> "other:" + value, new TestCodec())) { @@ -115,8 +115,8 @@ void passesDeclaredParameterizedTypesAndNullValuesThroughEveryCodecBoundary() th var clientCodec = new TestCodec(); var serverCodec = new TestCodec(); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, OPTIONS); - var server = RpcEndpoint.open(serverTransport, OPTIONS); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); var registration = server.register("collection", CollectionService.class, values -> values == null ? null : Map.of("size", values.size()), serverCodec)) { CollectionService proxy = client.proxy("collection", CollectionService.class, clientCodec); @@ -135,8 +135,8 @@ void passesDeclaredParameterizedTypesAndNullValuesThroughEveryCodecBoundary() th assertEquals(serverCodec.encoded, clientCodec.decoded); assertEquals("count(java.util.List)", clientTransport.next().payload().operation()); assertNull(clientTransport.next().payload().payload()); - assertEquals(RpcMessage.Type.RESPONSE, serverTransport.next().payload().type()); - assertEquals(RpcMessage.response(null), serverTransport.next().payload()); + assertEquals(MessagingRpcMessage.Type.RESPONSE, serverTransport.next().payload().type()); + assertEquals(MessagingRpcMessage.response(null), serverTransport.next().payload()); } } @@ -147,8 +147,8 @@ void supportsEveryPrimitiveWithoutChangingItsDeclaredCodecType() throws Exceptio var clientCodec = new TestCodec(); var serverCodec = new TestCodec(); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, OPTIONS); - var server = RpcEndpoint.open(serverTransport, OPTIONS); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); var registration = server.register("primitive", PrimitiveService.class, new PrimitiveEcho(), serverCodec)) { PrimitiveService proxy = client.proxy("primitive", PrimitiveService.class, clientCodec); @@ -198,8 +198,8 @@ public String inherited(@NotNull Long value) { return "inherited:" + value; } }; - try (var client = RpcEndpoint.open(clientTransport, OPTIONS); - var server = RpcEndpoint.open(serverTransport, OPTIONS); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); var registration = server.register("overload", OverloadedService.class, implementation, serverCodec)) { OverloadedService proxy = client.proxy("overload", OverloadedService.class, clientCodec); BaseService base = proxy; @@ -224,19 +224,19 @@ void invokesAnOperationInheritedFromANonPublicParentInAnotherPackage() throws Ex PublicInheritedService implementation = value -> "inherited:" + value; var operation = PublicInheritedService.class.getMethod("inherited", String.class); assertFalse(Modifier.isPublic(operation.getDeclaringClass().getModifiers())); - assertNotEquals(RpcEndpoint.class.getPackageName(), operation.getDeclaringClass().getPackageName()); + assertNotEquals(MessagingRpcEndpoint.class.getPackageName(), operation.getDeclaringClass().getPackageName()); assertFalse(operation.canAccess(implementation)); var clientTransport = new MemoryTransport(); var serverTransport = new MemoryTransport(); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, OPTIONS); - var server = RpcEndpoint.open(serverTransport, OPTIONS); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); var registration = server.register("inherited", PublicInheritedService.class, implementation, new TestCodec())) { PublicInheritedService proxy = client.proxy("inherited", PublicInheritedService.class, new TestCodec()); assertEquals("inherited:value", proxy.inherited("value")); assertEquals("inherited(java.lang.String)", clientTransport.next().payload().operation()); - assertEquals(RpcMessage.response("inherited:value"), serverTransport.next().payload()); + assertEquals(MessagingRpcMessage.response("inherited:value"), serverTransport.next().payload()); } } @@ -249,8 +249,8 @@ void voidCallsWaitForHandlerCompletionAndDoNotEncodeOrDecodeAResult() throws Exc var entered = new CountDownLatch(1); var release = new CountDownLatch(1); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, OPTIONS); - var server = RpcEndpoint.open(serverTransport, OPTIONS); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); var registration = server.register("void", VoidService.class, value -> { assertEquals("work", value); entered.countDown(); @@ -268,7 +268,7 @@ void voidCallsWaitForHandlerCompletionAndDoNotEncodeOrDecodeAResult() throws Exc release.countDown(); assertNull(result(pending)); - assertEquals(RpcMessage.response(null), serverTransport.next().payload()); + assertEquals(MessagingRpcMessage.response(null), serverTransport.next().payload()); assertEquals(List.of(new CodecCall("work", String.class)), clientCodec.encoded); assertEquals(clientCodec.encoded, serverCodec.decoded); assertTrue(serverCodec.encoded.isEmpty()); @@ -285,8 +285,8 @@ void reportsOriginalHandlerExceptionDetailsWithoutSerializingTheThrowableOrRetry var serverTransport = new MemoryTransport(); var calls = new AtomicInteger(); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, OPTIONS); - var server = RpcEndpoint.open(serverTransport, OPTIONS)) { + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS)) { for (Throwable cause : List.of(new Exception("private checked detail"), new IllegalStateException("private runtime detail"), new AssertionError("private error detail"))) { try (var registration = server.register("failure", FailingService.class, value -> { @@ -296,10 +296,10 @@ void reportsOriginalHandlerExceptionDetailsWithoutSerializingTheThrowableOrRetry } throw (Error) cause; }, new TestCodec())) { - RpcException failure = remoteFailure(RpcMessage.Error.HANDLER_FAILED, + MessagingRpcException failure = remoteFailure(MessagingRpcMessage.Error.HANDLER_FAILED, () -> client.proxy("failure", FailingService.class, new TestCodec()).fail("secret argument")); - RpcExceptionDetails details = failure.remoteException(); + MessagingRpcExceptionDetails details = failure.remoteException(); assertNotNull(details); assertEquals(cause.getClass().getName(), details.type()); assertEquals(cause.getMessage(), details.message()); @@ -307,7 +307,8 @@ void reportsOriginalHandlerExceptionDetailsWithoutSerializingTheThrowableOrRetry assertFalse(details.stackTrace().contains(InvocationTargetException.class.getName())); assertNotSame(cause, failure.getCause()); assertFalse(failure.toString().contains("secret")); - assertEquals(RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED, details), serverTransport.next().payload()); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED, details), + serverTransport.next().payload()); assertEquals("secret argument", clientTransport.next().payload().payload()); assertTrue(clientTransport.sent.isEmpty()); assertTrue(serverTransport.sent.isEmpty()); @@ -337,26 +338,28 @@ void distinguishesLocalAndRemoteCodecFailuresAtAllFourBoundariesAndRecovers() th default -> throw new AssertionError("Unexpected codec boundary"); } connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, OPTIONS); - var server = RpcEndpoint.open(serverTransport, OPTIONS); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); var registration = server.register("echo", EchoService.class, value -> { calls.incrementAndGet(); return value; }, serverCodec)) { EchoService proxy = client.proxy("echo", EchoService.class, clientCodec); if (boundary == 0 || boundary == 3) { - RpcException failure = failure(RpcException.Reason.CODEC_FAILED, () -> proxy.echo("secret argument")); + MessagingRpcException failure = failure(MessagingRpcException.Reason.CODEC_FAILED, () -> proxy.echo("secret argument")); assertSame(cause, failure.getCause()); assertNull(failure.remoteError()); assertNull(failure.remoteException()); } else { - RpcException failure = remoteFailure(RpcMessage.Error.CODEC_FAILED, () -> proxy.echo("secret argument")); - RpcExceptionDetails details = failure.remoteException(); + MessagingRpcException failure = remoteFailure(MessagingRpcMessage.Error.CODEC_FAILED, + () -> proxy.echo("secret argument")); + MessagingRpcExceptionDetails details = failure.remoteException(); assertNotNull(details); assertEquals(cause.getClass().getName(), details.type()); assertEquals(cause.getMessage(), details.message()); assertNotSame(cause, failure.getCause()); - assertEquals(RpcMessage.failure(RpcMessage.Error.CODEC_FAILED, details), serverTransport.next().payload()); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.CODEC_FAILED, details), + serverTransport.next().payload()); } assertEquals(boundary < 2 ? 0 : 1, calls.get()); assertEquals(boundary == 0 ? 0 : 1, clientTransport.sent.size()); @@ -378,8 +381,8 @@ void rejectsPrimitiveNullsAndWrongDecodedArgumentTypesBeforeInvokingAService() t var codec = new TestCodec(); var calls = new AtomicInteger(); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, OPTIONS); - var server = RpcEndpoint.open(serverTransport, OPTIONS); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); var references = server.register("reference", EchoService.class, value -> { calls.incrementAndGet(); return value; @@ -390,10 +393,11 @@ void rejectsPrimitiveNullsAndWrongDecodedArgumentTypesBeforeInvokingAService() t }, codec)) { for (Object invalid : Arrays.asList(null, "wrong primitive", 1L)) { codec.decoder = (value, type) -> invalid; - remoteFailure(RpcMessage.Error.INVALID_REQUEST, () -> client.proxy("primitive", IntService.class, new TestCodec()).echo(1)); + remoteFailure(MessagingRpcMessage.Error.INVALID_REQUEST, + () -> client.proxy("primitive", IntService.class, new TestCodec()).echo(1)); } codec.decoder = (value, type) -> 42; - remoteFailure(RpcMessage.Error.INVALID_REQUEST, + remoteFailure(MessagingRpcMessage.Error.INVALID_REQUEST, () -> client.proxy("reference", EchoService.class, new TestCodec()).echo("value")); assertEquals(0, calls.get()); @@ -407,18 +411,19 @@ void rejectsPrimitiveNullsAndWrongDecodedResultTypesAsInvalidResponses() throws var serverTransport = new MemoryTransport(); var codec = new TestCodec(); connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, OPTIONS); - var server = RpcEndpoint.open(serverTransport, OPTIONS); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); var references = server.register("reference", EchoService.class, value -> value, new TestCodec()); var primitives = server.register("primitive", IntService.class, value -> value, new TestCodec())) { for (Object invalid : Arrays.asList(null, "wrong primitive", 1L)) { codec.decoder = (value, type) -> invalid; - RpcException failure = failure(RpcException.Reason.INVALID_RESPONSE, + MessagingRpcException failure = failure(MessagingRpcException.Reason.INVALID_RESPONSE, () -> client.proxy("primitive", IntService.class, codec).echo(1)); assertNull(failure.remoteError()); } codec.decoder = (value, type) -> 42; - failure(RpcException.Reason.INVALID_RESPONSE, () -> client.proxy("reference", EchoService.class, codec).echo("value")); + failure(MessagingRpcException.Reason.INVALID_RESPONSE, + () -> client.proxy("reference", EchoService.class, codec).echo("value")); } } @@ -429,19 +434,19 @@ void rejectsUnknownOperationsAndNonRequestEnvelopesBeforeDecoding() throws Excep var codec = new TestCodec(); var calls = new AtomicInteger(); connect(clientTransport, serverTransport); - try (var client = RequestReplyEndpoint.open(clientTransport.forTopic("echo"), OPTIONS); - var server = RpcEndpoint.open(serverTransport, OPTIONS); + try (var client = MessagingRequestReplyEndpoint.open(clientTransport.forTopic("echo"), OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); var registration = server.register("echo", EchoService.class, value -> { calls.incrementAndGet(); return value; }, codec)) { for (String operation : List.of("missing(java.lang.String)", "echo(int)")) { - assertEquals(RpcMessage.failure(RpcMessage.Error.UNKNOWN_OPERATION), - client.request(RpcMessage.request(operation, "value"))); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION), + client.request(MessagingRpcMessage.request(operation, "value"))); } - for (RpcMessage invalid : List.of(RpcMessage.response("not a request"), - RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED))) { - assertEquals(RpcMessage.failure(RpcMessage.Error.INVALID_REQUEST), client.request(invalid)); + for (MessagingRpcMessage invalid : List.of(MessagingRpcMessage.response("not a request"), + MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED))) { + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST), client.request(invalid)); } assertEquals(0, calls.get()); assertTrue(codec.encoded.isEmpty()); @@ -453,21 +458,22 @@ void rejectsUnknownOperationsAndNonRequestEnvelopesBeforeDecoding() throws Excep void rejectsMissingResponseEnvelopesRequestsUsedAsRepliesAndNonemptyVoidReplies() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { EchoService proxy = endpoint.proxy("echo", EchoService.class, codec); - List> invalid = Arrays.asList(null, RpcMessage.request("echo(java.lang.String)", "value")); - for (RpcMessage reply : invalid) { + List> invalid = Arrays.asList(null, MessagingRpcMessage.request("echo(java.lang.String)", "value")); + for (MessagingRpcMessage reply : invalid) { transport.sender = request -> { transport.deliver(request.response(reply)); return CompletableFuture.completedFuture(null); }; - failure(RpcException.Reason.INVALID_RESPONSE, () -> proxy.echo("value")); + failure(MessagingRpcException.Reason.INVALID_RESPONSE, () -> proxy.echo("value")); } transport.sender = request -> { - transport.deliver(request.response(RpcMessage.response("unexpected void payload"))); + transport.deliver(request.response(MessagingRpcMessage.response("unexpected void payload"))); return CompletableFuture.completedFuture(null); }; - failure(RpcException.Reason.INVALID_RESPONSE, () -> endpoint.proxy("void", VoidService.class, codec).accept("value")); + failure(MessagingRpcException.Reason.INVALID_RESPONSE, + () -> endpoint.proxy("void", VoidService.class, codec).accept("value")); assertTrue(codec.decoded.isEmpty()); assertEquals(3, transport.sent.size()); } @@ -484,8 +490,8 @@ void scopesRegistrationHandlesByIdentityAndLetsAcceptedCallsFinishAfterUnregiste originalCodec.encoder = (value, type) -> "old-codec:" + value; replacementCodec.encoder = (value, type) -> "new-codec:" + value; connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, OPTIONS); - var server = RpcEndpoint.open(serverTransport, OPTIONS); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); var other = server.register("other", EchoService.class, value -> "other:" + value, new TestCodec()); var original = server.register("echo", EchoService.class, value -> { entered.countDown(); @@ -500,7 +506,7 @@ void scopesRegistrationHandlesByIdentityAndLetsAcceptedCallsFinishAfterUnregiste await(entered); original.close(); - remoteFailure(RpcMessage.Error.UNKNOWN_SERVICE, () -> proxy.echo("unregistered")); + remoteFailure(MessagingRpcMessage.Error.UNKNOWN_SERVICE, () -> proxy.echo("unregistered")); try (var replacement = server.register("echo", EchoService.class, value -> "replacement:" + value, replacementCodec)) { original.close(); assertEquals("new-codec:replacement:next", proxy.echo("next")); @@ -508,7 +514,7 @@ void scopesRegistrationHandlesByIdentityAndLetsAcceptedCallsFinishAfterUnregiste release.countDown(); assertEquals("old-codec:original:accepted", result(accepted)); } - remoteFailure(RpcMessage.Error.UNKNOWN_SERVICE, () -> proxy.echo("removed replacement")); + remoteFailure(MessagingRpcMessage.Error.UNKNOWN_SERVICE, () -> proxy.echo("removed replacement")); assertEquals("other:surviving", client.proxy("other", EchoService.class, new TestCodec()).echo("surviving")); assertEquals(2, serverTransport.listeners.size()); assertEquals(0, serverTransport.subscriptionCloses.get()); @@ -522,18 +528,18 @@ void scopesRegistrationHandlesByIdentityAndLetsAcceptedCallsFinishAfterUnregiste void closingAnEndpointFailsPendingCallsAndClosesItsTopicSubscriptions() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { EchoService proxy = endpoint.proxy("echo", EchoService.class, codec); Future pending = async(() -> proxy.echo("pending")); transport.next(); endpoint.close(); - RpcException failure = failure(RpcException.Reason.CLOSED, pending); + MessagingRpcException failure = failure(MessagingRpcException.Reason.CLOSED, pending); assertNull(failure.remoteError()); - failure(RpcException.Reason.CLOSED, () -> proxy.echo("closed")); - failure(RpcException.Reason.CLOSED, () -> endpoint.proxy("echo", EchoService.class, codec)); - failure(RpcException.Reason.CLOSED, () -> endpoint.register("echo", EchoService.class, value -> value, codec)); + failure(MessagingRpcException.Reason.CLOSED, () -> proxy.echo("closed")); + failure(MessagingRpcException.Reason.CLOSED, () -> endpoint.proxy("echo", EchoService.class, codec)); + failure(MessagingRpcException.Reason.CLOSED, () -> endpoint.register("echo", EchoService.class, value -> value, codec)); assertTrue(transport.listeners.isEmpty()); assertTrue(transport.sent.isEmpty()); assertEquals(1, transport.subscriptionCloses.get()); @@ -547,15 +553,16 @@ void closingAnEndpointFailsPendingCallsAndClosesItsTopicSubscriptions() throws E void failedTopicOpeningReleasesItsReservationAndAllowsRetry() throws Exception { var transport = new MemoryTransport(); var attempts = new AtomicInteger(); - var expected = new RequestReplyException(RequestReplyException.Reason.DISCONNECTED); - try (var endpoint = RpcEndpoint.open(topic -> { + var expected = new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED); + try (var endpoint = MessagingRpcEndpoint.open(topic -> { if (attempts.getAndIncrement() == 0) { throw expected; } return transport.forTopic(topic); }, OPTIONS)) { assertSame(expected, - failure(RpcException.Reason.DISCONNECTED, () -> endpoint.proxy("echo", EchoService.class, new TestCodec())).getCause()); + failure(MessagingRpcException.Reason.DISCONNECTED, + () -> endpoint.proxy("echo", EchoService.class, new TestCodec())).getCause()); endpoint.proxy("echo", EchoService.class, new TestCodec()); assertEquals(2, attempts.get()); assertEquals(1, transport.listeners.size()); @@ -565,22 +572,22 @@ void failedTopicOpeningReleasesItsReservationAndAllowsRetry() throws Exception { @Test void disconnectFailsPendingCallsAndFreshCallsDoNotReplayThem() throws Exception { var transport = new MemoryTransport(); - try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { EchoService proxy = endpoint.proxy("echo", EchoService.class, new TestCodec()); Future pending = async(() -> proxy.echo("old")); final var oldRequest = transport.next(); transport.disconnect(); - RpcException failure = failure(RpcException.Reason.DISCONNECTED, pending); + MessagingRpcException failure = failure(MessagingRpcException.Reason.DISCONNECTED, pending); assertNull(failure.remoteError()); assertTrue(transport.sent.isEmpty()); Future fresh = async(() -> proxy.echo("fresh")); var freshRequest = transport.next(); assertEquals("fresh", freshRequest.payload().payload()); - transport.deliver(oldRequest.response(RpcMessage.response("late"))); + transport.deliver(oldRequest.response(MessagingRpcMessage.response("late"))); assertFalse(fresh.isDone()); - transport.deliver(freshRequest.response(RpcMessage.response("fresh reply"))); + transport.deliver(freshRequest.response(MessagingRpcMessage.response("fresh reply"))); assertEquals("fresh reply", result(fresh)); assertTrue(transport.sent.isEmpty()); @@ -592,9 +599,9 @@ void disconnectFailsPendingCallsAndFreshCallsDoNotReplayThem() throws Exception void usesTheConfiguredTimeoutWithoutRetryingTheEncodedRequest() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - var options = new RequestReplyOptions(Duration.ofMillis(100), 1, 1); - try (var endpoint = RpcEndpoint.open(transport, options)) { - RpcException failure = failure(RpcException.Reason.TIMEOUT, + var options = new MessagingRequestReplyOptions(Duration.ofMillis(100), 1, 1); + try (var endpoint = MessagingRpcEndpoint.open(transport, options)) { + MessagingRpcException failure = failure(MessagingRpcException.Reason.TIMEOUT, () -> endpoint.proxy("echo", EchoService.class, codec).echo("expire")); assertNull(failure.remoteError()); @@ -609,12 +616,12 @@ void usesTheConfiguredTimeoutWithoutRetryingTheEncodedRequest() throws Exception void mapsSendFailureAndPendingOverloadWithoutSendingAnExtraRequest() throws Exception { var transport = new MemoryTransport(); var cause = new IllegalStateException("send failed"); - var options = new RequestReplyOptions(WAIT, 1, 1); + var options = new MessagingRequestReplyOptions(WAIT, 1, 1); transport.sender = request -> CompletableFuture.failedFuture(cause); - try (var endpoint = RpcEndpoint.open(transport, options)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, options)) { EchoService proxy = endpoint.proxy("echo", EchoService.class, new TestCodec()); - RpcException failed = failure(RpcException.Reason.SEND_FAILED, () -> proxy.echo("failed")); - assertSame(cause, assertInstanceOf(RequestReplyException.class, failed.getCause()).getCause()); + MessagingRpcException failed = failure(MessagingRpcException.Reason.SEND_FAILED, () -> proxy.echo("failed")); + assertSame(cause, assertInstanceOf(MessagingRequestReplyException.class, failed.getCause()).getCause()); assertNull(failed.remoteError()); transport.next(); assertTrue(transport.sent.isEmpty()); @@ -622,9 +629,9 @@ void mapsSendFailureAndPendingOverloadWithoutSendingAnExtraRequest() throws Exce transport.sender = request -> CompletableFuture.completedFuture(null); final Future pending = async(() -> proxy.echo("pending")); var request = transport.next(); - failure(RpcException.Reason.OVERLOADED, () -> proxy.echo("excess")); + failure(MessagingRpcException.Reason.OVERLOADED, () -> proxy.echo("excess")); assertTrue(transport.sent.isEmpty()); - transport.deliver(request.response(RpcMessage.response("completed"))); + transport.deliver(request.response(MessagingRpcMessage.response("completed"))); assertEquals("completed", result(pending)); } } @@ -632,12 +639,12 @@ void mapsSendFailureAndPendingOverloadWithoutSendingAnExtraRequest() throws Exce @Test void preservesTypedSendFailureReasonsAtTheProxyBoundary() throws Exception { for (var mapping : Map.of( - RequestReplyException.Reason.OVERLOADED, RpcException.Reason.OVERLOADED, - RequestReplyException.Reason.DISCONNECTED, RpcException.Reason.DISCONNECTED, - RequestReplyException.Reason.CLOSED, RpcException.Reason.CLOSED).entrySet()) { - var expected = new RequestReplyException(mapping.getKey()); + MessagingRequestReplyException.Reason.OVERLOADED, MessagingRpcException.Reason.OVERLOADED, + MessagingRequestReplyException.Reason.DISCONNECTED, MessagingRpcException.Reason.DISCONNECTED, + MessagingRequestReplyException.Reason.CLOSED, MessagingRpcException.Reason.CLOSED).entrySet()) { + var expected = new MessagingRequestReplyException(mapping.getKey()); var transport = new MemoryTransport(); - try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { EchoService proxy = endpoint.proxy("echo", EchoService.class, new TestCodec()); for (Throwable error : List.of(expected, new CompletionException(new ExecutionException(expected)))) { transport.sender = request -> CompletableFuture.failedFuture(error); @@ -657,15 +664,15 @@ void preservesTypedSendFailureReasonsAtTheProxyBoundary() throws Exception { void mapsRequestReplyRemoteFailuresWithoutInventingAnRpcErrorCode() throws Exception { var transport = new MemoryTransport(); transport.sender = request -> { - transport.deliver(request.failure(RequestReplyMessage.Error.OVERLOADED)); + transport.deliver(request.failure(MessagingRequestReplyMessage.Error.OVERLOADED)); return CompletableFuture.completedFuture(null); }; - try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { - RpcException failure = failure(RpcException.Reason.REMOTE_ERROR, + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + MessagingRpcException failure = failure(MessagingRpcException.Reason.REMOTE_ERROR, () -> endpoint.proxy("echo", EchoService.class, new TestCodec()).echo("value")); assertNull(failure.remoteError()); - assertEquals(RequestReplyMessage.Error.OVERLOADED, - assertInstanceOf(RequestReplyException.class, failure.getCause()).remoteError()); + assertEquals(MessagingRequestReplyMessage.Error.OVERLOADED, + assertInstanceOf(MessagingRequestReplyException.class, failure.getCause()).remoteError()); assertEquals(1, transport.sent.size()); } } @@ -673,10 +680,10 @@ void mapsRequestReplyRemoteFailuresWithoutInventingAnRpcErrorCode() throws Excep @Test void restoresTheCallerInterruptFlagWhenAPendingRequestIsInterrupted() throws Exception { var transport = new MemoryTransport(); - try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { EchoService proxy = endpoint.proxy("echo", EchoService.class, new TestCodec()); var pending = new FutureTask<>(() -> { - RpcException failure = failure(RpcException.Reason.INTERRUPTED, () -> proxy.echo("interrupt")); + MessagingRpcException failure = failure(MessagingRpcException.Reason.INTERRUPTED, () -> proxy.echo("interrupt")); assertInstanceOf(InterruptedException.class, failure.getCause()); assertNull(failure.remoteError()); return Thread.currentThread().isInterrupted(); @@ -695,12 +702,12 @@ void restoresTheCallerInterruptFlagWhenAPendingRequestIsInterrupted() throws Exc void alreadyInterruptedCallersKeepTheirFlagWithoutInvokingTheCodecOrSending() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { EchoService proxy = endpoint.proxy("echo", EchoService.class, codec); Future flag = async(() -> { Thread.currentThread().interrupt(); try { - failure(RpcException.Reason.INTERRUPTED, () -> proxy.echo("value")); + failure(MessagingRpcException.Reason.INTERRUPTED, () -> proxy.echo("value")); return Thread.currentThread().isInterrupted(); } finally { Thread.interrupted(); @@ -728,12 +735,13 @@ void restoresTheCallerInterruptFlagWhenEitherLocalCodecBoundaryIsInterrupted() t codec.decoder = interrupted; } transport.sender = request -> { - transport.deliver(request.response(RpcMessage.response("value"))); + transport.deliver(request.response(MessagingRpcMessage.response("value"))); return CompletableFuture.completedFuture(null); }; - try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { Future flag = async(() -> { - failure(RpcException.Reason.INTERRUPTED, () -> endpoint.proxy("echo", EchoService.class, codec).echo("value")); + failure(MessagingRpcException.Reason.INTERRUPTED, + () -> endpoint.proxy("echo", EchoService.class, codec).echo("value")); return Thread.currentThread().isInterrupted(); }); @@ -761,8 +769,8 @@ void processesParallelCallsAndConcurrentDecodesWithOutOfOrderCompletion() throws return value; }; connect(clientTransport, serverTransport); - try (var client = RpcEndpoint.open(clientTransport, OPTIONS); - var server = RpcEndpoint.open(serverTransport, OPTIONS); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); var registration = server.register("echo", EchoService.class, value -> { entered.countDown(); await(release.get(value)); @@ -787,7 +795,7 @@ void processesParallelCallsAndConcurrentDecodesWithOutOfOrderCompletion() throws String value = order.get(index); release.get(value).countDown(); assertEquals("reply:" + value, result(results.get(value))); - assertEquals(RpcMessage.response("reply:" + value), serverTransport.next().payload()); + assertEquals(MessagingRpcMessage.response("reply:" + value), serverTransport.next().payload()); } assertEquals(4, serverCodec.decoded.size()); assertEquals(4, serverCodec.encoded.size()); @@ -803,7 +811,7 @@ void processesParallelCallsAndConcurrentDecodesWithOutOfOrderCompletion() throws void objectMethodsRemainLocalAndUseProxyIdentityEvenAfterClosing() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { EchoService first = endpoint.proxy("echo", EchoService.class, codec); EchoService second = endpoint.proxy("echo", EchoService.class, codec); @@ -830,19 +838,19 @@ void objectMethodsRemainLocalAndUseProxyIdentityEvenAfterClosing() throws Except void rejectsInvalidTopicsNullArgumentsAndOptionsBeforeSubscribingOrSending() throws Exception { var transport = new MemoryTransport(); var codec = new TestCodec(); - assertThrows(NullPointerException.class, () -> RpcEndpoint.open(transport, (RequestReplyOptions) null)); - assertThrows(NullPointerException.class, () -> RpcEndpoint.open(transport, null, OPTIONS)); - assertThrows(NullPointerException.class, () -> RpcEndpoint.open(null, OPTIONS)); + assertThrows(NullPointerException.class, () -> MessagingRpcEndpoint.open(transport, (MessagingRequestReplyOptions) null)); + assertThrows(NullPointerException.class, () -> MessagingRpcEndpoint.open(transport, null, OPTIONS)); + assertThrows(NullPointerException.class, () -> MessagingRpcEndpoint.open(null, OPTIONS)); assertTrue(transport.listeners.isEmpty()); - try (var endpoint = RpcEndpoint.open(transport, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { for (String topic : List.of("", " ", "\n", "t".repeat(257))) { assertThrows(IllegalArgumentException.class, () -> endpoint.proxy(topic, EchoService.class, codec)); assertThrows(IllegalArgumentException.class, () -> endpoint.register(topic, EchoService.class, value -> value, codec)); } assertThrows(NullPointerException.class, () -> endpoint.proxy(null, EchoService.class, codec)); assertThrows(NullPointerException.class, () -> endpoint.proxy("echo", null, codec)); - assertThrows(NullPointerException.class, () -> endpoint.proxy("echo", EchoService.class, (RpcCodec) null)); + assertThrows(NullPointerException.class, () -> endpoint.proxy("echo", EchoService.class, (MessagingRpcCodec) null)); assertThrows(NullPointerException.class, () -> endpoint.register(null, EchoService.class, value -> value, codec)); assertThrows(NullPointerException.class, () -> endpoint.register("echo", EchoService.class, null, codec)); assertThrows(NullPointerException.class, () -> endpoint.register("echo", EchoService.class, value -> value, null)); @@ -854,27 +862,28 @@ void rejectsInvalidTopicsNullArgumentsAndOptionsBeforeSubscribingOrSending() thr @Test void propagatesSubscriptionFailureWithoutSending() { - var expected = new RequestReplyException(RequestReplyException.Reason.DISCONNECTED); - var transport = new RequestReplyTransport>() { + var expected = new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED); + var transport = new MessagingRequestReplyTransport>() { @NotNull @Override - public MessageSubscription subscribe( + public MessagingSubscription subscribe( @NotNull UUID endpointId, - @NotNull Listener> listener - ) throws RequestReplyException { + @NotNull Listener> listener + ) throws MessagingRequestReplyException { throw expected; } @NotNull @Override - public CompletionStage send(@NotNull RequestReplyMessage> message) { + public CompletionStage send(@NotNull MessagingRequestReplyMessage> message) { throw new AssertionError("Subscription failure must prevent sending"); } }; - try (var endpoint = RpcEndpoint.open(topic -> transport, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(topic -> transport, OPTIONS)) { assertSame(expected, - failure(RpcException.Reason.DISCONNECTED, () -> endpoint.proxy("echo", EchoService.class, new TestCodec())).getCause()); + failure(MessagingRpcException.Reason.DISCONNECTED, + () -> endpoint.proxy("echo", EchoService.class, new TestCodec())).getCause()); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetailsTest.java similarity index 68% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetailsTest.java index c60302f..3051824 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionDetailsTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetailsTest.java @@ -33,7 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @Timeout(10) -class RpcExceptionDetailsTest { +class MessagingRpcExceptionDetailsTest { @Test void capturesStandardTextIncludingCausesSuppressedExceptionsAndOriginalFrames() { var cause = new IllegalArgumentException("Invalid argument"); @@ -46,7 +46,7 @@ void capturesStandardTextIncludingCausesSuppressedExceptionsAndOriginalFrames() StringWriter expected = new StringWriter(); failure.printStackTrace(new PrintWriter(expected)); - RpcExceptionDetails details = RpcExceptionDetails.capture(failure); + MessagingRpcExceptionDetails details = MessagingRpcExceptionDetails.capture(failure); assertEquals(IllegalStateException.class.getName(), details.type()); assertEquals("Operation failed", details.message()); @@ -65,7 +65,7 @@ void preservesNullMessagesAndTerminatesCircularExceptionGraphs() { failure.initCause(cause); failure.addSuppressed(cause); - RpcExceptionDetails details = RpcExceptionDetails.capture(failure); + MessagingRpcExceptionDetails details = MessagingRpcExceptionDetails.capture(failure); assertNull(details.message()); assertTrue(details.stackTrace().contains("CIRCULAR REFERENCE")); @@ -74,18 +74,18 @@ void preservesNullMessagesAndTerminatesCircularExceptionGraphs() { @Test void boundsMessagesAndTraceTextWithoutSplittingSurrogatePairs() { - String message = "m".repeat(RpcExceptionDetails.MAX_MESSAGE_LENGTH - 1) + "😀tail"; + String message = "m".repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH - 1) + "😀tail"; var failure = new IllegalStateException(message) { @Override public void printStackTrace(@NotNull PrintWriter writer) { - writer.print("s".repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH - 1) + "😀tail"); + writer.print("s".repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH - 1) + "😀tail"); } }; - RpcExceptionDetails details = RpcExceptionDetails.capture(failure); + MessagingRpcExceptionDetails details = MessagingRpcExceptionDetails.capture(failure); - assertEquals("m".repeat(RpcExceptionDetails.MAX_MESSAGE_LENGTH - 1), details.message()); - assertEquals("s".repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH - 1), details.stackTrace()); + assertEquals("m".repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH - 1), details.message()); + assertEquals("s".repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH - 1), details.stackTrace()); assertTrue(details.truncated()); } @@ -95,7 +95,7 @@ void stopsFormattingAtTheLimitInsteadOfBuildingAnUnboundedString() { var failure = new IllegalStateException("Failure") { @Override public void printStackTrace(@NotNull PrintWriter writer) { - writer.print("x".repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH)); + writer.print("x".repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH)); progress.incrementAndGet(); writer.write(new char[] {'y'}, 0, 1); progress.incrementAndGet(); @@ -103,9 +103,9 @@ public void printStackTrace(@NotNull PrintWriter writer) { } }; - RpcExceptionDetails details = RpcExceptionDetails.capture(failure); + MessagingRpcExceptionDetails details = MessagingRpcExceptionDetails.capture(failure); - assertEquals(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH, details.stackTrace().length()); + assertEquals(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH, details.stackTrace().length()); assertEquals(1, progress.get()); assertTrue(details.truncated()); } @@ -120,7 +120,7 @@ public String getMessage() { } }; - RpcExceptionDetails details = RpcExceptionDetails.capture(failure); + MessagingRpcExceptionDetails details = MessagingRpcExceptionDetails.capture(failure); assertEquals(failure.getClass().getName(), details.type()); assertNull(details.message()); @@ -130,18 +130,20 @@ public String getMessage() { @Test void validatesAllBoundsWithoutRejectingTheirExactLimits() { - var details = new RpcExceptionDetails("t".repeat(RpcExceptionDetails.MAX_TYPE_LENGTH), - "m".repeat(RpcExceptionDetails.MAX_MESSAGE_LENGTH), "s".repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH), false); + var details = new MessagingRpcExceptionDetails("t".repeat(MessagingRpcExceptionDetails.MAX_TYPE_LENGTH), + "m".repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH), + "s".repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH), false); assertFalse(details.truncated()); - assertThrows(NullPointerException.class, () -> new RpcExceptionDetails(null, null, "", false)); - assertThrows(NullPointerException.class, () -> new RpcExceptionDetails("type", null, null, false)); - assertThrows(IllegalArgumentException.class, () -> new RpcExceptionDetails(" ", null, "", false)); + assertThrows(NullPointerException.class, () -> new MessagingRpcExceptionDetails(null, null, "", false)); + assertThrows(NullPointerException.class, () -> new MessagingRpcExceptionDetails("type", null, null, false)); + assertThrows(IllegalArgumentException.class, () -> new MessagingRpcExceptionDetails(" ", null, "", false)); assertThrows(IllegalArgumentException.class, - () -> new RpcExceptionDetails("t".repeat(RpcExceptionDetails.MAX_TYPE_LENGTH + 1), null, "", false)); + () -> new MessagingRpcExceptionDetails("t".repeat(MessagingRpcExceptionDetails.MAX_TYPE_LENGTH + 1), null, "", false)); assertThrows(IllegalArgumentException.class, - () -> new RpcExceptionDetails("type", "m".repeat(RpcExceptionDetails.MAX_MESSAGE_LENGTH + 1), "", false)); + () -> new MessagingRpcExceptionDetails("type", "m".repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH + 1), "", false)); assertThrows(IllegalArgumentException.class, - () -> new RpcExceptionDetails("type", null, "s".repeat(RpcExceptionDetails.MAX_STACK_TRACE_LENGTH + 1), false)); + () -> new MessagingRpcExceptionDetails("type", null, + "s".repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH + 1), false)); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionTest.java similarity index 80% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionTest.java index 565e35a..e0e2006 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcExceptionTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionTest.java @@ -28,18 +28,18 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; -class RpcExceptionTest { +class MessagingRpcExceptionTest { @Test void includesRemoteTextInOrdinaryExceptionLoggingWithoutLoadingTheRemoteClass() { - var details = new RpcExceptionDetails("unavailable.application.Failure", "Application failed", + var details = new MessagingRpcExceptionDetails("unavailable.application.Failure", "Application failed", "unavailable.application.Failure: Application failed\n\tat remote.Receiver.call(Receiver.java:42)", true); - var failure = new RpcException(RpcMessage.Error.HANDLER_FAILED, details); + var failure = new MessagingRpcException(MessagingRpcMessage.Error.HANDLER_FAILED, details); StringWriter printed = new StringWriter(); failure.printStackTrace(new PrintWriter(printed)); - assertEquals(RpcException.Reason.REMOTE_ERROR, failure.reason()); - assertEquals(RpcMessage.Error.HANDLER_FAILED, failure.remoteError()); + assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason()); + assertEquals(MessagingRpcMessage.Error.HANDLER_FAILED, failure.remoteError()); assertSame(details, failure.remoteException()); assertTrue(failure.getMessage().contains(details.type())); assertTrue(failure.getMessage().contains(details.message())); @@ -52,13 +52,13 @@ void includesRemoteTextInOrdinaryExceptionLoggingWithoutLoadingTheRemoteClass() @Test void preservesCodeOnlyErrorsAndLocalCausesWithoutInventingRemoteDiagnostics() { - var remote = new RpcException(RpcMessage.Error.UNKNOWN_SERVICE); + var remote = new MessagingRpcException(MessagingRpcMessage.Error.UNKNOWN_SERVICE); assertEquals("Remote RPC call failed: UNKNOWN_SERVICE", remote.getMessage()); assertNull(remote.getCause()); assertNull(remote.remoteException()); var cause = new IllegalStateException("Local failure"); - var local = new RpcException(RpcException.Reason.SEND_FAILED, cause); + var local = new MessagingRpcException(MessagingRpcException.Reason.SEND_FAILED, cause); assertSame(cause, local.getCause()); assertNull(local.remoteException()); assertNull(local.remoteError()); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java new file mode 100644 index 0000000..1a62f3b --- /dev/null +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java @@ -0,0 +1,110 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.messaging.rpc; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@Timeout(10) +class MessagingRpcMessageTest { + @Test + void preservesOpaquePayloadsAndAllowsNullApplicationValues() { + Object payload = new Object(); + MessagingRpcMessage request = MessagingRpcMessage.request("echo(java.lang.String)", payload); + + assertEquals(MessagingRpcMessage.Type.REQUEST, request.type()); + assertEquals("echo(java.lang.String)", request.operation()); + assertSame(payload, request.payload()); + assertNull(request.error()); + assertNull(MessagingRpcMessage.request("echo(java.lang.String)", null).payload()); + + MessagingRpcMessage response = MessagingRpcMessage.response(payload); + assertEquals(MessagingRpcMessage.Type.RESPONSE, response.type()); + assertSame(payload, response.payload()); + assertNull(response.operation()); + assertNull(response.error()); + assertEquals(new MessagingRpcMessage<>(MessagingRpcMessage.Type.RESPONSE, null, null, null, null), + MessagingRpcMessage.response(null)); + } + + @Test + void failuresCanCarryACodeWithoutExceptionDetails() { + for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { + MessagingRpcMessage failure = MessagingRpcMessage.failure(error); + + assertEquals(MessagingRpcMessage.Type.ERROR, failure.type()); + assertEquals(error, failure.error()); + assertNull(failure.operation()); + assertNull(failure.payload()); + assertNull(failure.exception()); + } + assertThrows(NullPointerException.class, () -> MessagingRpcMessage.failure(null)); + } + + @Test + void exceptionDetailsBelongOnlyToErrorResponsesAndNeverToApplicationPayloads() { + var details = new MessagingRpcExceptionDetails("test.ApplicationException", "failure", "remote stack trace", false); + MessagingRpcMessage failure = MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED, details); + + assertSame(details, failure.exception()); + assertNull(failure.payload()); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED), + MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED, null)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.REQUEST, "echo(int)", 1, null, details)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.RESPONSE, null, 1, null, details)); + } + + @Test + void validatesOperationBoundsWithoutRejectingTheBoundary() { + String operation = "o".repeat(1024); + MessagingRpcMessage request = MessagingRpcMessage.request(operation, null); + assertEquals(operation, request.operation()); + + for (String invalid : List.of("", " ", "\n", "o".repeat(1025))) { + assertThrows(IllegalArgumentException.class, () -> MessagingRpcMessage.request(invalid, null)); + } + assertThrows(IllegalArgumentException.class, () -> MessagingRpcMessage.request(null, null)); + assertThrows(NullPointerException.class, () -> new MessagingRpcMessage<>(null, null, null, null, null)); + } + + @Test + void rejectsRoutingFieldsOnRepliesAndInconsistentPayloadOrErrorFields() { + for (MessagingRpcMessage.Type type : List.of(MessagingRpcMessage.Type.RESPONSE, MessagingRpcMessage.Type.ERROR)) { + MessagingRpcMessage.Error error = type == MessagingRpcMessage.Type.ERROR ? MessagingRpcMessage.Error.HANDLER_FAILED : null; + assertThrows(IllegalArgumentException.class, () -> new MessagingRpcMessage<>(type, "echo(int)", null, error, null)); + } + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.ERROR, null, null, null, null)); + for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.REQUEST, "echo(int)", null, error, null)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.RESPONSE, null, null, error, null)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.ERROR, null, "private payload", error, null)); + } + } +} diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcTestSupport.java similarity index 72% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcTestSupport.java index 6d654f9..e309955 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcTestSupport.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcTestSupport.java @@ -16,10 +16,10 @@ */ package com.dbeaver.messaging.rpc; -import com.dbeaver.messaging.requestreply.MessageSubscription; -import com.dbeaver.messaging.requestreply.RequestReplyMessage; -import com.dbeaver.messaging.requestreply.RequestReplyOptions; -import com.dbeaver.messaging.requestreply.RequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; +import com.dbeaver.messaging.requestreply.MessagingSubscription; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; import org.junit.jupiter.api.AfterEach; @@ -53,9 +53,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -class RpcTestSupport { +class MessagingRpcTestSupport { static final Duration WAIT = Duration.ofSeconds(5); - static final RequestReplyOptions OPTIONS = new RequestReplyOptions(WAIT, 16, 16); + static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(WAIT, 16, 16); private final List callers = new CopyOnWriteArrayList<>(); @AfterEach @@ -86,23 +86,23 @@ static T result(@NotNull Future future) throws InterruptedException, Exec } @NotNull - static RpcException failure(@NotNull RpcException.Reason reason, @NotNull Executable action) { - RpcException failure = assertThrows(RpcException.class, action); + static MessagingRpcException failure(@NotNull MessagingRpcException.Reason reason, @NotNull Executable action) { + MessagingRpcException failure = assertThrows(MessagingRpcException.class, action); assertEquals(reason, failure.reason()); return failure; } @NotNull - static RpcException failure(@NotNull RpcException.Reason reason, @NotNull Future future) { + static MessagingRpcException failure(@NotNull MessagingRpcException.Reason reason, @NotNull Future future) { ExecutionException wrapper = assertThrows(ExecutionException.class, () -> result(future)); - RpcException failure = assertInstanceOf(RpcException.class, wrapper.getCause()); + MessagingRpcException failure = assertInstanceOf(MessagingRpcException.class, wrapper.getCause()); assertEquals(reason, failure.reason()); return failure; } @NotNull - static RpcException remoteFailure(@NotNull RpcMessage.Error error, @NotNull Executable action) { - RpcException failure = failure(RpcException.Reason.REMOTE_ERROR, action); + static MessagingRpcException remoteFailure(@NotNull MessagingRpcMessage.Error error, @NotNull Executable action) { + MessagingRpcException failure = failure(MessagingRpcException.Reason.REMOTE_ERROR, action); assertEquals(error, failure.remoteError()); if (failure.remoteException() == null) { assertNull(failure.getCause()); @@ -139,7 +139,7 @@ interface Conversion { record CodecCall(@Nullable Object value, @NotNull Type type) { } - static final class TestCodec implements RpcCodec { + static final class TestCodec implements MessagingRpcCodec { final List encoded = new CopyOnWriteArrayList<>(); final List decoded = new CopyOnWriteArrayList<>(); volatile Conversion encoder = (value, type) -> value; @@ -160,22 +160,22 @@ public Object decode(@Nullable Object payload, @NotNull Type type) throws Except } } - static final class MemoryTransport implements RpcTransport { - final Map>> listeners = new ConcurrentHashMap<>(); + static final class MemoryTransport implements MessagingRpcTransport { + final Map>> listeners = new ConcurrentHashMap<>(); final Map listenerTopics = new ConcurrentHashMap<>(); final Map routes = new ConcurrentHashMap<>(); - final BlockingQueue>> sent = new LinkedBlockingQueue<>(); + final BlockingQueue>> sent = new LinkedBlockingQueue<>(); final AtomicInteger subscriptionCloses = new AtomicInteger(); - volatile Function>, CompletionStage> sender = + volatile Function>, CompletionStage> sender = message -> CompletableFuture.completedFuture(null); @NotNull @Override - public RequestReplyTransport> forTopic(@NotNull String topic) { - return new RequestReplyTransport<>() { + public MessagingRequestReplyTransport> forTopic(@NotNull String topic) { + return new MessagingRequestReplyTransport<>() { @NotNull @Override - public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) { + public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener) { assertNull(listeners.putIfAbsent(endpointId, listener)); listenerTopics.put(endpointId, topic); return () -> { @@ -188,7 +188,7 @@ public MessageSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener @NotNull @Override - public CompletionStage send(@NotNull RequestReplyMessage> message) { + public CompletionStage send(@NotNull MessagingRequestReplyMessage> message) { routes.put(message.requestId(), topic); sent.add(message); return sender.apply(message); @@ -196,23 +196,23 @@ public CompletionStage send(@NotNull RequestReplyMessage> message) { + void deliver(@NotNull MessagingRequestReplyMessage> message) { deliver(routes.get(message.requestId()), message); } - void deliver(@NotNull String topic, @NotNull RequestReplyMessage> message) { + void deliver(@NotNull String topic, @NotNull MessagingRequestReplyMessage> message) { routes.put(message.requestId(), topic); List.copyOf(listeners.entrySet()).stream().filter(entry -> topic.equals(listenerTopics.get(entry.getKey()))) .forEach(entry -> entry.getValue().onMessage(message)); } void disconnect() { - List.copyOf(listeners.values()).forEach(RequestReplyTransport.Listener::onDisconnect); + List.copyOf(listeners.values()).forEach(MessagingRequestReplyTransport.Listener::onDisconnect); } @NotNull - RequestReplyMessage> next() throws InterruptedException { - RequestReplyMessage> message = sent.poll(WAIT.toMillis(), TimeUnit.MILLISECONDS); + MessagingRequestReplyMessage> next() throws InterruptedException { + MessagingRequestReplyMessage> message = sent.poll(WAIT.toMillis(), TimeUnit.MILLISECONDS); assertNotNull(message, "No message was sent"); return message; } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java deleted file mode 100644 index 34e322e..0000000 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/RpcMessageTest.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * DBeaver - Universal Database Manager - * Copyright (C) 2010-2026 DBeaver Corp - * - * All Rights Reserved. - * - * NOTICE: All information contained herein is, and remains - * the property of DBeaver Corp and its suppliers, if any. - * The intellectual and technical concepts contained - * herein are proprietary to DBeaver Corp and its suppliers - * and may be covered by U.S. and Foreign Patents, - * patents in process, and are protected by trade secret or copyright law. - * Dissemination of this information or reproduction of this material - * is strictly forbidden unless prior written permission is obtained - * from DBeaver Corp. - */ -package com.dbeaver.messaging.rpc; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@Timeout(10) -class RpcMessageTest { - @Test - void preservesOpaquePayloadsAndAllowsNullApplicationValues() { - Object payload = new Object(); - RpcMessage request = RpcMessage.request("echo(java.lang.String)", payload); - - assertEquals(RpcMessage.Type.REQUEST, request.type()); - assertEquals("echo(java.lang.String)", request.operation()); - assertSame(payload, request.payload()); - assertNull(request.error()); - assertNull(RpcMessage.request("echo(java.lang.String)", null).payload()); - - RpcMessage response = RpcMessage.response(payload); - assertEquals(RpcMessage.Type.RESPONSE, response.type()); - assertSame(payload, response.payload()); - assertNull(response.operation()); - assertNull(response.error()); - assertEquals(new RpcMessage<>(RpcMessage.Type.RESPONSE, null, null, null, null), RpcMessage.response(null)); - } - - @Test - void failuresCanCarryACodeWithoutExceptionDetails() { - for (RpcMessage.Error error : RpcMessage.Error.values()) { - RpcMessage failure = RpcMessage.failure(error); - - assertEquals(RpcMessage.Type.ERROR, failure.type()); - assertEquals(error, failure.error()); - assertNull(failure.operation()); - assertNull(failure.payload()); - assertNull(failure.exception()); - } - assertThrows(NullPointerException.class, () -> RpcMessage.failure(null)); - } - - @Test - void exceptionDetailsBelongOnlyToErrorResponsesAndNeverToApplicationPayloads() { - var details = new RpcExceptionDetails("test.ApplicationException", "failure", "remote stack trace", false); - RpcMessage failure = RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED, details); - - assertSame(details, failure.exception()); - assertNull(failure.payload()); - assertEquals(RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED), RpcMessage.failure(RpcMessage.Error.HANDLER_FAILED, null)); - assertThrows(IllegalArgumentException.class, - () -> new RpcMessage<>(RpcMessage.Type.REQUEST, "echo(int)", 1, null, details)); - assertThrows(IllegalArgumentException.class, - () -> new RpcMessage<>(RpcMessage.Type.RESPONSE, null, 1, null, details)); - } - - @Test - void validatesOperationBoundsWithoutRejectingTheBoundary() { - String operation = "o".repeat(1024); - RpcMessage request = RpcMessage.request(operation, null); - assertEquals(operation, request.operation()); - - for (String invalid : List.of("", " ", "\n", "o".repeat(1025))) { - assertThrows(IllegalArgumentException.class, () -> RpcMessage.request(invalid, null)); - } - assertThrows(IllegalArgumentException.class, () -> RpcMessage.request(null, null)); - assertThrows(NullPointerException.class, () -> new RpcMessage<>(null, null, null, null, null)); - } - - @Test - void rejectsRoutingFieldsOnRepliesAndInconsistentPayloadOrErrorFields() { - for (RpcMessage.Type type : List.of(RpcMessage.Type.RESPONSE, RpcMessage.Type.ERROR)) { - RpcMessage.Error error = type == RpcMessage.Type.ERROR ? RpcMessage.Error.HANDLER_FAILED : null; - assertThrows(IllegalArgumentException.class, () -> new RpcMessage<>(type, "echo(int)", null, error, null)); - } - assertThrows(IllegalArgumentException.class, () -> new RpcMessage<>(RpcMessage.Type.ERROR, null, null, null, null)); - for (RpcMessage.Error error : RpcMessage.Error.values()) { - assertThrows(IllegalArgumentException.class, - () -> new RpcMessage<>(RpcMessage.Type.REQUEST, "echo(int)", null, error, null)); - assertThrows(IllegalArgumentException.class, - () -> new RpcMessage<>(RpcMessage.Type.RESPONSE, null, null, error, null)); - assertThrows(IllegalArgumentException.class, - () -> new RpcMessage<>(RpcMessage.Type.ERROR, null, "private payload", error, null)); - } - } -} From b557275fe2efce289fe055a47668cc895df13890 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Mon, 26 Oct 2026 15:51:58 +0400 Subject: [PATCH 24/30] dbeaver/pro#11108 Rename DataDam messaging API packages and types --- README.md | 4 +- .../META-INF/MANIFEST.MF | 14 +- .../README.md | 37 ++-- .../api/DDMessagingApi.java} | 6 +- .../api/DDMessagingProtocol.java} | 40 ++-- .../client/DDMessagingCallbackClient.java} | 38 ++-- .../api/client/DDMessagingClient.java} | 71 ++++--- .../api/client/DDMessagingGraphQlClient.java} | 46 ++-- .../api/exception/DDMessagingException.java} | 8 +- .../api/model/DDMessagingConnection.java} | 16 +- .../api/model/DDMessagingConnectionInfo.java} | 12 +- .../model/DDMessagingHandshakeRequest.java} | 14 +- .../model/DDMessagingHandshakeResponse.java} | 8 +- .../api/model/DDMessagingMessage.java} | 6 +- .../DDMessagingRequestReplyTransport.java} | 58 ++--- .../api/rpc/DDMessagingGsonRpcCodec.java} | 6 +- .../api/rpc/DDMessagingRpcTransport.java} | 25 +-- .../api/transport/DDMessagingChannel.java} | 16 +- .../transport/DDMessagingChannelState.java} | 4 +- .../transport/DDMessagingChannelStatus.java} | 12 +- .../DDMessagingConnectionListener.java} | 10 +- .../DDMessagingMessageListener.java} | 10 +- .../transport/DDMessagingSubscription.java} | 4 +- .../api/DDMessagingApiTest.java} | 32 +-- .../api/DDMessagingProtocolTest.java} | 86 ++++---- .../DDMessagingCallbackClientTest.java} | 54 ++--- .../api/client/DDMessagingClientTest.java} | 93 ++++---- .../api/model/DDMessagingConnectionTest.java} | 10 +- .../api/model/DDMessagingMessageTest.java} | 18 +- ...DDMessagingRequestReplyTransportTest.java} | 199 +++++++++--------- .../api/rpc/DDMessagingGsonRpcCodecTest.java} | 10 +- .../DDMessagingRpcTopicsIntegrationTest.java} | 59 +++--- .../api/rpc/DDMessagingRpcTransportTest.java} | 161 +++++++------- .../DDMessagingChannelStatusTest.java} | 18 +- 34 files changed, 612 insertions(+), 593 deletions(-) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/DDExternalDamApi.java => messaging/api/DDMessagingApi.java} (95%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/DDExternalDamProtocol.java => messaging/api/DDMessagingProtocol.java} (75%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/client/DDExternalDamCallbackClient.java => messaging/api/client/DDMessagingCallbackClient.java} (70%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/client/DDExternalDamClient.java => messaging/api/client/DDMessagingClient.java} (81%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/client/DDExternalDamGraphQlClient.java => messaging/api/client/DDMessagingGraphQlClient.java} (83%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/exception/DDExternalDamException.java => messaging/api/exception/DDMessagingException.java} (75%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/model/DDExternalDamConnection.java => messaging/api/model/DDMessagingConnection.java} (64%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/model/DDConnectionInfo.java => messaging/api/model/DDMessagingConnectionInfo.java} (69%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/model/DDHello.java => messaging/api/model/DDMessagingHandshakeRequest.java} (66%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/model/DDReady.java => messaging/api/model/DDMessagingHandshakeResponse.java} (74%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/model/DDMessage.java => messaging/api/model/DDMessagingMessage.java} (91%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/requestreply/DDRequestReplyTransport.java => messaging/api/requestreply/DDMessagingRequestReplyTransport.java} (87%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/rpc/DDGsonRpcCodec.java => messaging/api/rpc/DDMessagingGsonRpcCodec.java} (88%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/rpc/DDRpcTransport.java => messaging/api/rpc/DDMessagingRpcTransport.java} (90%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/transport/DDMessageChannel.java => messaging/api/transport/DDMessagingChannel.java} (61%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/transport/DDChannelState.java => messaging/api/transport/DDMessagingChannelState.java} (88%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/transport/DDChannelStatus.java => messaging/api/transport/DDMessagingChannelStatus.java} (67%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/transport/DDConnectionListener.java => messaging/api/transport/DDMessagingConnectionListener.java} (69%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/transport/DDMessageListener.java => messaging/api/transport/DDMessagingMessageListener.java} (68%) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/{external/api/transport/DDSubscription.java => messaging/api/transport/DDMessagingSubscription.java} (86%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/{external/api/DDExternalDamApiTest.java => messaging/api/DDMessagingApiTest.java} (68%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/{external/api/DDExternalDamProtocolTest.java => messaging/api/DDMessagingProtocolTest.java} (61%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/{external/api/client/DDExternalDamCallbackClientTest.java => messaging/api/client/DDMessagingCallbackClientTest.java} (89%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/{external/api/client/DDExternalDamClientTest.java => messaging/api/client/DDMessagingClientTest.java} (93%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/{external/api/model/DDExternalDamConnectionTest.java => messaging/api/model/DDMessagingConnectionTest.java} (77%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/{external/api/model/DDMessageTest.java => messaging/api/model/DDMessagingMessageTest.java} (78%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/{external/api/requestreply/DDRequestReplyTransportTest.java => messaging/api/requestreply/DDMessagingRequestReplyTransportTest.java} (78%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/{external/api/rpc/DDGsonRpcCodecTest.java => messaging/api/rpc/DDMessagingGsonRpcCodecTest.java} (91%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/{external/api/rpc/DDRpcTopicsIntegrationTest.java => messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java} (74%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/{external/api/rpc/DDRpcTransportTest.java => messaging/api/rpc/DDMessagingRpcTransportTest.java} (90%) rename apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/{external/api/transport/DDChannelStatusTest.java => messaging/api/transport/DDMessagingChannelStatusTest.java} (62%) diff --git a/README.md b/README.md index c870873..d4060eb 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ Used by DBeaver, dbvr, CloudBeaver and by DataDam itself ## Messaging API [`apis/com.dbeaver.datadam.messaging.api`](apis/com.dbeaver.datadam.messaging.api/README.md) contains the External DAM -contracts, clients and transport adapters, together with the messaging implementation. Java packages remain -`com.dbeaver.datadam.external.api.*` and `com.dbeaver.messaging.*`; messaging is not a separate Maven or OSGi module. +contracts, clients and transport adapters, together with the messaging implementation. Java packages are +`com.dbeaver.datadam.messaging.api.*` and `com.dbeaver.messaging.*`; messaging is not a separate Maven or OSGi module. ## SSO API diff --git a/apis/com.dbeaver.datadam.messaging.api/META-INF/MANIFEST.MF b/apis/com.dbeaver.datadam.messaging.api/META-INF/MANIFEST.MF index eaeffea..fe34762 100644 --- a/apis/com.dbeaver.datadam.messaging.api/META-INF/MANIFEST.MF +++ b/apis/com.dbeaver.datadam.messaging.api/META-INF/MANIFEST.MF @@ -5,13 +5,13 @@ Bundle-Name: DataDam Messaging API Bundle-SymbolicName: com.dbeaver.datadam.messaging.api Bundle-Version: 0.0.17.qualifier Bundle-RequiredExecutionEnvironment: JavaSE-21 -Export-Package: com.dbeaver.datadam.external.api, - com.dbeaver.datadam.external.api.client, - com.dbeaver.datadam.external.api.exception, - com.dbeaver.datadam.external.api.model, - com.dbeaver.datadam.external.api.requestreply, - com.dbeaver.datadam.external.api.rpc, - com.dbeaver.datadam.external.api.transport, +Export-Package: com.dbeaver.datadam.messaging.api, + com.dbeaver.datadam.messaging.api.client, + com.dbeaver.datadam.messaging.api.exception, + com.dbeaver.datadam.messaging.api.model, + com.dbeaver.datadam.messaging.api.requestreply, + com.dbeaver.datadam.messaging.api.rpc, + com.dbeaver.datadam.messaging.api.transport, com.dbeaver.messaging.requestreply, com.dbeaver.messaging.rpc Bundle-ActivationPolicy: lazy diff --git a/apis/com.dbeaver.datadam.messaging.api/README.md b/apis/com.dbeaver.datadam.messaging.api/README.md index 49f8680..a60d81d 100644 --- a/apis/com.dbeaver.datadam.messaging.api/README.md +++ b/apis/com.dbeaver.datadam.messaging.api/README.md @@ -4,7 +4,10 @@ The `com.dbeaver.datadam.messaging.api` module contains the Desktop WebSocket co the External DAM internal GraphQL API, and the transport-independent messaging packages. Neither the models nor the WebSocket protocol depend on the HTTP client, Spring, or Eclipse runtime services. -Java package names remain `com.dbeaver.datadam.external.api.*` and `com.dbeaver.messaging.*`. +The WebSocket handshake sends Desktop metadata in `DDMessagingHandshakeRequest` and returns the assigned connection ID +in `DDMessagingHandshakeResponse`. The corresponding wire control types are `hello` and `ready`. + +Java package names are `com.dbeaver.datadam.messaging.api.*` and `com.dbeaver.messaging.*`. See [Messaging](MESSAGING.md) for the request/reply and typed RPC contracts. ## Internal GraphQL contract @@ -55,7 +58,7 @@ input ExternalDamSubscriptionInput { Sending must use the account and connection ID together. - `externalDamConnectionAccount` resolves a unique physical connection ID to the account from its verified identity. It returns null for a missing or not-ready connection. The backend client reports that result as a checked - `DDExternalDamException`. This backend-only lookup does not authorize end-user access to that account. + `DDMessagingException`. This backend-only lookup does not authorize end-user access to that account. - Connection lists return an immutable client-side snapshot of currently ready sockets, not persisted DAM installations or history. - Concurrent sockets can share a Deployment ID and application run ID. Consumers must not deduplicate sockets using either ID. - Product and version are Desktop-supplied presentation metadata, not authenticated identity or authorization claims. @@ -63,13 +66,13 @@ input ExternalDamSubscriptionInput { - A send result of `true` means the server completed its WebSocket write. It does not confirm that Desktop received or processed the event. - A result of `false` means that no ready connection exists for that account and connection ID. This also covers a connection belonging to another account. - A connection closing after lookup, queue rejection, an invalid message, or a write failure produces a GraphQL error rather than a successful send. -- HTTP errors, GraphQL errors (including errors with partial data), and malformed responses become `DDExternalDamException`. +- HTTP errors, GraphQL errors (including errors with partial data), and malformed responses become `DDMessagingException`. - The client does not retry mutations or queue messages offline. After a timeout or network failure, delivery may be unknown; callers must not blindly replay non-idempotent events. - Connections live in one External DAM process. Until owner-aware routing exists, calls must reach the process owning the socket; a missing result from another replica does not prove global offline status. ## Authentication and configuration -`DDExternalDamClient` takes a service root URL and the External DAM service secret. It never logs in or acquires a Desktop token. +`DDMessagingClient` takes a service root URL and the External DAM service secret. It never logs in or acquires a Desktop token. The URL must use HTTP or HTTPS, have no credentials, query, fragment, or non-root path, and use a valid port. HTTP is supported for trusted private service networks; use HTTPS across untrusted networks. @@ -90,7 +93,7 @@ The secret belongs to backend runtime configuration only and must never be distr It is separate from the Account backchannel secret used to verify Desktop JWTs. ```java -var client = new DDExternalDamClient(serviceUrl, serviceSecret); +var client = new DDMessagingClient(serviceUrl, serviceSecret); var connections = client.getConnections(accountId); boolean written = client.sendMessage(accountId, connectionId, message); ``` @@ -117,7 +120,7 @@ Callback URLs follow the same HTTP/HTTPS root-URL validation as the broker URL, 32–512 printable, non-space ASCII validation as the service secret. Only trusted authenticated backends may register callbacks; restrict the broker's outbound network access and use HTTPS outside a trusted private network. -`DDExternalDamCallbackClient` delivers messages to the receiver's authenticated +`DDMessagingCallbackClient` delivers messages to the receiver's authenticated `POST /internal/external-dam/graphql` using the same External DAM service secret. Successful acknowledgement extends the lease. The receiver calls its listener directly on the HTTP request thread and acknowledges after it returns. Concurrent callbacks may invoke the same listener concurrently and have no ordering guarantee; implementations must @@ -130,7 +133,7 @@ HTTP. Its default deadline is 30 seconds, including listener execution, and an e Timeout does not promise cancellation of the remote handler. Spring services use the shared `com.dbeaver.datadam.external.base` module from the DataDam server repository. -It supplies the callback endpoint, local dispatch, `DDSubscription.close()` cancellation, and automatic renewal +It supplies the callback endpoint, local dispatch, `DDMessagingSubscription.close()` cancellation, and automatic renewal during idle periods. Configure `datadam.external-dam-client.service-url`, `datadam.external-dam-client.service-secret`, and `datadam.external-dam-client.callback-url` on Account and Storage. Set `service-secret` to the External DAM service secret; the library uses it for outgoing requests, incoming callback authentication, and the registration's @@ -147,7 +150,7 @@ the Desktop channel dispatches the message to its local topic listeners without ## Channel state -`DDMessageChannel.getStatus()` returns an immutable `DDChannelStatus`: `DISCONNECTED`, `CONNECTING`, `CONNECTED`, +`DDMessagingChannel.getStatus()` returns an immutable `DDMessagingChannelStatus`: `DISCONNECTED`, `CONNECTING`, `CONNECTED`, or `CLOSED`. Only `CONNECTED` contains connection information, and `status.isReady()` checks that state. `observeState(observer)` delivers the current state and subsequent changes in order without a snapshot/subscription race. Closing the returned subscription cancels only that observer. Initial notification can run synchronously; @@ -156,13 +159,13 @@ subsequent callbacks run on the notifying thread. Existing `addConnectionListene ## Request/reply The `com.dbeaver.messaging.requestreply` package supplies `MessagingRequestReplyEndpoint

`. It knows nothing about DataDam -accounts, deployment IDs, GraphQL, or JSON. `DDRequestReplyTransport` adapts an existing scoped DataDam channel to +accounts, deployment IDs, GraphQL, or JSON. `DDMessagingRequestReplyTransport` adapts an existing scoped DataDam channel to `MessagingRequestReplyEndpoint` without changing the channel's asynchronous message API. On Desktop, or inside External DAM over an existing physical channel: ```java -var transport = DDRequestReplyTransport.forChannel(channel, "documents"); +var transport = DDMessagingRequestReplyTransport.forChannel(channel, "documents"); try (var requests = MessagingRequestReplyEndpoint.open(transport)) { var response = requests.request(payload); } @@ -218,20 +221,20 @@ off a serialized receiver when they can make synchronous calls whose replies nee ## Typed RPC `com.dbeaver.messaging.rpc` adds typed Java proxies and registered implementations without depending on DataDam or -WebSocket. `DDRpcTransport` adapts the existing request/reply transport to the neutral `MessagingRpcMessage` envelope. +WebSocket. `DDMessagingRpcTransport` adapts the existing request/reply transport to the neutral `MessagingRpcMessage` envelope. It owns no execution service or application serializer; the developer selects serialization through `MessagingRpcCodec`. For example, a plugin can supply its own Gson instance and adapters for its proxies and implementations: ```java Gson gson = new Gson(); -MessagingRpcCodec codec = new DDGsonRpcCodec(gson); +MessagingRpcCodec codec = new DDMessagingGsonRpcCodec(gson); ``` The Desktop side installs an implementation and can independently obtain proxies for backend services: ```java -try (var rpc = MessagingRpcEndpoint.open(topic -> DDRpcTransport.forChannel(channel, topic, requestExecutor)); +try (var rpc = MessagingRpcEndpoint.open(topic -> DDMessagingRpcTransport.forChannel(channel, topic, requestExecutor)); var registration = rpc.register("desktop.projects", DesktopProjects.class, implementation, codec)) { ServerDocuments documents = rpc.proxy("server.documents", ServerDocuments.class, codec); serveApplication(documents); @@ -249,8 +252,8 @@ The backend selects its physical account/connection scope once and uses the same var scope = subscriptions.forConnection(accountId, connectionId); try (var rpc = MessagingRpcEndpoint.open(topic -> { try { - return new DDRpcTransport(scope.requestReply(topic)); - } catch (DDExternalDamException e) { + return new DDMessagingRpcTransport(scope.requestReply(topic)); + } catch (DDMessagingException e) { throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e); } }); @@ -267,7 +270,7 @@ on the other peer. The existing private reply topic routes each answer back to t The application must arrange the intended receiver's subscription before calling it; duplicate backend receivers on the same topic are not service discovery. Replica routing and exact callback-process ownership constraints still apply. -`DDGsonRpcCodec` retains the supplied `Gson` instance, including application adapters and naming policies, and preserves +`DDMessagingGsonRpcCodec` retains the supplied `Gson` instance, including application adapters and naming policies, and preserves the declared generic `Type` passed by RPC. Encoded JSON null is represented as a nullable application payload. The neutral messaging packages remain independent of Gson, and each proxy or registration can supply another compatible codec. The resulting `MessagingRpcEndpoint` is not parameterized by JSON or by any other payload representation. @@ -294,7 +297,7 @@ The configured subscriber ID identifies the callback destination and does not se Repeated lookups return the same open endpoint; callers do not construct adapters or pass account/connection IDs to each registration or remote call. -The Desktop and backend connection owners provide an uncustomized `DDGsonRpcCodec` as a default, so ordinary +The Desktop and backend connection owners provide an uncustomized `DDMessagingGsonRpcCodec` as a default, so ordinary contracts can use `rpc.proxy(topic, Contract.class)` or `rpc.register(topic, Contract.class, implementation)`. The overloads accepting a codec override it only for that proxy or implementation registration, without modifying another caller's serialization or requiring a shared application codec bean. diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/DDMessagingApi.java similarity index 95% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/DDMessagingApi.java index a1d7ea0..f67ce8e 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamApi.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/DDMessagingApi.java @@ -14,13 +14,13 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api; +package com.dbeaver.datadam.messaging.api; import org.jkiss.code.Nullable; import java.net.URI; -public final class DDExternalDamApi { +public final class DDMessagingApi { public static final String GRAPHQL_PATH = "/internal/graphql"; public static final String CALLBACK_GRAPHQL_PATH = "/internal/external-dam/graphql"; public static final String HEADER_SERVICE_TOKEN = "X-DD-Service-Token"; @@ -31,7 +31,7 @@ public final class DDExternalDamApi { private static final int MAX_SUBSCRIBER_ID_LENGTH = 256; private static final int MAX_SERVICE_URL_LENGTH = 4096; - private DDExternalDamApi() { + private DDMessagingApi() { } public static boolean isValidSubscriberId(@Nullable String subscriberId) { diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/DDMessagingProtocol.java similarity index 75% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/DDMessagingProtocol.java index c7b39c2..6720f99 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/DDExternalDamProtocol.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/DDMessagingProtocol.java @@ -14,21 +14,21 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api; +package com.dbeaver.datadam.messaging.api; -import com.dbeaver.datadam.external.api.model.DDHello; -import com.dbeaver.datadam.external.api.model.DDMessage; -import com.dbeaver.datadam.external.api.model.DDReady; +import com.dbeaver.datadam.messaging.api.model.DDMessagingHandshakeRequest; +import com.dbeaver.datadam.messaging.api.model.DDMessagingHandshakeResponse; +import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import org.jkiss.code.NotNull; -public final class DDExternalDamProtocol { +public final class DDMessagingProtocol { public static final String WEBSOCKET_PATH = "/ws"; - public static final String TYPE_HELLO = "hello"; - public static final String TYPE_READY = "ready"; + public static final String TYPE_HANDSHAKE_REQUEST = "hello"; + public static final String TYPE_HANDSHAKE_RESPONSE = "ready"; private static final String FIELD_TOPIC = "topic"; private static final String FIELD_TYPE = "type"; @@ -39,20 +39,20 @@ public final class DDExternalDamProtocol { private static final String FIELD_VERSION = "version"; private static final String FIELD_CONNECTION_ID = "connectionId"; - private DDExternalDamProtocol() { + private DDMessagingProtocol() { } @NotNull - public static String encode(@NotNull DDMessage message) { + public static String encode(@NotNull DDMessagingMessage message) { JsonObject envelope = envelope(message.type(), message.payload()); envelope.addProperty(FIELD_TOPIC, message.topic()); return envelope.toString(); } @NotNull - public static DDMessage decode(@NotNull String message) { + public static DDMessagingMessage decode(@NotNull String message) { JsonObject envelope = parseEnvelope(message); - return new DDMessage( + return new DDMessagingMessage( readString(envelope, FIELD_TOPIC), readString(envelope, FIELD_TYPE), readPayload(envelope) @@ -60,19 +60,19 @@ public static DDMessage decode(@NotNull String message) { } @NotNull - public static String encodeHello(@NotNull DDHello hello) { + public static String encodeHandshakeRequest(@NotNull DDMessagingHandshakeRequest hello) { JsonObject payload = new JsonObject(); payload.addProperty(FIELD_DEPLOYMENT_ID, hello.deploymentId()); payload.addProperty(FIELD_APPLICATION_RUN_ID, hello.applicationRunId()); payload.addProperty(FIELD_PRODUCT, hello.product()); payload.addProperty(FIELD_VERSION, hello.version()); - return envelope(TYPE_HELLO, payload).toString(); + return envelope(TYPE_HANDSHAKE_REQUEST, payload).toString(); } @NotNull - public static DDHello decodeHello(@NotNull String message) { - JsonObject payload = readControlPayload(message, TYPE_HELLO); - return new DDHello( + public static DDMessagingHandshakeRequest decodeHandshakeRequest(@NotNull String message) { + JsonObject payload = readControlPayload(message, TYPE_HANDSHAKE_REQUEST); + return new DDMessagingHandshakeRequest( readString(payload, FIELD_DEPLOYMENT_ID), readString(payload, FIELD_APPLICATION_RUN_ID), readString(payload, FIELD_PRODUCT), @@ -81,15 +81,15 @@ public static DDHello decodeHello(@NotNull String message) { } @NotNull - public static String encodeReady(@NotNull DDReady ready) { + public static String encodeHandshakeResponse(@NotNull DDMessagingHandshakeResponse ready) { JsonObject payload = new JsonObject(); payload.addProperty(FIELD_CONNECTION_ID, ready.connectionId()); - return envelope(TYPE_READY, payload).toString(); + return envelope(TYPE_HANDSHAKE_RESPONSE, payload).toString(); } @NotNull - public static DDReady decodeReady(@NotNull String message) { - return new DDReady(readString(readControlPayload(message, TYPE_READY), FIELD_CONNECTION_ID)); + public static DDMessagingHandshakeResponse decodeHandshakeResponse(@NotNull String message) { + return new DDMessagingHandshakeResponse(readString(readControlPayload(message, TYPE_HANDSHAKE_RESPONSE), FIELD_CONNECTION_ID)); } @NotNull diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java similarity index 70% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java index cc07c09..4ed2c54 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClient.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java @@ -14,12 +14,12 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.client; +package com.dbeaver.datadam.messaging.api.client; -import com.dbeaver.datadam.external.api.DDExternalDamApi; -import com.dbeaver.datadam.external.api.exception.DDExternalDamException; -import com.dbeaver.datadam.external.api.model.DDConnectionInfo; -import com.dbeaver.datadam.external.api.model.DDMessage; +import com.dbeaver.datadam.messaging.api.DDMessagingApi; +import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; +import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import com.google.gson.JsonElement; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; @@ -30,7 +30,7 @@ import java.util.UUID; import java.util.concurrent.CompletionStage; -public final class DDExternalDamCallbackClient extends DDExternalDamGraphQlClient { +public final class DDMessagingCallbackClient extends DDMessagingGraphQlClient { private static final String DELIVER_MUTATION = """ mutation DeliverExternalDamMessage($subscriptionId: ID!, $accountId: ID!, $connection: ExternalDamConnectionInfoInput!, $message: ExternalDamMessageInput!) { @@ -38,20 +38,20 @@ mutation DeliverExternalDamMessage($subscriptionId: ID!, $accountId: ID!, } """; - public DDExternalDamCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret) { + public DDMessagingCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret) { this(serviceUrl, serviceSecret, Duration.ofSeconds(30)); } - public DDExternalDamCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull Duration deliveryTimeout) { - super(serviceUrl, serviceSecret, DDExternalDamApi.CALLBACK_GRAPHQL_PATH, timeoutMillis(deliveryTimeout)); + public DDMessagingCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull Duration deliveryTimeout) { + super(serviceUrl, serviceSecret, DDMessagingApi.CALLBACK_GRAPHQL_PATH, timeoutMillis(deliveryTimeout)); } public void deliver( @NotNull UUID subscriptionId, @NotNull UUID accountId, - @NotNull DDConnectionInfo connection, - @NotNull DDMessage message - ) throws DDExternalDamException { + @NotNull DDMessagingConnectionInfo connection, + @NotNull DDMessagingMessage message + ) throws DDMessagingException { readDeliveryResult(request("DeliverExternalDamMessage", DELIVER_MUTATION, deliveryVariables(subscriptionId, accountId, connection, message), "deliverExternalDamMessage")); } @@ -60,20 +60,20 @@ public void deliver( public CompletionStage deliverAsync( @NotNull UUID subscriptionId, @NotNull UUID accountId, - @NotNull DDConnectionInfo connection, - @NotNull DDMessage message + @NotNull DDMessagingConnectionInfo connection, + @NotNull DDMessagingMessage message ) { return requestAsync("DeliverExternalDamMessage", DELIVER_MUTATION, deliveryVariables(subscriptionId, accountId, connection, message), "deliverExternalDamMessage", - DDExternalDamCallbackClient::readDeliveryResult); + DDMessagingCallbackClient::readDeliveryResult); } @NotNull private static Map deliveryVariables( @NotNull UUID subscriptionId, @NotNull UUID accountId, - @NotNull DDConnectionInfo connection, - @NotNull DDMessage message + @NotNull DDMessagingConnectionInfo connection, + @NotNull DDMessagingMessage message ) { return Map.of( "subscriptionId", Objects.requireNonNull(subscriptionId).toString(), @@ -83,9 +83,9 @@ public CompletionStage deliverAsync( } @Nullable - private static Void readDeliveryResult(@NotNull JsonElement result) throws DDExternalDamException { + private static Void readDeliveryResult(@NotNull JsonElement result) throws DDMessagingException { if (!readBoolean(result)) { - throw new DDExternalDamException("External DAM callback was not accepted"); + throw new DDMessagingException("External DAM callback was not accepted"); } return null; } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingClient.java similarity index 81% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingClient.java index 284c8b3..c0081e3 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamClient.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingClient.java @@ -14,12 +14,12 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.client; +package com.dbeaver.datadam.messaging.api.client; -import com.dbeaver.datadam.external.api.DDExternalDamApi; -import com.dbeaver.datadam.external.api.exception.DDExternalDamException; -import com.dbeaver.datadam.external.api.model.DDExternalDamConnection; -import com.dbeaver.datadam.external.api.model.DDMessage; +import com.dbeaver.datadam.messaging.api.DDMessagingApi; +import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; +import com.dbeaver.datadam.messaging.api.model.DDMessagingConnection; +import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import org.jkiss.code.NotNull; @@ -33,7 +33,7 @@ import java.util.UUID; import java.util.concurrent.CompletionStage; -public final class DDExternalDamClient extends DDExternalDamGraphQlClient { +public final class DDMessagingClient extends DDMessagingGraphQlClient { private static final int READ_TIMEOUT_MILLIS = 10_000; private static final String CONNECTIONS_FIELD = "externalDamConnections"; private static final String CONNECTION_ACCOUNT_FIELD = "externalDamConnectionAccount"; @@ -69,24 +69,24 @@ mutation UnregisterExternalDamSubscription($subscriberId: String!, $subscription } """; - public DDExternalDamClient(@NotNull String serviceUrl, @NotNull String serviceSecret) { - super(serviceUrl, serviceSecret, DDExternalDamApi.GRAPHQL_PATH, READ_TIMEOUT_MILLIS); + public DDMessagingClient(@NotNull String serviceUrl, @NotNull String serviceSecret) { + super(serviceUrl, serviceSecret, DDMessagingApi.GRAPHQL_PATH, READ_TIMEOUT_MILLIS); } @NotNull - public List getConnections(@NotNull UUID accountId) throws DDExternalDamException { + public List getConnections(@NotNull UUID accountId) throws DDMessagingException { return readConnections(request(CONNECTIONS_OPERATION, CONNECTIONS_QUERY, Map.of("accountId", Objects.requireNonNull(accountId).toString()), CONNECTIONS_FIELD)); } @NotNull - public CompletionStage> getConnectionsAsync(@NotNull UUID accountId) { + public CompletionStage> getConnectionsAsync(@NotNull UUID accountId) { return requestAsync(CONNECTIONS_OPERATION, CONNECTIONS_QUERY, - Map.of("accountId", Objects.requireNonNull(accountId).toString()), CONNECTIONS_FIELD, DDExternalDamClient::readConnections); + Map.of("accountId", Objects.requireNonNull(accountId).toString()), CONNECTIONS_FIELD, DDMessagingClient::readConnections); } @NotNull - public UUID getConnectionAccount(@NotNull String connectionId) throws DDExternalDamException { + public UUID getConnectionAccount(@NotNull String connectionId) throws DDMessagingException { return readConnectionAccount(request(CONNECTION_ACCOUNT_OPERATION, CONNECTION_ACCOUNT_QUERY, connectionAccountVariables(connectionId), CONNECTION_ACCOUNT_FIELD)); } @@ -94,11 +94,11 @@ public UUID getConnectionAccount(@NotNull String connectionId) throws DDExternal @NotNull public CompletionStage getConnectionAccountAsync(@NotNull String connectionId) { return requestAsync(CONNECTION_ACCOUNT_OPERATION, CONNECTION_ACCOUNT_QUERY, - connectionAccountVariables(connectionId), CONNECTION_ACCOUNT_FIELD, DDExternalDamClient::readConnectionAccount); + connectionAccountVariables(connectionId), CONNECTION_ACCOUNT_FIELD, DDMessagingClient::readConnectionAccount); } - public boolean sendMessage(@NotNull UUID accountId, @NotNull String connectionId, @NotNull DDMessage message) - throws DDExternalDamException { + public boolean sendMessage(@NotNull UUID accountId, @NotNull String connectionId, @NotNull DDMessagingMessage message) + throws DDMessagingException { return readBoolean(request(SEND_OPERATION, SEND_MUTATION, messageVariables(accountId, connectionId, message), SEND_FIELD)); } @@ -106,10 +106,10 @@ public boolean sendMessage(@NotNull UUID accountId, @NotNull String connectionId public CompletionStage sendMessageAsync( @NotNull UUID accountId, @NotNull String connectionId, - @NotNull DDMessage message + @NotNull DDMessagingMessage message ) { return requestAsync(SEND_OPERATION, SEND_MUTATION, messageVariables(accountId, connectionId, message), SEND_FIELD, - DDExternalDamGraphQlClient::readBoolean); + DDMessagingGraphQlClient::readBoolean); } public int registerSubscription( @@ -120,7 +120,7 @@ public int registerSubscription( @NotNull String topic, @NotNull String callbackUrl, @NotNull String callbackSecret - ) throws DDExternalDamException { + ) throws DDMessagingException { return readLease(request("RegisterExternalDamSubscription", REGISTER_MUTATION, subscriptionVariables(subscriberId, subscriptionId, accountId, connectionId, topic, callbackUrl, callbackSecret), "registerExternalDamSubscription")); @@ -138,10 +138,10 @@ public CompletionStage registerSubscriptionAsync( ) { return requestAsync("RegisterExternalDamSubscription", REGISTER_MUTATION, subscriptionVariables(subscriberId, subscriptionId, accountId, connectionId, topic, callbackUrl, callbackSecret), - "registerExternalDamSubscription", DDExternalDamClient::readLease); + "registerExternalDamSubscription", DDMessagingClient::readLease); } - public boolean unregisterSubscription(@NotNull String subscriberId, @NotNull UUID subscriptionId) throws DDExternalDamException { + public boolean unregisterSubscription(@NotNull String subscriberId, @NotNull UUID subscriptionId) throws DDMessagingException { return readBoolean(request("UnregisterExternalDamSubscription", UNREGISTER_MUTATION, unsubscriptionVariables(subscriberId, subscriptionId), "unregisterExternalDamSubscription")); } @@ -149,18 +149,19 @@ public boolean unregisterSubscription(@NotNull String subscriberId, @NotNull UUI @NotNull public CompletionStage unregisterSubscriptionAsync(@NotNull String subscriberId, @NotNull UUID subscriptionId) { return requestAsync("UnregisterExternalDamSubscription", UNREGISTER_MUTATION, - unsubscriptionVariables(subscriberId, subscriptionId), "unregisterExternalDamSubscription", DDExternalDamGraphQlClient::readBoolean); + unsubscriptionVariables(subscriberId, subscriptionId), "unregisterExternalDamSubscription", + DDMessagingGraphQlClient::readBoolean); } @NotNull - private static List readConnections(@NotNull JsonElement result) throws DDExternalDamException { + private static List readConnections(@NotNull JsonElement result) throws DDMessagingException { if (!result.isJsonArray()) { throw invalidResponse(); } - List connections = new ArrayList<>(); + List connections = new ArrayList<>(); for (JsonElement value : result.getAsJsonArray()) { JsonObject connection = readObject(value); - connections.add(new DDExternalDamConnection( + connections.add(new DDMessagingConnection( readString(connection, "deploymentId"), readString(connection, "applicationRunId"), readString(connection, "connectionId"), readString(connection, "product"), readString(connection, "version") )); @@ -169,7 +170,7 @@ private static List readConnections(@NotNull JsonElemen } @NotNull - private static UUID readConnectionAccount(@NotNull JsonElement result) throws DDExternalDamException { + private static UUID readConnectionAccount(@NotNull JsonElement result) throws DDMessagingException { if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isString()) { throw invalidResponse(); } @@ -185,14 +186,14 @@ private static UUID readConnectionAccount(@NotNull JsonElement result) throws DD return accountId; } - private static int readLease(@NotNull JsonElement result) throws DDExternalDamException { + private static int readLease(@NotNull JsonElement result) throws DDMessagingException { if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isNumber()) { throw invalidResponse(); } try { int leaseSeconds = result.getAsBigDecimal().intValueExact(); - if (leaseSeconds >= DDExternalDamApi.MIN_SUBSCRIPTION_LEASE_SECONDS - && leaseSeconds <= DDExternalDamApi.MAX_SUBSCRIPTION_LEASE_SECONDS) { + if (leaseSeconds >= DDMessagingApi.MIN_SUBSCRIPTION_LEASE_SECONDS + && leaseSeconds <= DDMessagingApi.MAX_SUBSCRIPTION_LEASE_SECONDS) { return leaseSeconds; } } catch (ArithmeticException | NumberFormatException e) { @@ -210,7 +211,11 @@ private static int readLease(@NotNull JsonElement result) throws DDExternalDamEx } @NotNull - private static Map messageVariables(@NotNull UUID accountId, @NotNull String connectionId, @NotNull DDMessage message) { + private static Map messageVariables( + @NotNull UUID accountId, + @NotNull String connectionId, + @NotNull DDMessagingMessage message + ) { if (connectionId.isBlank()) { throw new IllegalArgumentException("A connection ID is required"); } @@ -235,10 +240,10 @@ private static int readLease(@NotNull JsonElement result) throws DDExternalDamEx if (connectionId != null) { requireText(connectionId); } - if (!DDExternalDamApi.isValidServiceUrl(callbackUrl)) { + if (!DDMessagingApi.isValidServiceUrl(callbackUrl)) { throw new IllegalArgumentException("Invalid External DAM callback URL"); } - if (!DDExternalDamApi.isValidServiceSecret(callbackSecret)) { + if (!DDMessagingApi.isValidServiceSecret(callbackSecret)) { throw new IllegalArgumentException("Invalid External DAM callback secret"); } Map input = new HashMap<>(); @@ -259,7 +264,7 @@ private static int readLease(@NotNull JsonElement result) throws DDExternalDamEx } @NotNull - private static String readString(@NotNull JsonObject object, @NotNull String field) throws DDExternalDamException { + private static String readString(@NotNull JsonObject object, @NotNull String field) throws DDMessagingException { JsonElement value = object.get(field); if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank()) { throw invalidResponse(); @@ -268,7 +273,7 @@ private static String readString(@NotNull JsonObject object, @NotNull String fie } private static void requireSubscriberId(@Nullable String subscriberId) { - if (!DDExternalDamApi.isValidSubscriberId(subscriberId)) { + if (!DDMessagingApi.isValidSubscriberId(subscriberId)) { throw new IllegalArgumentException("Invalid subscription metadata"); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingGraphQlClient.java similarity index 83% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingGraphQlClient.java index 46d8197..e6e982b 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/client/DDExternalDamGraphQlClient.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingGraphQlClient.java @@ -14,10 +14,10 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.client; +package com.dbeaver.datadam.messaging.api.client; -import com.dbeaver.datadam.external.api.DDExternalDamApi; -import com.dbeaver.datadam.external.api.exception.DDExternalDamException; +import com.dbeaver.datadam.messaging.api.DDMessagingApi; +import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; import com.dbeaver.rest.client.AbstractRestClient; import com.dbeaver.rest.client.MediaType; import com.google.gson.JsonElement; @@ -45,15 +45,15 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -abstract class DDExternalDamGraphQlClient extends AbstractRestClient { +abstract class DDMessagingGraphQlClient extends AbstractRestClient { private final String serviceSecret; private final String endpoint; private final Duration requestTimeout; - DDExternalDamGraphQlClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull String endpoint, int timeoutMillis) { + DDMessagingGraphQlClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull String endpoint, int timeoutMillis) { super(validateServiceUrl(serviceUrl), Math.min(DEFAULT_CONNECT_TIMEOUT, timeoutMillis), timeoutMillis, List.of(), HttpClient.Redirect.NEVER); - if (!DDExternalDamApi.isValidServiceSecret(serviceSecret)) { + if (!DDMessagingApi.isValidServiceSecret(serviceSecret)) { throw new IllegalArgumentException("A valid External DAM service secret is required"); } this.serviceSecret = serviceSecret; @@ -67,7 +67,7 @@ final JsonElement request( @NotNull String query, @NotNull Map variables, @NotNull String field - ) throws DDExternalDamException { + ) throws DDMessagingException { String response; try { response = execute(createRequest(operationName, query, variables), String.class); @@ -103,7 +103,7 @@ final CompletionStage requestAsync( throw mapErrorResponse(code, "", request.uri()); } return readResult(response.body(), field); - } catch (DDExternalDamException e) { + } catch (DDMessagingException e) { throw new CompletionException(e); } }), bounded); @@ -124,7 +124,7 @@ final CompletionStage requestAsync( return propagateCancellation(response.thenApply(result -> { try { return reader.read(result); - } catch (DDExternalDamException e) { + } catch (DDMessagingException e) { throw new CompletionException(e); } }), response); @@ -160,7 +160,7 @@ private HttpRequest.Builder createRequest( ) throws DBException { return HttpRequest.newBuilder(buildUri(CommonUtils.removeLeadingSlash(endpoint), Map.of())) .timeout(requestTimeout) - .header(DDExternalDamApi.HEADER_SERVICE_TOKEN, serviceSecret) + .header(DDMessagingApi.HEADER_SERVICE_TOKEN, serviceSecret) .header(HttpConstants.HEADER_CONTENT_TYPE, MediaType.JSON.toString()) .header(HttpConstants.HEADER_ACCEPT, MediaType.JSON.toString()) .header(HttpConstants.HEADER_CACHE_CONTROL, HttpConstants.CACHE_CONTROL_NO_STORE) @@ -168,7 +168,7 @@ private HttpRequest.Builder createRequest( } @NotNull - private static JsonElement readResult(@NotNull String response, @NotNull String field) throws DDExternalDamException { + private static JsonElement readResult(@NotNull String response, @NotNull String field) throws DDMessagingException { JsonObject envelope; try { envelope = readObject(JsonParser.parseString(response)); @@ -177,7 +177,7 @@ private static JsonElement readResult(@NotNull String response, @NotNull String } JsonElement errors = envelope.get("errors"); if (errors != null && (!errors.isJsonArray() || !errors.getAsJsonArray().isEmpty())) { - throw new DDExternalDamException("External DAM GraphQL request failed"); + throw new DDMessagingException("External DAM GraphQL request failed"); } JsonElement result = readObject(envelope.get("data")).get(field); if (result == null || result.isJsonNull()) { @@ -188,11 +188,11 @@ private static JsonElement readResult(@NotNull String response, @NotNull String @Override @NotNull - protected DDExternalDamException mapErrorResponse(int code, @NotNull String message, @NotNull URI uri) { - return new DDExternalDamException("External DAM request failed: HTTP " + code); + protected DDMessagingException mapErrorResponse(int code, @NotNull String message, @NotNull URI uri) { + return new DDMessagingException("External DAM request failed: HTTP " + code); } - static boolean readBoolean(@NotNull JsonElement value) throws DDExternalDamException { + static boolean readBoolean(@NotNull JsonElement value) throws DDMessagingException { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) { throw invalidResponse(); } @@ -200,7 +200,7 @@ static boolean readBoolean(@NotNull JsonElement value) throws DDExternalDamExcep } @NotNull - static JsonObject readObject(@Nullable JsonElement value) throws DDExternalDamException { + static JsonObject readObject(@Nullable JsonElement value) throws DDMessagingException { if (value == null || !value.isJsonObject()) { throw invalidResponse(); } @@ -208,12 +208,12 @@ static JsonObject readObject(@Nullable JsonElement value) throws DDExternalDamEx } @NotNull - static DDExternalDamException invalidResponse() { - return new DDExternalDamException("Invalid External DAM response"); + static DDMessagingException invalidResponse() { + return new DDMessagingException("Invalid External DAM response"); } @NotNull - private static DDExternalDamException requestFailure(@NotNull Throwable error) { + private static DDMessagingException requestFailure(@NotNull Throwable error) { while (error instanceof CompletionException && error.getCause() != null) { error = error.getCause(); } @@ -222,13 +222,13 @@ private static DDExternalDamException requestFailure(@NotNull Throwable error) { timeout.initCause(error); error = timeout; } - return error instanceof DDExternalDamException failure - ? failure : new DDExternalDamException("External DAM service request failed", error); + return error instanceof DDMessagingException failure + ? failure : new DDMessagingException("External DAM service request failed", error); } @NotNull private static String validateServiceUrl(@NotNull String serviceUrl) { - if (!DDExternalDamApi.isValidServiceUrl(serviceUrl)) { + if (!DDMessagingApi.isValidServiceUrl(serviceUrl)) { throw new IllegalArgumentException("Invalid External DAM service URL"); } return serviceUrl; @@ -237,6 +237,6 @@ private static String validateServiceUrl(@NotNull String serviceUrl) { @FunctionalInterface interface ResultReader { @Nullable - T read(@NotNull JsonElement result) throws DDExternalDamException; + T read(@NotNull JsonElement result) throws DDMessagingException; } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/exception/DDMessagingException.java similarity index 75% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/exception/DDMessagingException.java index 7642145..5b13f2f 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/exception/DDExternalDamException.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/exception/DDMessagingException.java @@ -14,17 +14,17 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.exception; +package com.dbeaver.datadam.messaging.api.exception; import org.jkiss.code.NotNull; import org.jkiss.dbeaver.DBException; -public class DDExternalDamException extends DBException { - public DDExternalDamException(@NotNull String message) { +public class DDMessagingException extends DBException { + public DDMessagingException(@NotNull String message) { super(message); } - public DDExternalDamException(@NotNull String message, @NotNull Throwable cause) { + public DDMessagingException(@NotNull String message, @NotNull Throwable cause) { super(message, cause); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnection.java similarity index 64% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnection.java index d9f1ada..91267c7 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnection.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnection.java @@ -14,22 +14,22 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.model; +package com.dbeaver.datadam.messaging.api.model; import org.jkiss.code.NotNull; -public record DDExternalDamConnection( +public record DDMessagingConnection( @NotNull String deploymentId, @NotNull String applicationRunId, @NotNull String connectionId, @NotNull String product, @NotNull String version ) { - public DDExternalDamConnection { - DDMessage.requireText(deploymentId, "deploymentId"); - DDMessage.requireText(applicationRunId, "applicationRunId"); - DDMessage.requireText(connectionId, "connectionId"); - DDMessage.requireText(product, "product"); - DDMessage.requireText(version, "version"); + public DDMessagingConnection { + DDMessagingMessage.requireText(deploymentId, "deploymentId"); + DDMessagingMessage.requireText(applicationRunId, "applicationRunId"); + DDMessagingMessage.requireText(connectionId, "connectionId"); + DDMessagingMessage.requireText(product, "product"); + DDMessagingMessage.requireText(version, "version"); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnectionInfo.java similarity index 69% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnectionInfo.java index df9fd2a..bddde8f 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDConnectionInfo.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnectionInfo.java @@ -14,18 +14,18 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.model; +package com.dbeaver.datadam.messaging.api.model; import org.jkiss.code.NotNull; -public record DDConnectionInfo( +public record DDMessagingConnectionInfo( @NotNull String deploymentId, @NotNull String applicationRunId, @NotNull String connectionId ) { - public DDConnectionInfo { - DDMessage.requireText(deploymentId, "deploymentId"); - DDMessage.requireText(applicationRunId, "applicationRunId"); - DDMessage.requireText(connectionId, "connectionId"); + public DDMessagingConnectionInfo { + DDMessagingMessage.requireText(deploymentId, "deploymentId"); + DDMessagingMessage.requireText(applicationRunId, "applicationRunId"); + DDMessagingMessage.requireText(connectionId, "connectionId"); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingHandshakeRequest.java similarity index 66% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingHandshakeRequest.java index 1bace8f..7d8029b 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDHello.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingHandshakeRequest.java @@ -14,20 +14,20 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.model; +package com.dbeaver.datadam.messaging.api.model; import org.jkiss.code.NotNull; -public record DDHello( +public record DDMessagingHandshakeRequest( @NotNull String deploymentId, @NotNull String applicationRunId, @NotNull String product, @NotNull String version ) { - public DDHello { - DDMessage.requireText(deploymentId, "deploymentId"); - DDMessage.requireText(applicationRunId, "applicationRunId"); - DDMessage.requireText(product, "product"); - DDMessage.requireText(version, "version"); + public DDMessagingHandshakeRequest { + DDMessagingMessage.requireText(deploymentId, "deploymentId"); + DDMessagingMessage.requireText(applicationRunId, "applicationRunId"); + DDMessagingMessage.requireText(product, "product"); + DDMessagingMessage.requireText(version, "version"); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingHandshakeResponse.java similarity index 74% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingHandshakeResponse.java index cc75713..4085269 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDReady.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingHandshakeResponse.java @@ -14,12 +14,12 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.model; +package com.dbeaver.datadam.messaging.api.model; import org.jkiss.code.NotNull; -public record DDReady(@NotNull String connectionId) { - public DDReady { - DDMessage.requireText(connectionId, "connectionId"); +public record DDMessagingHandshakeResponse(@NotNull String connectionId) { + public DDMessagingHandshakeResponse { + DDMessagingMessage.requireText(connectionId, "connectionId"); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessage.java similarity index 91% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessage.java index e62ae0a..c638936 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/model/DDMessage.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessage.java @@ -14,7 +14,7 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.model; +package com.dbeaver.datadam.messaging.api.model; import com.google.gson.JsonObject; import org.jkiss.code.NotNull; @@ -22,12 +22,12 @@ import java.util.Objects; -public record DDMessage( +public record DDMessagingMessage( @NotNull String topic, @NotNull String type, @NotNull JsonObject payload ) { - public DDMessage { + public DDMessagingMessage { requireText(topic, "topic"); requireText(type, "type"); payload = Objects.requireNonNull(payload, "payload").deepCopy(); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransport.java similarity index 87% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransport.java index 7c0b44b..4ae6436 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransport.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransport.java @@ -14,15 +14,15 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.requestreply; +package com.dbeaver.datadam.messaging.api.requestreply; -import com.dbeaver.datadam.external.api.exception.DDExternalDamException; -import com.dbeaver.datadam.external.api.model.DDConnectionInfo; -import com.dbeaver.datadam.external.api.model.DDMessage; -import com.dbeaver.datadam.external.api.transport.DDChannelStatus; -import com.dbeaver.datadam.external.api.transport.DDMessageChannel; -import com.dbeaver.datadam.external.api.transport.DDMessageListener; -import com.dbeaver.datadam.external.api.transport.DDSubscription; +import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; +import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannel; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelStatus; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingMessageListener; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingSubscription; import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; @@ -46,7 +46,7 @@ import java.util.concurrent.CompletionStage; import java.util.concurrent.Semaphore; -public final class DDRequestReplyTransport implements MessagingRequestReplyTransport, AutoCloseable { +public final class DDMessagingRequestReplyTransport implements MessagingRequestReplyTransport, AutoCloseable { private static final String MESSAGE_TYPE = "datadam.requestreply.v1"; private static final String FIELD_REQUEST_ID = "requestId"; private static final String FIELD_REPLY_TO = "replyTo"; @@ -62,7 +62,7 @@ public final class DDRequestReplyTransport implements MessagingRequestReplyTrans private final Sender sender; private final TopicSubscriber subscriber; @Nullable - private final DDMessageChannel channel; + private final DDMessagingChannel channel; @Nullable private Registration registration; private boolean claimed; @@ -71,25 +71,25 @@ public final class DDRequestReplyTransport implements MessagingRequestReplyTrans @FunctionalInterface public interface Sender { @NotNull - CompletionStage send(@NotNull DDMessage message); + CompletionStage send(@NotNull DDMessagingMessage message); } /** Registers topic listeners for a selected peer. */ @FunctionalInterface public interface TopicSubscriber { @NotNull - DDSubscription subscribe(@NotNull String topic, @NotNull DDMessageListener listener) throws DDExternalDamException; + DDMessagingSubscription subscribe(@NotNull String topic, @NotNull DDMessagingMessageListener listener) throws DDMessagingException; } - public DDRequestReplyTransport(@NotNull String topic, @NotNull Sender sender, @NotNull TopicSubscriber subscriber) { + public DDMessagingRequestReplyTransport(@NotNull String topic, @NotNull Sender sender, @NotNull TopicSubscriber subscriber) { this(topic, sender, subscriber, null); } - private DDRequestReplyTransport( + private DDMessagingRequestReplyTransport( @NotNull String topic, @NotNull Sender sender, @NotNull TopicSubscriber subscriber, - @Nullable DDMessageChannel channel + @Nullable DDMessagingChannel channel ) { if (topic.isBlank() || topic.length() > 256) { throw new IllegalArgumentException("Expected a nonblank request/reply topic of at most 256 characters"); @@ -102,9 +102,9 @@ private DDRequestReplyTransport( } @NotNull - public static DDRequestReplyTransport forChannel(@NotNull DDMessageChannel channel, @NotNull String topic) { + public static DDMessagingRequestReplyTransport forChannel(@NotNull DDMessagingChannel channel, @NotNull String topic) { Objects.requireNonNull(channel); - return new DDRequestReplyTransport(topic, channel::send, channel::subscribe, channel); + return new DDMessagingRequestReplyTransport(topic, channel::send, channel::subscribe, channel); } @Override @@ -136,7 +136,7 @@ public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listen opened.opening = false; } return opened; - } catch (DDExternalDamException | MessagingRequestReplyException | RuntimeException e) { + } catch (DDMessagingException | MessagingRequestReplyException | RuntimeException e) { try { opened.close(); } catch (RuntimeException closeFailure) { @@ -179,7 +179,7 @@ public CompletionStage send(@NotNull MessagingRequestReplyMessage result = new CompletableFuture<>(); - Objects.requireNonNull(sender.send(new DDMessage(target, MESSAGE_TYPE, envelope))).whenComplete((ignored, error) -> { + Objects.requireNonNull(sender.send(new DDMessagingMessage(target, MESSAGE_TYPE, envelope))).whenComplete((ignored, error) -> { sendBudget.release(); if (error == null) { result.complete(null); @@ -217,7 +217,7 @@ private static String replyPrefix(@NotNull String topic) { } @Nullable - private static MessagingRequestReplyMessage decode(@NotNull DDMessage message) { + private static MessagingRequestReplyMessage decode(@NotNull DDMessagingMessage message) { if (!MESSAGE_TYPE.equals(message.type())) { return null; } @@ -270,10 +270,10 @@ private final class Registration implements MessagingSubscription { private final UUID endpointId; private final String replyTopic; private final Listener listener; - private final List subscriptions = new ArrayList<>(); + private final List subscriptions = new ArrayList<>(); private final ArrayDeque deliveries = new ArrayDeque<>(); @Nullable - private DDChannelStatus status; + private DDMessagingChannelStatus status; private boolean opening = true; private boolean openingDisconnected; private boolean delivering; @@ -285,7 +285,7 @@ private Registration(@NotNull UUID endpointId, @NotNull Listener lis this.listener = listener; } - private void add(@NotNull DDSubscription subscription) { + private void add(@NotNull DDMessagingSubscription subscription) { synchronized (lock) { if (!closed) { subscriptions.add(Objects.requireNonNull(subscription)); @@ -306,7 +306,7 @@ private void requireReady() throws MessagingRequestReplyException { } } - private void state(@NotNull DDChannelStatus next) { + private void state(@NotNull DDMessagingChannelStatus next) { boolean drain; synchronized (lock) { if (closed) { @@ -320,7 +320,7 @@ private void state(@NotNull DDChannelStatus next) { } } - private boolean updateState(@NotNull DDChannelStatus next) { + private boolean updateState(@NotNull DDMessagingChannelStatus next) { if (next.equals(status)) { return false; } @@ -331,7 +331,7 @@ private boolean updateState(@NotNull DDChannelStatus next) { return disconnected && enqueue(null); } - private void receive(@NotNull DDConnectionInfo connection, @NotNull DDMessage message, boolean requestTopic) { + private void receive(@NotNull DDMessagingConnectionInfo connection, @NotNull DDMessagingMessage message, boolean requestTopic) { MessagingRequestReplyMessage frame = decode(message); if (frame == null || (requestTopic ? frame.type() != MessagingRequestReplyMessage.Type.REQUEST : frame.type() == MessagingRequestReplyMessage.Type.REQUEST || !endpointId.equals(frame.replyTo()))) { @@ -343,7 +343,7 @@ private void receive(@NotNull DDConnectionInfo connection, @NotNull DDMessage me return; } if (channel != null) { - DDChannelStatus current = channel.getStatus(); + DDMessagingChannelStatus current = channel.getStatus(); if (!current.isReady() || !connection.equals(current.connection())) { return; } @@ -415,7 +415,7 @@ private void drain() { @Override public void close() { - List stopped; + List stopped; synchronized (lock) { if (closed) { return; @@ -426,7 +426,7 @@ public void close() { subscriptions.clear(); } RuntimeException failure = null; - for (DDSubscription subscription : stopped.reversed()) { + for (DDMessagingSubscription subscription : stopped.reversed()) { try { subscription.close(); } catch (RuntimeException e) { diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingGsonRpcCodec.java similarity index 88% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingGsonRpcCodec.java index e5464bf..647081d 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodec.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingGsonRpcCodec.java @@ -14,7 +14,7 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.rpc; +package com.dbeaver.datadam.messaging.api.rpc; import com.dbeaver.messaging.rpc.MessagingRpcCodec; import com.google.gson.Gson; @@ -25,10 +25,10 @@ import java.lang.reflect.Type; import java.util.Objects; -public final class DDGsonRpcCodec implements MessagingRpcCodec { +public final class DDMessagingGsonRpcCodec implements MessagingRpcCodec { private final Gson gson; - public DDGsonRpcCodec(@NotNull Gson gson) { + public DDMessagingGsonRpcCodec(@NotNull Gson gson) { this.gson = Objects.requireNonNull(gson); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransport.java similarity index 90% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransport.java index ee75f67..8abedea 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransport.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransport.java @@ -14,10 +14,10 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.rpc; +package com.dbeaver.datadam.messaging.api.rpc; -import com.dbeaver.datadam.external.api.requestreply.DDRequestReplyTransport; -import com.dbeaver.datadam.external.api.transport.DDMessageChannel; +import com.dbeaver.datadam.messaging.api.requestreply.DDMessagingRequestReplyTransport; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannel; import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; @@ -38,7 +38,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.Semaphore; -public final class DDRpcTransport implements MessagingRequestReplyTransport> { +public final class DDMessagingRpcTransport implements MessagingRequestReplyTransport> { private static final String PROTOCOL = "dbeaver.rpc.v2"; private static final String FIELD_PROTOCOL = "protocol"; private static final String FIELD_TYPE = "type"; @@ -55,33 +55,34 @@ public final class DDRpcTransport implements MessagingRequestReplyTransport delegate) { + public DDMessagingRpcTransport(@NotNull MessagingRequestReplyTransport delegate) { this(delegate, null); } - public DDRpcTransport(@NotNull MessagingRequestReplyTransport delegate, @Nullable Executor requestExecutor) { + public DDMessagingRpcTransport(@NotNull MessagingRequestReplyTransport delegate, @Nullable Executor requestExecutor) { this.delegate = Objects.requireNonNull(delegate); this.requestExecutor = requestExecutor; } @NotNull - public static MessagingRpcTransport forChannel(@NotNull DDMessageChannel channel) { + public static MessagingRpcTransport forChannel(@NotNull DDMessagingChannel channel) { Objects.requireNonNull(channel); return topic -> forChannel(channel, topic); } @NotNull - public static DDRpcTransport forChannel(@NotNull DDMessageChannel channel, @NotNull String topic) { - return new DDRpcTransport(DDRequestReplyTransport.forChannel(channel, topic)); + public static DDMessagingRpcTransport forChannel(@NotNull DDMessagingChannel channel, @NotNull String topic) { + return new DDMessagingRpcTransport(DDMessagingRequestReplyTransport.forChannel(channel, topic)); } @NotNull - public static DDRpcTransport forChannel( - @NotNull DDMessageChannel channel, + public static DDMessagingRpcTransport forChannel( + @NotNull DDMessagingChannel channel, @NotNull String topic, @NotNull Executor requestExecutor ) { - return new DDRpcTransport(DDRequestReplyTransport.forChannel(channel, topic), Objects.requireNonNull(requestExecutor)); + return new DDMessagingRpcTransport(DDMessagingRequestReplyTransport.forChannel(channel, topic), + Objects.requireNonNull(requestExecutor)); } @Override diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannel.java similarity index 61% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannel.java index eb1cc92..0e2a418 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageChannel.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannel.java @@ -14,31 +14,31 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.transport; +package com.dbeaver.datadam.messaging.api.transport; -import com.dbeaver.datadam.external.api.model.DDMessage; +import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import org.jkiss.code.NotNull; import java.util.concurrent.CompletionStage; import java.util.function.Consumer; /** Provides asynchronous DataDam messaging and connection-state observation. */ -public interface DDMessageChannel extends AutoCloseable { +public interface DDMessagingChannel extends AutoCloseable { @NotNull - DDChannelStatus getStatus(); + DDMessagingChannelStatus getStatus(); @NotNull - DDSubscription observeState(@NotNull Consumer observer); + DDMessagingSubscription observeState(@NotNull Consumer observer); @NotNull - CompletionStage send(@NotNull DDMessage message); + CompletionStage send(@NotNull DDMessagingMessage message); @NotNull - DDSubscription subscribe(@NotNull String topic, @NotNull DDMessageListener listener); + DDMessagingSubscription subscribe(@NotNull String topic, @NotNull DDMessagingMessageListener listener); @NotNull - DDSubscription addConnectionListener(@NotNull DDConnectionListener listener); + DDMessagingSubscription addConnectionListener(@NotNull DDMessagingConnectionListener listener); @Override void close(); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelState.java similarity index 88% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelState.java index cc5415b..90b0f14 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelState.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelState.java @@ -14,9 +14,9 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.transport; +package com.dbeaver.datadam.messaging.api.transport; -public enum DDChannelState { +public enum DDMessagingChannelState { DISCONNECTED, CONNECTING, CONNECTED, diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatus.java similarity index 67% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatus.java index af3e089..6e2e354 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDChannelStatus.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatus.java @@ -14,23 +14,23 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.transport; +package com.dbeaver.datadam.messaging.api.transport; -import com.dbeaver.datadam.external.api.model.DDConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; import java.util.Objects; -public record DDChannelStatus(@NotNull DDChannelState state, @Nullable DDConnectionInfo connection) { - public DDChannelStatus { +public record DDMessagingChannelStatus(@NotNull DDMessagingChannelState state, @Nullable DDMessagingConnectionInfo connection) { + public DDMessagingChannelStatus { Objects.requireNonNull(state); - if ((state == DDChannelState.CONNECTED) != (connection != null)) { + if ((state == DDMessagingChannelState.CONNECTED) != (connection != null)) { throw new IllegalArgumentException("Only a connected channel has connection information"); } } public boolean isReady() { - return state == DDChannelState.CONNECTED; + return state == DDMessagingChannelState.CONNECTED; } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingConnectionListener.java similarity index 69% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingConnectionListener.java index 5e3f9cf..8aec813 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDConnectionListener.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingConnectionListener.java @@ -14,14 +14,14 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.transport; +package com.dbeaver.datadam.messaging.api.transport; -import com.dbeaver.datadam.external.api.model.DDConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; import org.jkiss.code.NotNull; /** Receives physical connection lifecycle notifications. */ -public interface DDConnectionListener { - void onConnected(@NotNull DDConnectionInfo connection); +public interface DDMessagingConnectionListener { + void onConnected(@NotNull DDMessagingConnectionInfo connection); - void onDisconnected(@NotNull DDConnectionInfo connection); + void onDisconnected(@NotNull DDMessagingConnectionInfo connection); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingMessageListener.java similarity index 68% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingMessageListener.java index bbe4c5b..d2da501 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDMessageListener.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingMessageListener.java @@ -14,14 +14,14 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.transport; +package com.dbeaver.datadam.messaging.api.transport; -import com.dbeaver.datadam.external.api.model.DDConnectionInfo; -import com.dbeaver.datadam.external.api.model.DDMessage; +import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import org.jkiss.code.NotNull; /** Receives topic messages with their source connection. */ @FunctionalInterface -public interface DDMessageListener { - void onMessage(@NotNull DDConnectionInfo connection, @NotNull DDMessage message); +public interface DDMessagingMessageListener { + void onMessage(@NotNull DDMessagingConnectionInfo connection, @NotNull DDMessagingMessage message); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingSubscription.java similarity index 86% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingSubscription.java index a10a488..be0b9f4 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/external/api/transport/DDSubscription.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingSubscription.java @@ -14,11 +14,11 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.transport; +package com.dbeaver.datadam.messaging.api.transport; /** Controls the lifetime of a DataDam listener registration. */ @FunctionalInterface -public interface DDSubscription extends AutoCloseable { +public interface DDMessagingSubscription extends AutoCloseable { @Override void close(); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/DDMessagingApiTest.java similarity index 68% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/DDMessagingApiTest.java index c075402..2ce3711 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamApiTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/DDMessagingApiTest.java @@ -14,49 +14,49 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api; +package com.dbeaver.datadam.messaging.api; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -class DDExternalDamApiTest { +class DDMessagingApiTest { @Test void acceptsSubscriberIdsWithSubscriptionMetadataCharactersAndLengthBoundaries() { for (String subscriberId : new String[] {"s", "service:account", "service account", "s".repeat(129), "s".repeat(256)}) { - Assertions.assertTrue(DDExternalDamApi.isValidSubscriberId(subscriberId)); + Assertions.assertTrue(DDMessagingApi.isValidSubscriberId(subscriberId)); } } @Test void rejectsMissingBlankAndOversizedSubscriberIds() { for (String subscriberId : new String[] {null, "", " ", "\t\n", "s".repeat(257)}) { - Assertions.assertFalse(DDExternalDamApi.isValidSubscriberId(subscriberId)); + Assertions.assertFalse(DDMessagingApi.isValidSubscriberId(subscriberId)); } } @Test void acceptsPrintableAsciiSecretsAtBothLengthBoundaries() { - Assertions.assertTrue(DDExternalDamApi.isValidServiceSecret("!".repeat(32))); - Assertions.assertTrue(DDExternalDamApi.isValidServiceSecret("~".repeat(512))); + Assertions.assertTrue(DDMessagingApi.isValidServiceSecret("!".repeat(32))); + Assertions.assertTrue(DDMessagingApi.isValidServiceSecret("~".repeat(512))); StringBuilder printable = new StringBuilder(); for (char value = 33; value <= 126; value++) { printable.append(value); } - Assertions.assertTrue(DDExternalDamApi.isValidServiceSecret(printable.toString())); + Assertions.assertTrue(DDMessagingApi.isValidServiceSecret(printable.toString())); } @Test void rejectsMissingShortAndOversizedSecrets() { - Assertions.assertFalse(DDExternalDamApi.isValidServiceSecret(null)); - Assertions.assertFalse(DDExternalDamApi.isValidServiceSecret("")); - Assertions.assertFalse(DDExternalDamApi.isValidServiceSecret("x".repeat(31))); - Assertions.assertFalse(DDExternalDamApi.isValidServiceSecret("x".repeat(513))); + Assertions.assertFalse(DDMessagingApi.isValidServiceSecret(null)); + Assertions.assertFalse(DDMessagingApi.isValidServiceSecret("")); + Assertions.assertFalse(DDMessagingApi.isValidServiceSecret("x".repeat(31))); + Assertions.assertFalse(DDMessagingApi.isValidServiceSecret("x".repeat(513))); } @Test void rejectsSpacesControlCharactersAndNonAsciiSecrets() { for (String invalid : new String[] {" ", "\t", "\r", "\n", "\0", "\u007f", "\u0080", "é"}) { - Assertions.assertFalse(DDExternalDamApi.isValidServiceSecret("x".repeat(31) + invalid)); + Assertions.assertFalse(DDMessagingApi.isValidServiceSecret("x".repeat(31) + invalid)); } } @@ -66,7 +66,7 @@ void acceptsHttpAndHttpsServiceOrigins() { "http://localhost", "http://localhost/", "https://example.com", "HTTPS://example.com/", "http://127.0.0.1:1", "https://example.com:65535/", "http://[::1]", "https://[::1]:8443/" }) { - Assertions.assertTrue(DDExternalDamApi.isValidServiceUrl(url)); + Assertions.assertTrue(DDMessagingApi.isValidServiceUrl(url)); } } @@ -80,14 +80,14 @@ void rejectsMalformedServiceOrigins() { "https://example.com/%2f", "https://example.com:0", "https://example.com:65536", "https://example.com:-1", "https://example.com:port", "http://[::1", "http://example.com/\n" }) { - Assertions.assertFalse(DDExternalDamApi.isValidServiceUrl(url)); + Assertions.assertFalse(DDMessagingApi.isValidServiceUrl(url)); } } @Test void boundsServiceUrlLength() { String prefix = "https://"; - Assertions.assertTrue(DDExternalDamApi.isValidServiceUrl(prefix + "a".repeat(4096 - prefix.length()))); - Assertions.assertFalse(DDExternalDamApi.isValidServiceUrl(prefix + "a".repeat(4097 - prefix.length()))); + Assertions.assertTrue(DDMessagingApi.isValidServiceUrl(prefix + "a".repeat(4096 - prefix.length()))); + Assertions.assertFalse(DDMessagingApi.isValidServiceUrl(prefix + "a".repeat(4097 - prefix.length()))); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/DDMessagingProtocolTest.java similarity index 61% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/DDMessagingProtocolTest.java index d588181..4407a86 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/DDExternalDamProtocolTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/DDMessagingProtocolTest.java @@ -14,11 +14,11 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api; +package com.dbeaver.datadam.messaging.api; -import com.dbeaver.datadam.external.api.model.DDHello; -import com.dbeaver.datadam.external.api.model.DDMessage; -import com.dbeaver.datadam.external.api.model.DDReady; +import com.dbeaver.datadam.messaging.api.model.DDMessagingHandshakeRequest; +import com.dbeaver.datadam.messaging.api.model.DDMessagingHandshakeResponse; +import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import org.junit.jupiter.api.Test; @@ -29,27 +29,27 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; -class DDExternalDamProtocolTest { +class DDMessagingProtocolTest { @Test void roundTripsApplicationMessagesWithTopicTypeAndPayload() { JsonObject payload = new JsonObject(); payload.addProperty("projectId", "project-1"); - DDMessage message = new DDMessage("projects", "project.updated", payload); - String encoded = DDExternalDamProtocol.encode(message); + DDMessagingMessage message = new DDMessagingMessage("projects", "project.updated", payload); + String encoded = DDMessagingProtocol.encode(message); assertEquals(JsonParser.parseString(""" {"topic":"projects","type":"project.updated","payload":{"projectId":"project-1"}} """), JsonParser.parseString(encoded)); - assertEquals(message, DDExternalDamProtocol.decode(encoded)); + assertEquals(message, DDMessagingProtocol.decode(encoded)); } @Test - void roundTripsHelloWithoutAccountCredentialsOrProtocolVersion() { - DDHello hello = new DDHello("DI_desktop", "run-1", "DBeaver", "26.2.4"); - String encoded = DDExternalDamProtocol.encodeHello(hello); + void roundTripsHandshakeRequestWithoutAccountCredentialsOrProtocolVersion() { + DDMessagingHandshakeRequest hello = new DDMessagingHandshakeRequest("DI_desktop", "run-1", "DBeaver", "26.2.4"); + String encoded = DDMessagingProtocol.encodeHandshakeRequest(hello); JsonObject envelope = JsonParser.parseString(encoded).getAsJsonObject(); - assertEquals(hello, DDExternalDamProtocol.decodeHello(encoded)); + assertEquals(hello, DDMessagingProtocol.decodeHandshakeRequest(encoded)); assertEquals("hello", envelope.get("type").getAsString()); assertEquals(2, envelope.size()); assertEquals(4, envelope.getAsJsonObject("payload").size()); @@ -59,61 +59,62 @@ void roundTripsHelloWithoutAccountCredentialsOrProtocolVersion() { } @Test - void roundTripsReadyWithOnlyTheAssignedConnectionId() { - DDReady ready = new DDReady("connection-1"); - String encoded = DDExternalDamProtocol.encodeReady(ready); + void roundTripsHandshakeResponseWithOnlyTheAssignedConnectionId() { + DDMessagingHandshakeResponse ready = new DDMessagingHandshakeResponse("connection-1"); + String encoded = DDMessagingProtocol.encodeHandshakeResponse(ready); JsonObject envelope = JsonParser.parseString(encoded).getAsJsonObject(); - assertEquals(ready, DDExternalDamProtocol.decodeReady(encoded)); + assertEquals(ready, DDMessagingProtocol.decodeHandshakeResponse(encoded)); assertEquals("ready", envelope.get("type").getAsString()); assertEquals(1, envelope.getAsJsonObject("payload").size()); } @Test void keepsControlMessagesSeparateFromTopicMessages() { - String hello = DDExternalDamProtocol.encodeHello(new DDHello("DI_desktop", "run-1", "DBeaver", "26.2.4")); - String ready = DDExternalDamProtocol.encodeReady(new DDReady("connection-1")); - - assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decode(hello)); - assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decode(ready)); - assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeHello(ready)); - assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeReady(hello)); + String hello = DDMessagingProtocol.encodeHandshakeRequest( + new DDMessagingHandshakeRequest("DI_desktop", "run-1", "DBeaver", "26.2.4")); + String ready = DDMessagingProtocol.encodeHandshakeResponse(new DDMessagingHandshakeResponse("connection-1")); + + assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decode(hello)); + assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decode(ready)); + assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeRequest(ready)); + assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeResponse(hello)); String event = """ {"topic":"projects","type":"project.updated","payload":{}} """; - assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeHello(event)); - assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeReady(event)); + assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeRequest(event)); + assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeResponse(event)); } @Test void rejectsControlMessagesWithAnyTopicField() { for (String control : List.of( - DDExternalDamProtocol.encodeHello(new DDHello("DI_desktop", "run-1", "DBeaver", "26.2.4")), - DDExternalDamProtocol.encodeReady(new DDReady("connection-1")) + DDMessagingProtocol.encodeHandshakeRequest(new DDMessagingHandshakeRequest("DI_desktop", "run-1", "DBeaver", "26.2.4")), + DDMessagingProtocol.encodeHandshakeResponse(new DDMessagingHandshakeResponse("connection-1")) )) { for (String topic : List.of("\"projects\"", "null", "\"\"", "\" \"", "42", "true", "[]", "{}")) { JsonObject envelope = JsonParser.parseString(control).getAsJsonObject(); envelope.add("topic", JsonParser.parseString(topic)); String message = envelope.toString(); - assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeHello(message)); - assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeReady(message)); + assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeRequest(message)); + assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeResponse(message)); } } } @Test void allowsControlTypeNamesInApplicationMessages() { - for (String type : List.of(DDExternalDamProtocol.TYPE_HELLO, DDExternalDamProtocol.TYPE_READY)) { - DDMessage message = new DDMessage("projects", type, new JsonObject()); + for (String type : List.of(DDMessagingProtocol.TYPE_HANDSHAKE_REQUEST, DDMessagingProtocol.TYPE_HANDSHAKE_RESPONSE)) { + DDMessagingMessage message = new DDMessagingMessage("projects", type, new JsonObject()); - assertEquals(message, DDExternalDamProtocol.decode(DDExternalDamProtocol.encode(message))); + assertEquals(message, DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); } } @Test void preservesUnknownApplicationTypesAndAdditionalEnvelopeFields() { - DDMessage message = DDExternalDamProtocol.decode(""" + DDMessagingMessage message = DDMessagingProtocol.decode(""" {"topic":"future","type":"future.event","payload":{"value":42},"additional":true} """); @@ -130,7 +131,7 @@ void rejectsMalformedEnvelopesAndMissingFields() { "{\"topic\":\"projects\",\"payload\":{}}", "{\"type\":\"updated\",\"payload\":{}}" )) { - assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decode(message), message); + assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decode(message), message); } } @@ -143,7 +144,7 @@ void rejectsNonStringAndBlankTopicAndTypeFields() { """).getAsJsonObject(); envelope.add(field, JsonParser.parseString(value)); - assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decode(envelope.toString())); + assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decode(envelope.toString())); } } } @@ -153,33 +154,34 @@ void rejectsNonObjectPayloads() { for (String payload : List.of("null", "true", "42", "[]", "\"value\"")) { String message = "{\"topic\":\"projects\",\"type\":\"updated\",\"payload\":" + payload + "}"; - assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decode(message)); + assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decode(message)); } } @Test - void rejectsMissingAndIncorrectlyTypedHelloFields() { - String valid = DDExternalDamProtocol.encodeHello(new DDHello("DI_desktop", "run-1", "DBeaver", "26.2.4")); + void rejectsMissingAndIncorrectlyTypedHandshakeRequestFields() { + String valid = DDMessagingProtocol.encodeHandshakeRequest( + new DDMessagingHandshakeRequest("DI_desktop", "run-1", "DBeaver", "26.2.4")); for (String field : List.of("deploymentId", "applicationRunId", "product", "version")) { JsonObject missing = JsonParser.parseString(valid).getAsJsonObject(); missing.getAsJsonObject("payload").remove(field); - assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeHello(missing.toString())); + assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeRequest(missing.toString())); for (String value : List.of("null", "true", "42", "[]", "{}", "\" \"")) { JsonObject invalid = JsonParser.parseString(valid).getAsJsonObject(); invalid.getAsJsonObject("payload").add(field, JsonParser.parseString(value)); - assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeHello(invalid.toString())); + assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeRequest(invalid.toString())); } } } @Test - void rejectsReadyWithoutAStringConnectionId() { + void rejectsHandshakeResponseWithoutAStringConnectionId() { for (String payload : List.of("{}", "{\"connectionId\":null}", "{\"connectionId\":42}", "{\"connectionId\":\" \"}")) { String message = "{\"type\":\"ready\",\"payload\":" + payload + "}"; - assertThrows(IllegalArgumentException.class, () -> DDExternalDamProtocol.decodeReady(message)); + assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeResponse(message)); } } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClientTest.java similarity index 89% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClientTest.java index 2aba33e..432d62b 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamCallbackClientTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClientTest.java @@ -14,12 +14,12 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.client; +package com.dbeaver.datadam.messaging.api.client; -import com.dbeaver.datadam.external.api.DDExternalDamApi; -import com.dbeaver.datadam.external.api.exception.DDExternalDamException; -import com.dbeaver.datadam.external.api.model.DDConnectionInfo; -import com.dbeaver.datadam.external.api.model.DDMessage; +import com.dbeaver.datadam.messaging.api.DDMessagingApi; +import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; +import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.sun.net.httpserver.HttpExchange; @@ -59,12 +59,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @Timeout(10) -class DDExternalDamCallbackClientTest { +class DDMessagingCallbackClientTest { private static final String SECRET = "external-dam-callback-secret-2026-test"; private static final UUID SUBSCRIPTION_ID = UUID.randomUUID(); private static final UUID ACCOUNT_ID = UUID.randomUUID(); - private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("deployment", "run", "socket"); - private static final DDMessage MESSAGE = new DDMessage("projects", "updated", + private static final DDMessagingConnectionInfo CONNECTION = new DDMessagingConnectionInfo("deployment", "run", "socket"); + private static final DDMessagingMessage MESSAGE = new DDMessagingMessage("projects", "updated", JsonParser.parseString(""" {"nested":{"value":42},"nothing":null,"integer":9007199254740993, "decimal":1.2345678901234567890123456789,"text":"Привет"} @@ -77,14 +77,14 @@ class DDExternalDamCallbackClientTest { private final CountDownLatch requestReceived = new CountDownLatch(1); private final AtomicReference responseGate = new AtomicReference<>(); private HttpServer server; - private DDExternalDamCallbackClient client; + private DDMessagingCallbackClient client; @BeforeEach void start() throws IOException { server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/", this::respond); server.start(); - client = new DDExternalDamCallbackClient(url(), SECRET); + client = new DDMessagingCallbackClient(url(), SECRET); } @AfterEach @@ -95,7 +95,7 @@ void stop() { @Test void deliversTheExactCallbackWireWithIsolatedCredentialsAndAnOpaquePayload() throws Exception { for (String suffix : List.of("", "/")) { - client = new DDExternalDamCallbackClient(url() + suffix, SECRET); + client = new DDMessagingCallbackClient(url() + suffix, SECRET); deliver(); CapturedRequest synchronous = request.get(); assertNull(deliverAsync().toCompletableFuture().get(5, TimeUnit.SECONDS)); @@ -174,7 +174,7 @@ void allowsDefaultAsyncDeliveryToWaitLongerThanFiveSecondsForTheHandler() throws void rejectsFalseAcknowledgementsAsProtocolFailures() { response.set("{\"data\":{\"deliverExternalDamMessage\":false}}"); assertFailureOnce(); - DDExternalDamException failure = assertAsyncFailureOnce(); + DDMessagingException failure = assertAsyncFailureOnce(); assertEquals("External DAM callback was not accepted", failure.getMessage()); } @@ -195,7 +195,7 @@ void rejectsHttpFailuresAndNeverFollowsRedirects() { for (int code : List.of(301, 302, 303, 307, 308, 400, 401, 403, 429, 500, 503)) { status.set(code); assertFailureOnce(); - DDExternalDamException failure = assertAsyncFailureOnce(); + DDMessagingException failure = assertAsyncFailureOnce(); assertEquals("External DAM request failed: HTTP " + code, failure.getMessage()); } } @@ -203,16 +203,16 @@ void rejectsHttpFailuresAndNeverFollowsRedirects() { @Test void boundsDeliveryTimeAndDoesNotRetryTimedOutMessages() { responseDelay.set(1000); - client = new DDExternalDamCallbackClient(url(), SECRET, Duration.ofMillis(250)); + client = new DDMessagingCallbackClient(url(), SECRET, Duration.ofMillis(250)); assertFailureOnce(); } @Test void preservesTheExplicitAsyncDeliveryTimeoutAndItsNetworkCause() { responseDelay.set(1000); - client = new DDExternalDamCallbackClient(url(), SECRET, Duration.ofMillis(250)); + client = new DDMessagingCallbackClient(url(), SECRET, Duration.ofMillis(250)); - DDExternalDamException failure = assertAsyncFailureOnce(); + DDMessagingException failure = assertAsyncFailureOnce(); assertEquals("External DAM service request failed", failure.getMessage()); assertInstanceOf(HttpTimeoutException.class, failure.getCause()); @@ -241,16 +241,16 @@ void rejectsNullAsyncDeliveryArgumentsBeforeNetworkIo() { void rejectsUntrustedOriginFormsAndInvalidSecretsBeforeNetworkIo() { for (String origin : List.of("", "/relative", url() + "/prefix", url() + "?query=1", url() + "#fragment", "http://user:password@127.0.0.1", "ftp://127.0.0.1", "http://127.0.0.1:65536")) { - assertThrows(IllegalArgumentException.class, () -> new DDExternalDamCallbackClient(origin, SECRET)); + assertThrows(IllegalArgumentException.class, () -> new DDMessagingCallbackClient(origin, SECRET)); } for (String secret : List.of("", "short", " ".repeat(32), "x".repeat(31) + "\n")) { - assertThrows(IllegalArgumentException.class, () -> new DDExternalDamCallbackClient(url(), secret)); + assertThrows(IllegalArgumentException.class, () -> new DDMessagingCallbackClient(url(), secret)); } - assertThrows(IllegalArgumentException.class, () -> new DDExternalDamCallbackClient(url(), SECRET, Duration.ZERO)); + assertThrows(IllegalArgumentException.class, () -> new DDMessagingCallbackClient(url(), SECRET, Duration.ZERO)); assertEquals(0, requestCount.get()); } - private void deliver() throws DDExternalDamException { + private void deliver() throws DDMessagingException { client.deliver(SUBSCRIPTION_ID, ACCOUNT_ID, CONNECTION, MESSAGE); } @@ -261,18 +261,18 @@ private CompletionStage deliverAsync() { private void assertFailureOnce() { int before = requestCount.get(); - DDExternalDamException failure = assertThrows(DDExternalDamException.class, this::deliver); + DDMessagingException failure = assertThrows(DDMessagingException.class, this::deliver); assertFalse(failure.toString().contains(SECRET)); assertEquals(before + 1, requestCount.get()); } @NotNull - private DDExternalDamException assertAsyncFailureOnce() { + private DDMessagingException assertAsyncFailureOnce() { int before = requestCount.get(); CompletionStage stage = deliverAsync(); ExecutionException completion = assertThrows(ExecutionException.class, () -> stage.toCompletableFuture().get(5, TimeUnit.SECONDS)); - DDExternalDamException failure = assertInstanceOf(DDExternalDamException.class, completion.getCause()); + DDMessagingException failure = assertInstanceOf(DDMessagingException.class, completion.getCause()); StringWriter trace = new StringWriter(); failure.printStackTrace(new PrintWriter(trace)); assertFalse(trace.toString().contains(SECRET)); @@ -285,7 +285,7 @@ private void assertStalledBodyIsCancelled(boolean cancelDelivery) throws Excepti CountDownLatch resumeBody = new CountDownLatch(1); CountDownLatch probeFinished = new CountDownLatch(1); AtomicReference bodyFailure = new AtomicReference<>(); - server.createContext(DDExternalDamApi.CALLBACK_GRAPHQL_PATH, exchange -> { + server.createContext(DDMessagingApi.CALLBACK_GRAPHQL_PATH, exchange -> { try { exchange.getRequestBody().readAllBytes(); requestCount.incrementAndGet(); @@ -312,7 +312,7 @@ private void assertStalledBodyIsCancelled(boolean cancelDelivery) throws Excepti probeFinished.countDown(); } }); - client = new DDExternalDamCallbackClient(url(), SECRET, + client = new DDMessagingCallbackClient(url(), SECRET, cancelDelivery ? Duration.ofSeconds(5) : Duration.ofSeconds(1)); CompletableFuture delivery = deliverAsync().toCompletableFuture(); try { @@ -324,7 +324,7 @@ private void assertStalledBodyIsCancelled(boolean cancelDelivery) throws Excepti } else { ExecutionException completion = assertThrows(ExecutionException.class, () -> delivery.get(3, TimeUnit.SECONDS)); - DDExternalDamException failure = assertInstanceOf(DDExternalDamException.class, completion.getCause()); + DDMessagingException failure = assertInstanceOf(DDMessagingException.class, completion.getCause()); assertEquals("External DAM service request failed", failure.getMessage()); assertInstanceOf(HttpTimeoutException.class, failure.getCause()); StringWriter trace = new StringWriter(); @@ -353,7 +353,7 @@ private String url() { private void respond(@NotNull HttpExchange exchange) throws IOException { try { request.set(new CapturedRequest(exchange.getRequestMethod(), exchange.getRequestURI().toString(), - exchange.getRequestHeaders().getFirst(DDExternalDamApi.HEADER_SERVICE_TOKEN), + exchange.getRequestHeaders().getFirst(DDMessagingApi.HEADER_SERVICE_TOKEN), exchange.getRequestHeaders().getFirst("Content-Type"), exchange.getRequestHeaders().getFirst("Cache-Control"), JsonParser.parseString(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject())); requestCount.incrementAndGet(); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingClientTest.java similarity index 93% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingClientTest.java index 574a174..bd541ce 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/client/DDExternalDamClientTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingClientTest.java @@ -14,12 +14,12 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.client; +package com.dbeaver.datadam.messaging.api.client; -import com.dbeaver.datadam.external.api.DDExternalDamApi; -import com.dbeaver.datadam.external.api.exception.DDExternalDamException; -import com.dbeaver.datadam.external.api.model.DDExternalDamConnection; -import com.dbeaver.datadam.external.api.model.DDMessage; +import com.dbeaver.datadam.messaging.api.DDMessagingApi; +import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; +import com.dbeaver.datadam.messaging.api.model.DDMessagingConnection; +import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonNull; @@ -65,7 +65,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @Timeout(30) -class DDExternalDamClientTest { +class DDMessagingClientTest { private static final UUID ACCOUNT_ID = UUID.fromString("58c487c0-de34-4b45-83bb-705cb733cbcd"); private static final UUID OTHER_ACCOUNT_ID = UUID.fromString("678d5732-708f-441e-bc59-719b92af1d64"); private static final String SERVICE_SECRET = "external-dam-service-secret-2026-test"; @@ -85,14 +85,14 @@ class DDExternalDamClientTest { private final AtomicReference requestReceived = new AtomicReference<>(); private final AtomicReference responseGate = new AtomicReference<>(); private HttpServer server; - private DDExternalDamClient client; + private DDMessagingClient client; @BeforeEach void setUp() throws IOException { server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/", this::handleRequest); server.start(); - client = new DDExternalDamClient(serviceUrl(), SERVICE_SECRET); + client = new DDMessagingClient(serviceUrl(), SERVICE_SECRET); } @AfterEach @@ -104,10 +104,10 @@ void stop() { } @Test - void postsConnectionQueriesToTheInternalEndpointWithEitherRootUrlForm() throws DDExternalDamException { + void postsConnectionQueriesToTheInternalEndpointWithEitherRootUrlForm() throws DDMessagingException { respond(CONNECTIONS_FIELD, new JsonArray()); for (String suffix : List.of("", "/")) { - var configured = new DDExternalDamClient(serviceUrl() + suffix, SERVICE_SECRET); + var configured = new DDMessagingClient(serviceUrl() + suffix, SERVICE_SECRET); assertEquals(List.of(), configured.getConnections(ACCOUNT_ID)); @@ -133,9 +133,9 @@ void returnsAllAsyncOperationsBeforeDelayedResponsesAndParsesTheirResults() thro connections.add(connection("connection-1")); respond(CONNECTIONS_FIELD, connections); - List result = awaitDelayedResponse(() -> client.getConnectionsAsync(ACCOUNT_ID)); + List result = awaitDelayedResponse(() -> client.getConnectionsAsync(ACCOUNT_ID)); - assertEquals(List.of(new DDExternalDamConnection("DI_desktop", "run-1", "connection-1", "DBeaver", "26.2.4")), result); + assertEquals(List.of(new DDMessagingConnection("DI_desktop", "run-1", "connection-1", "DBeaver", "26.2.4")), result); assertThrows(UnsupportedOperationException.class, () -> result.add(result.get(0))); assertEquals("ExternalDamConnections", posted().get("operationName").getAsString()); assertEquals(ACCOUNT_ID.toString(), posted().getAsJsonObject("variables").get("accountId").getAsString()); @@ -180,7 +180,7 @@ void sendsAsyncMessagesWithExactJsonNumbersNullsAndUtf8() throws Exception { {"integer":9007199254740993,"decimal":1.2345678901234567890123456789, "nothing":null,"nested":[null,{"text":"Привет"}]} """).getAsJsonObject(); - DDMessage message = new DDMessage("topic-\"\\\n", "type-\"\\\n", payload); + DDMessagingMessage message = new DDMessagingMessage("topic-\"\\\n", "type-\"\\\n", payload); respond(SEND_FIELD, JsonParser.parseString("true")); assertTrue(client.sendMessageAsync(ACCOUNT_ID, "connection-\"\\\n", message).toCompletableFuture().get(5, TimeUnit.SECONDS)); @@ -197,7 +197,7 @@ void sendsAsyncMessagesWithExactJsonNumbersNullsAndUtf8() throws Exception { } @Test - void resolvesTheConnectionOwnerWithOneAuthenticatedReadAndNoDiscovery() throws DDExternalDamException { + void resolvesTheConnectionOwnerWithOneAuthenticatedReadAndNoDiscovery() throws DDMessagingException { String connectionId = "connection-\"\\\n) { injectedConnection }"; for (UUID accountId : List.of(ACCOUNT_ID, OTHER_ACCOUNT_ID)) { respond(CONNECTION_ACCOUNT_FIELD, JsonParser.parseString("\"" + accountId + "\"")); @@ -240,24 +240,24 @@ void rejectsBlankAndOversizedConnectionOwnerLookupIdsBeforeSendingAnyRequest() { } @Test - void preservesDistinctConcurrentConnectionsAndReturnsAnImmutableSnapshot() throws DDExternalDamException { + void preservesDistinctConcurrentConnectionsAndReturnsAnImmutableSnapshot() throws DDMessagingException { JsonArray connections = new JsonArray(); connections.add(connection("connection-1")); connections.add(connection("connection-2")); respond(CONNECTIONS_FIELD, connections); - List result = client.getConnections(ACCOUNT_ID); + List result = client.getConnections(ACCOUNT_ID); assertEquals(List.of( - new DDExternalDamConnection("DI_desktop", "run-1", "connection-1", "DBeaver", "26.2.4"), - new DDExternalDamConnection("DI_desktop", "run-1", "connection-2", "DBeaver", "26.2.4") + new DDMessagingConnection("DI_desktop", "run-1", "connection-1", "DBeaver", "26.2.4"), + new DDMessagingConnection("DI_desktop", "run-1", "connection-2", "DBeaver", "26.2.4") ), result); assertThrows(UnsupportedOperationException.class, () -> result.add(result.get(0))); assertThrows(UnsupportedOperationException.class, () -> result.set(0, result.get(1))); assertThrows(UnsupportedOperationException.class, () -> result.remove(0)); respond(CONNECTIONS_FIELD, new JsonArray()); - List empty = client.getConnections(ACCOUNT_ID); + List empty = client.getConnections(ACCOUNT_ID); assertEquals(List.of(), empty); assertThrows(UnsupportedOperationException.class, () -> empty.add(result.get(0))); @@ -267,7 +267,7 @@ void preservesDistinctConcurrentConnectionsAndReturnsAnImmutableSnapshot() throw } @Test - void scopesBothOperationsToTheAccountOnEveryRequest() throws DDExternalDamException { + void scopesBothOperationsToTheAccountOnEveryRequest() throws DDMessagingException { String connectionsQuery = null; String sendQuery = null; for (UUID accountId : List.of(ACCOUNT_ID, OTHER_ACCOUNT_ID)) { @@ -298,7 +298,7 @@ void scopesBothOperationsToTheAccountOnEveryRequest() throws DDExternalDamExcept } @Test - void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDExternalDamException { + void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDMessagingException { JsonObject nested = new JsonObject(); nested.addProperty("name", "quoted \"value\""); nested.addProperty("path", "C:\\temp\\file"); @@ -312,7 +312,7 @@ void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDE payload.addProperty("text", "Привет"); String topic = "topic-\"\\\n{ injectedTopic }"; String type = "type-\"\\\n{ injectedType }"; - DDMessage message = new DDMessage(topic, type, payload); + DDMessagingMessage message = new DDMessagingMessage(topic, type, payload); respond(SEND_FIELD, JsonParser.parseString("true")); String connectionId = "connection-\"\\\n) { injectedConnection }"; @@ -345,7 +345,7 @@ void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDE } @Test - void returnsTheSendBooleanWithoutAdditionalRequests() throws DDExternalDamException { + void returnsTheSendBooleanWithoutAdditionalRequests() throws DDMessagingException { for (boolean written : List.of(true, false)) { respond(SEND_FIELD, JsonParser.parseString(Boolean.toString(written))); int before = requestCount.get(); @@ -358,7 +358,7 @@ void returnsTheSendBooleanWithoutAdditionalRequests() throws DDExternalDamExcept } @Test - void registersDynamicCallbacksAsVariablesAndPreservesStableIdsAndOptionalConnectionScope() throws DDExternalDamException { + void registersDynamicCallbacksAsVariablesAndPreservesStableIdsAndOptionalConnectionScope() throws DDMessagingException { UUID subscriptionId = UUID.randomUUID(); for (String connectionId : List.of("", "socket")) { String callbackUrl = connectionId.isEmpty() ? CALLBACK_URL : "http://127.0.0.1:8080/"; @@ -398,7 +398,7 @@ void registersDynamicCallbacksAsVariablesAndPreservesStableIdsAndOptionalConnect } @Test - void registersTheServiceSecretAsTheCallbackSecret() throws DDExternalDamException { + void registersTheServiceSecretAsTheCallbackSecret() throws DDMessagingException { respond("registerExternalDamSubscription", JsonParser.parseString("60")); assertEquals(60, client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, @@ -406,7 +406,7 @@ void registersTheServiceSecretAsTheCallbackSecret() throws DDExternalDamExceptio CapturedRequest request = latestRequest.get(); assertNotNull(request); - assertEquals(SERVICE_SECRET, request.header(DDExternalDamApi.HEADER_SERVICE_TOKEN)); + assertEquals(SERVICE_SECRET, request.header(DDMessagingApi.HEADER_SERVICE_TOKEN)); JsonObject body = JsonParser.parseString(request.body()).getAsJsonObject(); assertEquals(SERVICE_SECRET, body.getAsJsonObject("variables").getAsJsonObject("input") .get("callbackSecret").getAsString()); @@ -415,7 +415,7 @@ void registersTheServiceSecretAsTheCallbackSecret() throws DDExternalDamExceptio } @Test - void acceptsCallbackRootOriginsAndPrintableSecretBoundaries() throws DDExternalDamException { + void acceptsCallbackRootOriginsAndPrintableSecretBoundaries() throws DDMessagingException { UUID subscriptionId = UUID.randomUUID(); respond("registerExternalDamSubscription", JsonParser.parseString("60")); for (String callbackUrl : List.of("http://127.0.0.1:8080", "https://callback.example.com/")) { @@ -432,7 +432,7 @@ void acceptsCallbackRootOriginsAndPrintableSecretBoundaries() throws DDExternalD } @Test - void preservesSubscriberIdsAcrossRegistrationAndCancellation() throws DDExternalDamException { + void preservesSubscriberIdsAcrossRegistrationAndCancellation() throws DDMessagingException { for (String subscriberId : List.of("service:account", "service account", "s".repeat(129), "s".repeat(256))) { UUID subscriptionId = UUID.randomUUID(); respond("registerExternalDamSubscription", JsonParser.parseString("60")); @@ -524,7 +524,7 @@ void rejectsInvalidCallbackSecretsBeforeSendingAnyRequest() { } @Test - void unregistersOnlyTheStableSubscriberAndSubscriptionId() throws DDExternalDamException { + void unregistersOnlyTheStableSubscriberAndSubscriptionId() throws DDMessagingException { UUID subscriptionId = UUID.randomUUID(); for (boolean removed : List.of(true, false)) { respond("unregisterExternalDamSubscription", JsonParser.parseString(Boolean.toString(removed))); @@ -641,7 +641,7 @@ JsonNull.INSTANCE, new JsonObject(), JsonParser.parseString("true"), JsonParser. assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); assertAsyncRequestFails(() -> client.getConnectionsAsync(ACCOUNT_ID)); - DDExternalDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); + DDMessagingException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); assertEquals("External DAM GraphQL request failed", failure.getMessage()); } } @@ -682,7 +682,7 @@ void rejectsHttpFailuresWithoutExposingTheResponseOrRetrying() { assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); assertRequestFails(() -> client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); - DDExternalDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); + DDMessagingException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); assertEquals(status == 204 ? "Invalid External DAM response" : "External DAM request failed: HTTP " + status, failure.getMessage()); } @@ -695,7 +695,7 @@ void neverFollowsRedirectsOrForwardsTheServiceSecret() throws IOException { HttpServer destination = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); destination.createContext("/", exchange -> { forwarded.incrementAndGet(); - forwardedSecret.set(exchange.getRequestHeaders().getFirst(DDExternalDamApi.HEADER_SERVICE_TOKEN)); + forwardedSecret.set(exchange.getRequestHeaders().getFirst(DDMessagingApi.HEADER_SERVICE_TOKEN)); try { exchange.sendResponseHeaders(200, -1); } catch (IOException e) { @@ -728,7 +728,7 @@ void neverFollowsRedirectsOrForwardsTheServiceSecret() throws IOException { } @Test - void neverRetriesOrReplaysFailedMutationsAfterRecovery() throws DDExternalDamException { + void neverRetriesOrReplaysFailedMutationsAfterRecovery() throws DDMessagingException { responseJson.set("{\"errors\":[{\"message\":\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\"}]}"); assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); @@ -750,7 +750,7 @@ void preservesAsyncNetworkFailureCausesAndAllowsSubsequentRequests() throws Exce responseJson.set(successfulEnvelope().toString()); truncateResponse.set(true); - DDExternalDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); + DDMessagingException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); assertEquals("External DAM service request failed", failure.getMessage()); assertInstanceOf(IOException.class, failure.getCause()); @@ -767,7 +767,7 @@ void reportsAsyncConnectionFailuresThroughTheStageWithTheirCause() { ExecutionException completion = assertThrows(ExecutionException.class, () -> client.getConnectionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS)); - DDExternalDamException failure = assertInstanceOf(DDExternalDamException.class, completion.getCause()); + DDMessagingException failure = assertInstanceOf(DDMessagingException.class, completion.getCause()); assertEquals("External DAM service request failed", failure.getMessage()); assertInstanceOf(IOException.class, failure.getCause()); assertSafeFailure(failure); @@ -784,7 +784,7 @@ void rejectsInvalidServiceOriginsBeforeSendingAnyRequest() { "http://user:" + SERVICE_SECRET + "@127.0.0.1", "http://127.0.0.1:0", "http://127.0.0.1:65536" )) { IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, - () -> new DDExternalDamClient(url, SERVICE_SECRET), url); + () -> new DDMessagingClient(url, SERVICE_SECRET), url); assertFalse(failure.toString().contains(SERVICE_SECRET)); } assertEquals(0, requestCount.get()); @@ -794,7 +794,7 @@ void rejectsInvalidServiceOriginsBeforeSendingAnyRequest() { void acceptsHttpAndHttpsRootOriginsAndPrintableSecretBoundaries() { for (String url : List.of(serviceUrl(), serviceUrl() + "/", "https://127.0.0.1", "https://127.0.0.1:8443/")) { for (String secret : List.of("!".repeat(32), "~".repeat(512))) { - assertDoesNotThrow(() -> new DDExternalDamClient(url, secret)); + assertDoesNotThrow(() -> new DDMessagingClient(url, secret)); } } assertEquals(0, requestCount.get()); @@ -804,7 +804,7 @@ void acceptsHttpAndHttpsRootOriginsAndPrintableSecretBoundaries() { void rejectsInvalidSecretsAndBlankConnectionIdsBeforeSendingAnyRequest() { for (String secret : List.of("", "s".repeat(31), "s".repeat(513), " ".repeat(32), "s".repeat(31) + "\n", "s".repeat(31) + "\t", "s".repeat(31) + "\u007f", "s".repeat(31) + "é")) { - assertThrows(IllegalArgumentException.class, () -> new DDExternalDamClient(serviceUrl(), secret)); + assertThrows(IllegalArgumentException.class, () -> new DDMessagingClient(serviceUrl(), secret)); } for (String connectionId : List.of("", " ", "\t\n")) { assertThrows(IllegalArgumentException.class, () -> client.sendMessage(ACCOUNT_ID, connectionId, message())); @@ -817,6 +817,7 @@ void rejectsInvalidSecretsAndBlankConnectionIdsBeforeSendingAnyRequest() { assertEquals(0, requestCount.get()); } + @Nullable private T awaitDelayedResponse(@NotNull Supplier> action) throws Exception { CountDownLatch received = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); @@ -903,11 +904,11 @@ private JsonObject posted() { CapturedRequest request = latestRequest.get(); assertNotNull(request); assertEquals("POST", request.method()); - assertEquals(DDExternalDamApi.GRAPHQL_PATH, request.uri().toString()); + assertEquals(DDMessagingApi.GRAPHQL_PATH, request.uri().toString()); assertEquals("application/json", request.header("Content-Type")); assertEquals("application/json", request.header("Accept")); assertEquals("no-store", request.header("Cache-Control")); - assertEquals(SERVICE_SECRET, request.header(DDExternalDamApi.HEADER_SERVICE_TOKEN)); + assertEquals(SERVICE_SECRET, request.header(DDMessagingApi.HEADER_SERVICE_TOKEN)); assertNull(request.header("Authorization")); assertNull(request.header("Cookie")); assertFalse(request.body().contains(SERVICE_SECRET)); @@ -934,24 +935,24 @@ private void assertInvalidConnection(@NotNull JsonObject invalid) { private void assertRequestFails(@NotNull Executable action) { int before = requestCount.get(); - DDExternalDamException failure = assertThrows(DDExternalDamException.class, action); + DDMessagingException failure = assertThrows(DDMessagingException.class, action); assertEquals(before + 1, requestCount.get()); assertSafeFailure(failure); } @NotNull - private DDExternalDamException assertAsyncRequestFails(@NotNull Supplier> action) { + private DDMessagingException assertAsyncRequestFails(@NotNull Supplier> action) { int before = requestCount.get(); CompletionStage stage = action.get(); ExecutionException completion = assertThrows(ExecutionException.class, () -> stage.toCompletableFuture().get(5, TimeUnit.SECONDS)); - DDExternalDamException failure = assertInstanceOf(DDExternalDamException.class, completion.getCause()); + DDMessagingException failure = assertInstanceOf(DDMessagingException.class, completion.getCause()); assertEquals(before + 1, requestCount.get()); assertSafeFailure(failure); return failure; } - private static void assertSafeFailure(@NotNull DDExternalDamException failure) { + private static void assertSafeFailure(@NotNull DDMessagingException failure) { StringWriter trace = new StringWriter(); failure.printStackTrace(new PrintWriter(trace)); assertFalse(trace.toString().contains(SERVICE_SECRET)); @@ -981,8 +982,8 @@ private static JsonObject successfulEnvelope() { } @NotNull - private static DDMessage message() { - return new DDMessage("projects", "project.updated", new JsonObject()); + private static DDMessagingMessage message() { + return new DDMessagingMessage("projects", "project.updated", new JsonObject()); } private record CapturedRequest( diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnectionTest.java similarity index 77% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnectionTest.java index 8c33a60..f224b05 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/model/DDExternalDamConnectionTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnectionTest.java @@ -14,22 +14,22 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.model; +package com.dbeaver.datadam.messaging.api.model; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -class DDExternalDamConnectionTest { +class DDMessagingConnectionTest { @Test void preservesSeparateDeploymentRunAndSocketIdentities() { - DDExternalDamConnection connection = new DDExternalDamConnection("deployment", "run", "socket", "DBeaver", "26.2"); + DDMessagingConnection connection = new DDMessagingConnection("deployment", "run", "socket", "DBeaver", "26.2"); Assertions.assertEquals("deployment", connection.deploymentId()); Assertions.assertEquals("run", connection.applicationRunId()); Assertions.assertEquals("socket", connection.connectionId()); Assertions.assertEquals("DBeaver", connection.product()); Assertions.assertEquals("26.2", connection.version()); - Assertions.assertNotEquals(connection, new DDExternalDamConnection("deployment", "run", "another-socket", "DBeaver", "26.2")); + Assertions.assertNotEquals(connection, new DDMessagingConnection("deployment", "run", "another-socket", "DBeaver", "26.2")); } @Test @@ -40,7 +40,7 @@ void rejectsMissingOrBlankMetadataInEveryField() { values[field] = invalid; Assertions.assertThrows(IllegalArgumentException.class, () -> - new DDExternalDamConnection(values[0], values[1], values[2], values[3], values[4])); + new DDMessagingConnection(values[0], values[1], values[2], values[3], values[4])); } } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessageTest.java similarity index 78% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessageTest.java index d25d5a6..8870f36 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/model/DDMessageTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessageTest.java @@ -14,7 +14,7 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.model; +package com.dbeaver.datadam.messaging.api.model; import com.google.gson.JsonObject; import org.junit.jupiter.api.Test; @@ -22,14 +22,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -class DDMessageTest { +class DDMessagingMessageTest { @Test void isolatesThePayloadFromChangesToTheOriginalObject() { JsonObject project = new JsonObject(); project.addProperty("name", "original"); JsonObject payload = new JsonObject(); payload.add("project", project); - DDMessage message = new DDMessage("projects", "updated", payload); + DDMessagingMessage message = new DDMessagingMessage("projects", "updated", payload); project.addProperty("name", "changed"); @@ -40,7 +40,7 @@ void isolatesThePayloadFromChangesToTheOriginalObject() { void isolatesListenersFromEachOthersPayloadChanges() { JsonObject payload = new JsonObject(); payload.addProperty("projectId", "project-1"); - DDMessage message = new DDMessage("projects", "updated", payload); + DDMessagingMessage message = new DDMessagingMessage("projects", "updated", payload); message.payload().addProperty("projectId", "changed"); @@ -49,14 +49,14 @@ void isolatesListenersFromEachOthersPayloadChanges() { @Test void rejectsBlankRoutingFields() { - assertThrows(IllegalArgumentException.class, () -> new DDMessage(" ", "updated", new JsonObject())); - assertThrows(IllegalArgumentException.class, () -> new DDMessage("projects", " ", new JsonObject())); + assertThrows(IllegalArgumentException.class, () -> new DDMessagingMessage(" ", "updated", new JsonObject())); + assertThrows(IllegalArgumentException.class, () -> new DDMessagingMessage("projects", " ", new JsonObject())); } @Test void rejectsMissingConnectionIdentifiers() { - assertThrows(IllegalArgumentException.class, () -> new DDConnectionInfo(" ", "run-1", "connection-1")); - assertThrows(IllegalArgumentException.class, () -> new DDConnectionInfo("DI_desktop", " ", "connection-1")); - assertThrows(IllegalArgumentException.class, () -> new DDConnectionInfo("DI_desktop", "run-1", " ")); + assertThrows(IllegalArgumentException.class, () -> new DDMessagingConnectionInfo(" ", "run-1", "connection-1")); + assertThrows(IllegalArgumentException.class, () -> new DDMessagingConnectionInfo("DI_desktop", " ", "connection-1")); + assertThrows(IllegalArgumentException.class, () -> new DDMessagingConnectionInfo("DI_desktop", "run-1", " ")); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransportTest.java similarity index 78% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransportTest.java index 7292466..876abd0 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/requestreply/DDRequestReplyTransportTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransportTest.java @@ -14,17 +14,17 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.requestreply; - -import com.dbeaver.datadam.external.api.exception.DDExternalDamException; -import com.dbeaver.datadam.external.api.model.DDConnectionInfo; -import com.dbeaver.datadam.external.api.model.DDMessage; -import com.dbeaver.datadam.external.api.transport.DDChannelState; -import com.dbeaver.datadam.external.api.transport.DDChannelStatus; -import com.dbeaver.datadam.external.api.transport.DDConnectionListener; -import com.dbeaver.datadam.external.api.transport.DDMessageChannel; -import com.dbeaver.datadam.external.api.transport.DDMessageListener; -import com.dbeaver.datadam.external.api.transport.DDSubscription; +package com.dbeaver.datadam.messaging.api.requestreply; + +import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; +import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannel; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelState; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelStatus; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingConnectionListener; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingMessageListener; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingSubscription; import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint; import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; @@ -54,9 +54,9 @@ import java.util.function.Consumer; @Timeout(10) -class DDRequestReplyTransportTest { +class DDMessagingRequestReplyTransportTest { private static final String TOPIC = "projects"; - private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("desktop", "run", "socket"); + private static final DDMessagingConnectionInfo CONNECTION = new DDMessagingConnectionInfo("desktop", "run", "socket"); @Test void exchangesRequestsInBothDirectionsThroughExistingMessageChannels() throws Exception { @@ -66,12 +66,12 @@ void exchangesRequestsInBothDirectionsThroughExistingMessageChannels() throws Ex handlerChannel.forward = message -> callerChannel.deliver(CONNECTION, message); JsonObject payload = new JsonObject(); payload.addProperty("value", "request"); - try (var handler = MessagingRequestReplyEndpoint.open(DDRequestReplyTransport.forChannel(handlerChannel, TOPIC)); + try (var handler = MessagingRequestReplyEndpoint.open(DDMessagingRequestReplyTransport.forChannel(handlerChannel, TOPIC)); var handlerRegistration = handler.handle(request -> { request.addProperty("handled", true); return request; }); - var caller = MessagingRequestReplyEndpoint.open(DDRequestReplyTransport.forChannel(callerChannel, TOPIC)); + var caller = MessagingRequestReplyEndpoint.open(DDMessagingRequestReplyTransport.forChannel(callerChannel, TOPIC)); var callerRegistration = caller.handle(request -> null)) { JsonObject response = caller.request(payload, Duration.ofSeconds(2)); @@ -92,8 +92,8 @@ void exchangesRequestsInBothDirectionsThroughExistingMessageChannels() throws Ex @Test void routesRequestsAndRepliesToUniqueEndpointTopics() throws Exception { FakeChannel channel = new FakeChannel(); - DDRequestReplyTransport first = DDRequestReplyTransport.forChannel(channel, TOPIC); - DDRequestReplyTransport second = DDRequestReplyTransport.forChannel(channel, TOPIC); + DDMessagingRequestReplyTransport first = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); + DDMessagingRequestReplyTransport second = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); UUID firstId = UUID.randomUUID(); UUID secondId = UUID.randomUUID(); Received firstReceived = new Received(); @@ -110,7 +110,7 @@ void routesRequestsAndRepliesToUniqueEndpointTopics() throws Exception { Assertions.assertEquals(TOPIC, channel.subscriptions.get(3).topic); MessagingRequestReplyMessage request = request(firstId); first.send(request).toCompletableFuture().join(); - DDMessage sent = channel.sent.getLast(); + DDMessagingMessage sent = channel.sent.getLast(); Assertions.assertEquals(TOPIC, sent.topic()); channel.deliver(CONNECTION, sent); Assertions.assertEquals(List.of(request), firstReceived.messages); @@ -135,20 +135,20 @@ void routesRequestsAndRepliesToUniqueEndpointTopics() throws Exception { void keepsDerivedTopicsWithinRegistrationMetadataLimit() throws Exception { FakeChannel channel = new FakeChannel(); String topic = "x".repeat(256); - DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, topic); + DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, topic); try (var subscription = adapter.subscribe(UUID.randomUUID(), new Received())) { Assertions.assertEquals(topic, channel.subscriptions.get(1).topic); Assertions.assertTrue(channel.subscriptions.getFirst().topic.length() <= 256); } - Assertions.assertThrows(IllegalArgumentException.class, () -> DDRequestReplyTransport.forChannel(channel, " ")); - Assertions.assertThrows(IllegalArgumentException.class, () -> DDRequestReplyTransport.forChannel(channel, topic + "x")); + Assertions.assertThrows(IllegalArgumentException.class, () -> DDMessagingRequestReplyTransport.forChannel(channel, " ")); + Assertions.assertThrows(IllegalArgumentException.class, () -> DDMessagingRequestReplyTransport.forChannel(channel, topic + "x")); } @Test void rollsBackReplySubscriptionAndStateObservationWhenRequestSubscriptionFails() { FakeChannel channel = new FakeChannel(); channel.failTopic = TOPIC; - DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); Assertions.assertSame(channel.subscriptionFailure, failure.getCause()); @@ -161,9 +161,9 @@ void rollsBackReplySubscriptionAndStateObservationWhenRequestSubscriptionFails() @Test void scopedSubscriberPreservesCheckedOpeningFailureAndRollsBack() { - DDExternalDamException unavailable = new DDExternalDamException("Unavailable"); + DDMessagingException unavailable = new DDMessagingException("Unavailable"); AtomicInteger closed = new AtomicInteger(); - DDRequestReplyTransport adapter = new DDRequestReplyTransport(TOPIC, + DDMessagingRequestReplyTransport adapter = new DDMessagingRequestReplyTransport(TOPIC, message -> CompletableFuture.completedFuture(null), (topic, listener) -> { if (topic.equals(TOPIC)) { throw unavailable; @@ -178,10 +178,12 @@ void scopedSubscriberPreservesCheckedOpeningFailureAndRollsBack() { @Test void rejectsDisconnectedAndClosedChannelsAtOpening() { - for (DDChannelState state : List.of(DDChannelState.DISCONNECTED, DDChannelState.CONNECTING, DDChannelState.CLOSED)) { + for (DDMessagingChannelState state : List.of( + DDMessagingChannelState.DISCONNECTED, DDMessagingChannelState.CONNECTING, DDMessagingChannelState.CLOSED + )) { FakeChannel channel = new FakeChannel(); - channel.setStatus(new DDChannelStatus(state, null)); - DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + channel.setStatus(new DDMessagingChannelStatus(state, null)); + DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); Assertions.assertEquals(MessagingRequestReplyException.Reason.DISCONNECTED, failure.reason()); @@ -195,11 +197,11 @@ void rollsBackAllResourcesAfterDisconnectDuringOpeningEvenIfTheChannelReconnects FakeChannel channel = new FakeChannel(); channel.onSubscribe = topic -> { if (TOPIC.equals(topic)) { - channel.setStatus(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); - channel.setStatus(new DDChannelStatus(DDChannelState.CONNECTED, CONNECTION)); + channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.DISCONNECTED, null)); + channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, CONNECTION)); } }; - DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); Assertions.assertEquals(MessagingRequestReplyException.Reason.DISCONNECTED, failure.reason()); @@ -211,29 +213,29 @@ void rollsBackAllResourcesAfterDisconnectDuringOpeningEvenIfTheChannelReconnects @Test void propagatesDisconnectAndAcceptsOnlyTheCurrentPhysicalSource() throws Exception { FakeChannel channel = new FakeChannel(); - DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); Received received = new Received(); try (var subscription = adapter.subscribe(endpoint, received)) { adapter.send(request(endpoint)).toCompletableFuture().join(); - DDMessage wire = channel.sent.getLast(); - channel.deliver(new DDConnectionInfo("other", "run", "socket"), wire); - channel.deliver(new DDConnectionInfo("desktop", "other", "socket"), wire); - channel.deliver(new DDConnectionInfo("desktop", "run", "other"), wire); + DDMessagingMessage wire = channel.sent.getLast(); + channel.deliver(new DDMessagingConnectionInfo("other", "run", "socket"), wire); + channel.deliver(new DDMessagingConnectionInfo("desktop", "other", "socket"), wire); + channel.deliver(new DDMessagingConnectionInfo("desktop", "run", "other"), wire); Assertions.assertTrue(received.messages.isEmpty()); - channel.setStatus(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); + channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.DISCONNECTED, null)); Assertions.assertEquals(1, received.disconnects); assertFailure(MessagingRequestReplyException.Reason.DISCONNECTED, adapter.send(request(endpoint))); channel.deliver(CONNECTION, wire); Assertions.assertTrue(received.messages.isEmpty()); - DDConnectionInfo replacement = new DDConnectionInfo("desktop", "new-run", "new-socket"); - channel.setStatus(new DDChannelStatus(DDChannelState.CONNECTED, replacement)); + DDMessagingConnectionInfo replacement = new DDMessagingConnectionInfo("desktop", "new-run", "new-socket"); + channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, replacement)); channel.deliver(CONNECTION, wire); channel.deliver(replacement, wire); Assertions.assertEquals(1, received.messages.size()); - channel.setStatus(new DDChannelStatus(DDChannelState.CONNECTED, CONNECTION)); + channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, CONNECTION)); Assertions.assertEquals(2, received.disconnects); - channel.setStatus(new DDChannelStatus(DDChannelState.CLOSED, null)); + channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CLOSED, null)); Assertions.assertEquals(3, received.disconnects); } } @@ -241,7 +243,7 @@ void propagatesDisconnectAndAcceptsOnlyTheCurrentPhysicalSource() throws Excepti @Test void completesMessageDeliveryBeforeNotifyingAConcurrentConnectionReplacement() throws Exception { FakeChannel channel = new FakeChannel(); - DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); CountDownLatch messageEntered = new CountDownLatch(1); CountDownLatch releaseMessage = new CountDownLatch(1); @@ -260,15 +262,15 @@ public void onDisconnect() { } })) { adapter.send(request(endpoint)).toCompletableFuture().join(); - DDMessage wire = channel.sent.getLast(); + DDMessagingMessage wire = channel.sent.getLast(); FutureTask message = async(() -> channel.deliver(CONNECTION, wire)); try { await(messageEntered); CountDownLatch replacing = new CountDownLatch(1); - DDConnectionInfo replacement = new DDConnectionInfo("desktop", "next-run", "next-socket"); + DDMessagingConnectionInfo replacement = new DDMessagingConnectionInfo("desktop", "next-run", "next-socket"); FutureTask state = async(() -> { replacing.countDown(); - channel.setStatus(new DDChannelStatus(DDChannelState.CONNECTED, replacement)); + channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, replacement)); }); try { await(replacing); @@ -293,7 +295,7 @@ public void onDisconnect() { @Test void notifiesConnectionReplacementBeforeDeliveringANewSourceMessage() throws Exception { FakeChannel channel = new FakeChannel(); - DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); CountDownLatch disconnectEntered = new CountDownLatch(1); CountDownLatch releaseDisconnect = new CountDownLatch(1); @@ -312,9 +314,10 @@ public void onDisconnect() { } })) { adapter.send(request(endpoint)).toCompletableFuture().join(); - DDMessage wire = channel.sent.getLast(); - DDConnectionInfo replacement = new DDConnectionInfo("desktop", "next-run", "next-socket"); - FutureTask state = async(() -> channel.setStatus(new DDChannelStatus(DDChannelState.CONNECTED, replacement))); + DDMessagingMessage wire = channel.sent.getLast(); + DDMessagingConnectionInfo replacement = new DDMessagingConnectionInfo("desktop", "next-run", "next-socket"); + FutureTask state = async(() -> + channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, replacement))); try { await(disconnectEntered); CountDownLatch receiving = new CountDownLatch(1); @@ -342,13 +345,13 @@ public void onDisconnect() { @Test void defersReentrantStateNotificationsUntilTheCurrentMessageReturns() throws Exception { FakeChannel channel = new FakeChannel(); - DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); List delivered = new ArrayList<>(); try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { @Override public void onMessage(@NotNull MessagingRequestReplyMessage message) { - channel.setStatus(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); + channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.DISCONNECTED, null)); delivered.add("message"); } @@ -368,7 +371,7 @@ public void onDisconnect() { @Test void establishesTheNewGenerationBeforeMessagesWhenStateObserversAreDelayed() throws Exception { FakeChannel channel = new FakeChannel(); - DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); List delivered = new ArrayList<>(); try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { @@ -383,16 +386,16 @@ public void onDisconnect() { } })) { adapter.send(request(endpoint)).toCompletableFuture().join(); - DDMessage wire = channel.sent.getLast(); - DDConnectionInfo replacement = new DDConnectionInfo("desktop", "next-run", "next-socket"); - DDChannelStatus ready = new DDChannelStatus(DDChannelState.CONNECTED, replacement); + DDMessagingMessage wire = channel.sent.getLast(); + DDMessagingConnectionInfo replacement = new DDMessagingConnectionInfo("desktop", "next-run", "next-socket"); + DDMessagingChannelStatus ready = new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, replacement); channel.status = ready; channel.deliver(replacement, wire); Assertions.assertEquals(List.of("disconnect", "message"), delivered); - for (Consumer observer : List.copyOf(channel.observers)) { - observer.accept(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); + for (Consumer observer : List.copyOf(channel.observers)) { + observer.accept(new DDMessagingChannelStatus(DDMessagingChannelState.DISCONNECTED, null)); observer.accept(ready); } Assertions.assertEquals(List.of("disconnect", "message"), delivered); @@ -407,8 +410,8 @@ public void onDisconnect() { void permitsConcurrentInlineRepliesWithoutHoldingCallbackLocks() throws Exception { FakeChannel leftChannel = new FakeChannel(); FakeChannel rightChannel = new FakeChannel(); - DDRequestReplyTransport left = DDRequestReplyTransport.forChannel(leftChannel, TOPIC); - DDRequestReplyTransport right = DDRequestReplyTransport.forChannel(rightChannel, TOPIC); + DDMessagingRequestReplyTransport left = DDMessagingRequestReplyTransport.forChannel(leftChannel, TOPIC); + DDMessagingRequestReplyTransport right = DDMessagingRequestReplyTransport.forChannel(rightChannel, TOPIC); UUID leftId = UUID.randomUUID(); UUID rightId = UUID.randomUUID(); CountDownLatch requests = new CountDownLatch(2); @@ -417,8 +420,8 @@ void permitsConcurrentInlineRepliesWithoutHoldingCallbackLocks() throws Exceptio var rightSubscription = right.subscribe(rightId, inlineReplies(right, requests, replies))) { left.send(request(leftId)).toCompletableFuture().join(); right.send(request(rightId)).toCompletableFuture().join(); - DDMessage toRight = leftChannel.sent.getLast(); - DDMessage toLeft = rightChannel.sent.getLast(); + DDMessagingMessage toRight = leftChannel.sent.getLast(); + DDMessagingMessage toLeft = rightChannel.sent.getLast(); leftChannel.forward = message -> rightChannel.deliver(CONNECTION, message); rightChannel.forward = message -> leftChannel.deliver(CONNECTION, message); @@ -435,7 +438,7 @@ void permitsConcurrentInlineRepliesWithoutHoldingCallbackLocks() throws Exceptio void boundsQueuedCallbacksAndPreservesDisconnectWhenTheQueueIsFull() throws Exception { for (boolean disconnect : List.of(false, true)) { FakeChannel channel = new FakeChannel(); - DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); CountDownLatch entered = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); @@ -455,7 +458,7 @@ public void onDisconnect() { } })) { adapter.send(request(endpoint)).toCompletableFuture().join(); - DDMessage wire = channel.sent.getLast(); + DDMessagingMessage wire = channel.sent.getLast(); FutureTask delivery = async(() -> channel.deliver(CONNECTION, wire)); try { await(entered); @@ -463,7 +466,7 @@ public void onDisconnect() { channel.deliver(CONNECTION, wire); } if (disconnect) { - channel.setStatus(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); + channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.DISCONNECTED, null)); } } finally { release.countDown(); @@ -478,7 +481,7 @@ public void onDisconnect() { @Test void releasesReceivingResourcesWhenAListenerFails() throws Exception { FakeChannel channel = new FakeChannel(); - DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); IllegalStateException failure = new IllegalStateException("Listener failed"); try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { @@ -506,15 +509,15 @@ public void onDisconnect() { @Test void rejectsMalformedRecognizedFramesAndWrongTypeTopicAndOrigin() throws Exception { FakeChannel channel = new FakeChannel(); - DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); Received received = new Received(); try (var subscription = adapter.subscribe(endpoint, received)) { MessagingRequestReplyMessage request = request(endpoint); adapter.send(request).toCompletableFuture().join(); - DDMessage requestWire = channel.sent.getLast(); + DDMessagingMessage requestWire = channel.sent.getLast(); adapter.send(request.response(new JsonObject())).toCompletableFuture().join(); - final DDMessage responseWire = channel.sent.getLast(); + final DDMessagingMessage responseWire = channel.sent.getLast(); List malformed = new ArrayList<>(); JsonObject abbreviatedId = requestWire.payload(); abbreviatedId.addProperty("requestId", "1-1-1-1-1"); @@ -541,34 +544,34 @@ void rejectsMalformedRecognizedFramesAndWrongTypeTopicAndOrigin() throws Excepti requestError.addProperty("error", "NO_HANDLER"); malformed.add(requestError); for (JsonObject envelope : malformed) { - channel.deliver(CONNECTION, new DDMessage(TOPIC, requestWire.type(), envelope)); + channel.deliver(CONNECTION, new DDMessagingMessage(TOPIC, requestWire.type(), envelope)); } JsonObject responseError = responseWire.payload(); responseError.addProperty("error", "NO_HANDLER"); - channel.deliver(CONNECTION, new DDMessage(responseWire.topic(), responseWire.type(), responseError)); + channel.deliver(CONNECTION, new DDMessagingMessage(responseWire.topic(), responseWire.type(), responseError)); JsonObject responsePrimitive = responseWire.payload(); responsePrimitive.addProperty("data", 42); - channel.deliver(CONNECTION, new DDMessage(responseWire.topic(), responseWire.type(), responsePrimitive)); + channel.deliver(CONNECTION, new DDMessagingMessage(responseWire.topic(), responseWire.type(), responsePrimitive)); JsonObject responseMissingData = responseWire.payload(); responseMissingData.remove("data"); - channel.deliver(CONNECTION, new DDMessage(responseWire.topic(), responseWire.type(), responseMissingData)); + channel.deliver(CONNECTION, new DDMessagingMessage(responseWire.topic(), responseWire.type(), responseMissingData)); JsonObject wrongOrigin = responseWire.payload(); wrongOrigin.addProperty("replyTo", UUID.randomUUID().toString()); - channel.deliver(CONNECTION, new DDMessage(responseWire.topic(), responseWire.type(), wrongOrigin)); + channel.deliver(CONNECTION, new DDMessagingMessage(responseWire.topic(), responseWire.type(), wrongOrigin)); adapter.send(request.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)).toCompletableFuture().join(); - DDMessage errorWire = channel.sent.getLast(); + DDMessagingMessage errorWire = channel.sent.getLast(); JsonObject errorData = errorWire.payload(); errorData.add("data", new JsonObject()); - channel.deliver(CONNECTION, new DDMessage(errorWire.topic(), errorWire.type(), errorData)); + channel.deliver(CONNECTION, new DDMessagingMessage(errorWire.topic(), errorWire.type(), errorData)); JsonObject unknownError = errorWire.payload(); unknownError.addProperty("error", "OTHER"); - channel.deliver(CONNECTION, new DDMessage(errorWire.topic(), errorWire.type(), unknownError)); + channel.deliver(CONNECTION, new DDMessagingMessage(errorWire.topic(), errorWire.type(), unknownError)); JsonObject missingError = errorWire.payload(); missingError.remove("error"); - channel.deliver(CONNECTION, new DDMessage(errorWire.topic(), errorWire.type(), missingError)); - channel.deliver(CONNECTION, new DDMessage(TOPIC, "unrelated", requestWire.payload())); - channel.deliver(CONNECTION, new DDMessage(TOPIC, responseWire.type(), responseWire.payload())); - channel.deliver(CONNECTION, new DDMessage(responseWire.topic(), requestWire.type(), requestWire.payload())); + channel.deliver(CONNECTION, new DDMessagingMessage(errorWire.topic(), errorWire.type(), missingError)); + channel.deliver(CONNECTION, new DDMessagingMessage(TOPIC, "unrelated", requestWire.payload())); + channel.deliver(CONNECTION, new DDMessagingMessage(TOPIC, responseWire.type(), responseWire.payload())); + channel.deliver(CONNECTION, new DDMessagingMessage(responseWire.topic(), requestWire.type(), requestWire.payload())); channel.subscriptions.getFirst().listener.onMessage(CONNECTION, requestWire); channel.subscriptions.get(1).listener.onMessage(CONNECTION, responseWire); Assertions.assertTrue(received.messages.isEmpty()); @@ -583,8 +586,8 @@ void rejectsMalformedRecognizedFramesAndWrongTypeTopicAndOrigin() throws Excepti @Test void closeIsIdempotentAndLeavesBorrowedSubscriptionsIntact() throws Exception { FakeChannel channel = new FakeChannel(); - final DDSubscription borrowed = channel.subscribe("other", (connection, message) -> { }); - DDRequestReplyTransport adapter = DDRequestReplyTransport.forChannel(channel, TOPIC); + final DDMessagingSubscription borrowed = channel.subscribe("other", (connection, message) -> { }); + DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); final MessagingSubscription subscription = adapter.subscribe(endpoint, new Received()); Assertions.assertThrows(IllegalStateException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); @@ -601,7 +604,7 @@ void closeIsIdempotentAndLeavesBorrowedSubscriptionsIntact() throws Exception { @Test void boundsOutstandingSendsAndReleasesBudgetOnCompletionAndFailure() throws Exception { List> pending = new ArrayList<>(); - DDRequestReplyTransport adapter = new DDRequestReplyTransport(TOPIC, message -> { + DDMessagingRequestReplyTransport adapter = new DDMessagingRequestReplyTransport(TOPIC, message -> { CompletableFuture result = new CompletableFuture<>(); pending.add(result); return result; @@ -642,7 +645,7 @@ void boundsOutstandingSendsAndReleasesBudgetOnCompletionAndFailure() throws Exce void releasesSendBudgetAfterSynchronousSenderFailure() throws Exception { IllegalStateException unavailable = new IllegalStateException("Unavailable"); AtomicInteger attempts = new AtomicInteger(); - DDRequestReplyTransport adapter = new DDRequestReplyTransport(TOPIC, message -> { + DDMessagingRequestReplyTransport adapter = new DDMessagingRequestReplyTransport(TOPIC, message -> { if (attempts.incrementAndGet() <= 129) { throw unavailable; } @@ -680,7 +683,7 @@ private static FutureTask async(@NotNull Runnable action) { @NotNull private static MessagingRequestReplyTransport.Listener inlineReplies( - @NotNull DDRequestReplyTransport adapter, + @NotNull DDMessagingRequestReplyTransport adapter, @NotNull CountDownLatch requests, @NotNull AtomicInteger replies ) { @@ -726,29 +729,29 @@ public void onDisconnect() { } } - private static final class FakeChannel implements DDMessageChannel { + private static final class FakeChannel implements DDMessagingChannel { private final List subscriptions = new ArrayList<>(); - private final List> observers = new ArrayList<>(); - private final List sent = new ArrayList<>(); + private final List> observers = new ArrayList<>(); + private final List sent = new ArrayList<>(); private final RuntimeException subscriptionFailure = new IllegalStateException("Subscription unavailable"); - private volatile DDChannelStatus status = new DDChannelStatus(DDChannelState.CONNECTED, CONNECTION); + private volatile DDMessagingChannelStatus status = new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, CONNECTION); @Nullable private String failTopic; @Nullable private Consumer onSubscribe; @Nullable - private Consumer forward; + private Consumer forward; private boolean closed; @Override @NotNull - public DDChannelStatus getStatus() { + public DDMessagingChannelStatus getStatus() { return status; } @Override @NotNull - public DDSubscription observeState(@NotNull Consumer observer) { + public DDMessagingSubscription observeState(@NotNull Consumer observer) { observers.add(observer); observer.accept(status); return () -> observers.remove(observer); @@ -756,7 +759,7 @@ public DDSubscription observeState(@NotNull Consumer observer) @Override @NotNull - public CompletionStage send(@NotNull DDMessage message) { + public CompletionStage send(@NotNull DDMessagingMessage message) { sent.add(message); if (forward != null) { forward.accept(message); @@ -766,7 +769,7 @@ public CompletionStage send(@NotNull DDMessage message) { @Override @NotNull - public DDSubscription subscribe(@NotNull String topic, @NotNull DDMessageListener listener) { + public DDMessagingSubscription subscribe(@NotNull String topic, @NotNull DDMessagingMessageListener listener) { if (topic.equals(failTopic)) { throw subscriptionFailure; } @@ -780,16 +783,16 @@ public DDSubscription subscribe(@NotNull String topic, @NotNull DDMessageListene @Override @NotNull - public DDSubscription addConnectionListener(@NotNull DDConnectionListener listener) { + public DDMessagingSubscription addConnectionListener(@NotNull DDMessagingConnectionListener listener) { throw new UnsupportedOperationException(); } - private void setStatus(@NotNull DDChannelStatus next) { + private void setStatus(@NotNull DDMessagingChannelStatus next) { status = next; List.copyOf(observers).forEach(observer -> observer.accept(next)); } - private void deliver(@NotNull DDConnectionInfo source, @NotNull DDMessage message) { + private void deliver(@NotNull DDMessagingConnectionInfo source, @NotNull DDMessagingMessage message) { for (TopicSubscription subscription : List.copyOf(subscriptions)) { if (subscription.active && subscription.topic.equals(message.topic())) { subscription.listener.onMessage(source, message); @@ -805,10 +808,10 @@ public void close() { private static final class TopicSubscription { private final String topic; - private final DDMessageListener listener; + private final DDMessagingMessageListener listener; private boolean active = true; - private TopicSubscription(@NotNull String topic, @NotNull DDMessageListener listener) { + private TopicSubscription(@NotNull String topic, @NotNull DDMessagingMessageListener listener) { this.topic = topic; this.listener = listener; } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingGsonRpcCodecTest.java similarity index 91% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingGsonRpcCodecTest.java index cd459d2..dcd2a42 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDGsonRpcCodecTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingGsonRpcCodecTest.java @@ -14,7 +14,7 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.rpc; +package com.dbeaver.datadam.messaging.api.rpc; import com.google.gson.FieldNamingPolicy; import com.google.gson.Gson; @@ -29,8 +29,8 @@ import java.lang.reflect.Type; import java.util.List; -class DDGsonRpcCodecTest { - private final DDGsonRpcCodec codec = new DDGsonRpcCodec(new Gson()); +class DDMessagingGsonRpcCodecTest { + private final DDMessagingGsonRpcCodec codec = new DDMessagingGsonRpcCodec(new Gson()); @Test void encodesOrdinaryAndPrimitiveValues() { @@ -61,7 +61,7 @@ void usesDeclaredGenericTypesForApplicationRecords() { @Test void preservesApplicationGsonConfiguration() { - var configured = new DDGsonRpcCodec(new GsonBuilder() + var configured = new DDMessagingGsonRpcCodec(new GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create()); Item value = new Item("configured", List.of(3)); @@ -73,7 +73,7 @@ void preservesApplicationGsonConfiguration() { @Test void requiresAnExplicitGsonAndDeclaredTypes() { - Assertions.assertThrows(NullPointerException.class, () -> new DDGsonRpcCodec(null)); + Assertions.assertThrows(NullPointerException.class, () -> new DDMessagingGsonRpcCodec(null)); Assertions.assertThrows(NullPointerException.class, () -> codec.encode("Hello", null)); Assertions.assertThrows(NullPointerException.class, () -> codec.decode(null, null)); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java similarity index 74% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java index ca0eeaf..8340378 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTopicsIntegrationTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java @@ -14,16 +14,16 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.rpc; - -import com.dbeaver.datadam.external.api.model.DDConnectionInfo; -import com.dbeaver.datadam.external.api.model.DDMessage; -import com.dbeaver.datadam.external.api.transport.DDChannelState; -import com.dbeaver.datadam.external.api.transport.DDChannelStatus; -import com.dbeaver.datadam.external.api.transport.DDConnectionListener; -import com.dbeaver.datadam.external.api.transport.DDMessageChannel; -import com.dbeaver.datadam.external.api.transport.DDMessageListener; -import com.dbeaver.datadam.external.api.transport.DDSubscription; +package com.dbeaver.datadam.messaging.api.rpc; + +import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannel; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelState; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelStatus; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingConnectionListener; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingMessageListener; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingSubscription; import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions; import com.dbeaver.messaging.rpc.MessagingRpcCodec; import com.dbeaver.messaging.rpc.MessagingRpcEndpoint; @@ -47,8 +47,8 @@ import java.util.function.Consumer; @Timeout(10) -class DDRpcTopicsIntegrationTest { - private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("desktop", "run", "physical-connection"); +class DDMessagingRpcTopicsIntegrationTest { + private static final DDMessagingConnectionInfo CONNECTION = new DDMessagingConnectionInfo("desktop", "run", "physical-connection"); private static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 16, 8); @Test @@ -62,15 +62,15 @@ void routesExactDeveloperTopicsBidirectionallyWithIndependentBindingCodecs() { }; firstBackend.forward = desktop::receive; secondBackend.forward = desktop::receive; - var codec = new DDGsonRpcCodec(new Gson()); + var codec = new DDMessagingGsonRpcCodec(new Gson()); String firstTopic = "hello.files.v2"; String secondTopic = "developer..chosen-topic"; var serviceCodec = new PrefixCodec("response:", "argument:"); var firstProxyCodec = new PrefixCodec("first:", "one:"); var secondProxyCodec = new PrefixCodec("second:", "two:"); - try (var desktopRpc = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(desktop), codec, OPTIONS); - var firstRpc = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(firstBackend), OPTIONS); - var secondRpc = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(secondBackend), OPTIONS); + try (var desktopRpc = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(desktop), codec, OPTIONS); + var firstRpc = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(firstBackend), OPTIONS); + var secondRpc = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(secondBackend), OPTIONS); var firstGreeting = firstRpc.register(firstTopic, Greeting.class, value -> value, serviceCodec); var secondGreeting = secondRpc.register(secondTopic, Greeting.class, value -> "other:" + value, codec); var reverseGreeting = desktopRpc.register(firstTopic, Greeting.class, value -> "desktop:" + value)) { @@ -86,7 +86,7 @@ void routesExactDeveloperTopicsBidirectionallyWithIndependentBindingCodecs() { Assertions.assertEquals(2, secondBackend.subscriptions.size()); Assertions.assertTrue(desktop.subscriptions.stream().anyMatch(value -> value.topic().equals(firstTopic))); Assertions.assertTrue(desktop.subscriptions.stream().anyMatch(value -> value.topic().equals(secondTopic))); - for (DDMessage message : desktop.sent) { + for (DDMessagingMessage message : desktop.sent) { if ("REQUEST".equals(message.payload().get("type").getAsString())) { Assertions.assertTrue(List.of(firstTopic, secondTopic).contains(message.topic())); JsonElement envelope = message.payload().get("data"); @@ -127,22 +127,23 @@ public Object decode(@Nullable JsonElement payload, @NotNull Type type) { } } - private static final class Channel implements DDMessageChannel { + private static final class Channel implements DDMessagingChannel { private final List subscriptions = new CopyOnWriteArrayList<>(); - private final List> observers = new CopyOnWriteArrayList<>(); - private final List sent = new CopyOnWriteArrayList<>(); - private Consumer forward = message -> { }; + private final List> observers = new CopyOnWriteArrayList<>(); + private final List sent = new CopyOnWriteArrayList<>(); + private Consumer forward = message -> { }; private boolean closed; @Override @NotNull - public DDChannelStatus getStatus() { - return new DDChannelStatus(closed ? DDChannelState.CLOSED : DDChannelState.CONNECTED, closed ? null : CONNECTION); + public DDMessagingChannelStatus getStatus() { + return new DDMessagingChannelStatus( + closed ? DDMessagingChannelState.CLOSED : DDMessagingChannelState.CONNECTED, closed ? null : CONNECTION); } @Override @NotNull - public DDSubscription observeState(@NotNull Consumer observer) { + public DDMessagingSubscription observeState(@NotNull Consumer observer) { observers.add(observer); observer.accept(getStatus()); return () -> observers.remove(observer); @@ -150,13 +151,13 @@ public DDSubscription observeState(@NotNull Consumer observer) @Override @NotNull - public CompletionStage send(@NotNull DDMessage message) { + public CompletionStage send(@NotNull DDMessagingMessage message) { sent.add(message); forward.accept(message); return CompletableFuture.completedFuture(null); } - private void receive(@NotNull DDMessage message) { + private void receive(@NotNull DDMessagingMessage message) { for (Subscription subscription : subscriptions) { if (subscription.topic.equals(message.topic())) { subscription.listener.onMessage(CONNECTION, message); @@ -166,7 +167,7 @@ private void receive(@NotNull DDMessage message) { @Override @NotNull - public DDSubscription subscribe(@NotNull String topic, @NotNull DDMessageListener listener) { + public DDMessagingSubscription subscribe(@NotNull String topic, @NotNull DDMessagingMessageListener listener) { var subscription = new Subscription(topic, listener); subscriptions.add(subscription); return () -> subscriptions.remove(subscription); @@ -174,7 +175,7 @@ public DDSubscription subscribe(@NotNull String topic, @NotNull DDMessageListene @Override @NotNull - public DDSubscription addConnectionListener(@NotNull DDConnectionListener listener) { + public DDMessagingSubscription addConnectionListener(@NotNull DDMessagingConnectionListener listener) { return () -> { }; } @@ -187,6 +188,6 @@ public void close() { } } - private record Subscription(@NotNull String topic, @NotNull DDMessageListener listener) { + private record Subscription(@NotNull String topic, @NotNull DDMessagingMessageListener listener) { } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java similarity index 90% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java index 077410d..4cc9e22 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/rpc/DDRpcTransportTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java @@ -14,18 +14,18 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.rpc; - -import com.dbeaver.datadam.external.api.DDExternalDamProtocol; -import com.dbeaver.datadam.external.api.model.DDConnectionInfo; -import com.dbeaver.datadam.external.api.model.DDMessage; -import com.dbeaver.datadam.external.api.requestreply.DDRequestReplyTransport; -import com.dbeaver.datadam.external.api.transport.DDChannelState; -import com.dbeaver.datadam.external.api.transport.DDChannelStatus; -import com.dbeaver.datadam.external.api.transport.DDConnectionListener; -import com.dbeaver.datadam.external.api.transport.DDMessageChannel; -import com.dbeaver.datadam.external.api.transport.DDMessageListener; -import com.dbeaver.datadam.external.api.transport.DDSubscription; +package com.dbeaver.datadam.messaging.api.rpc; + +import com.dbeaver.datadam.messaging.api.DDMessagingProtocol; +import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; +import com.dbeaver.datadam.messaging.api.requestreply.DDMessagingRequestReplyTransport; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannel; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelState; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelStatus; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingConnectionListener; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingMessageListener; +import com.dbeaver.datadam.messaging.api.transport.DDMessagingSubscription; import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint; import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; @@ -75,9 +75,9 @@ import java.util.function.Consumer; @Timeout(10) -public class DDRpcTransportTest { +public class DDMessagingRpcTransportTest { private static final String TOPIC = "custom.files"; - private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("desktop", "run", "socket"); + private static final DDMessagingConnectionInfo CONNECTION = new DDMessagingConnectionInfo("desktop", "run", "socket"); private static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 16, 8); private static final MessagingRpcCodec CODEC = new GsonCodec(); @@ -87,7 +87,7 @@ void delegatesRequestExecutionUnlessAnExplicitExecutorIsSupplied() { List queued = new ArrayList<>(); AtomicInteger calls = new AtomicInteger(); delegate.requestExecutor = queued::add; - DDRpcTransport adapter = new DDRpcTransport(delegate); + DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); adapter.executeRequest(calls::incrementAndGet); Assertions.assertEquals(1, queued.size()); Assertions.assertEquals(0, calls.get()); @@ -101,7 +101,7 @@ void delegatesRequestExecutionUnlessAnExplicitExecutorIsSupplied() { Assertions.assertSame(unavailable, Assertions.assertThrows(RejectedExecutionException.class, () -> adapter.executeRequest(calls::incrementAndGet))); - DDRpcTransport inline = new DDRpcTransport(delegate, Runnable::run); + DDMessagingRpcTransport inline = new DDMessagingRpcTransport(delegate, Runnable::run); inline.executeRequest(calls::incrementAndGet); Assertions.assertEquals(2, calls.get()); } @@ -114,8 +114,8 @@ void channelRequestsRunInlineByDefaultAndCanUseABorrowedExecutor() throws Except channel.forward = channel::deliver; AtomicReference handlerThread = new AtomicReference<>(); try (var endpoint = MessagingRpcEndpoint.open(topic -> supplied - ? DDRpcTransport.forChannel(channel, topic, executor) - : DDRpcTransport.forChannel(channel, topic), CODEC, OPTIONS); + ? DDMessagingRpcTransport.forChannel(channel, topic, executor) + : DDMessagingRpcTransport.forChannel(channel, topic), CODEC, OPTIONS); var service = endpoint.register(TOPIC, Text.class, value -> { handlerThread.set(Thread.currentThread()); return "reply:" + value; @@ -147,7 +147,8 @@ void mapsSuppliedExecutorRejectionToRemoteOverloadAndAllowsFreshCalls() { request.run(); }; var options = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 1, 1); - try (var endpoint = MessagingRpcEndpoint.open(topic -> DDRpcTransport.forChannel(channel, topic, executor), CODEC, options); + try (var endpoint = MessagingRpcEndpoint.open( + topic -> DDMessagingRpcTransport.forChannel(channel, topic, executor), CODEC, options); var service = endpoint.register(TOPIC, Text.class, value -> { calls.incrementAndGet(); return value; @@ -174,8 +175,10 @@ void supportsSynchronousCallbacksWithBorrowedRequestExecutorsAndIndependentChann FakeChannel right = new FakeChannel(); left.forward = message -> deliveries.execute(() -> right.deliver(message)); right.forward = message -> deliveries.execute(() -> left.deliver(message)); - try (var caller = MessagingRpcEndpoint.open(topic -> DDRpcTransport.forChannel(left, topic, requests), CODEC, OPTIONS); - var handler = MessagingRpcEndpoint.open(topic -> DDRpcTransport.forChannel(right, topic, requests), CODEC, OPTIONS); + try (var caller = MessagingRpcEndpoint.open( + topic -> DDMessagingRpcTransport.forChannel(left, topic, requests), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open( + topic -> DDMessagingRpcTransport.forChannel(right, topic, requests), CODEC, OPTIONS); var callback = caller.register(TOPIC, Text.class, value -> "callback:" + value); var service = handler.register(TOPIC, Text.class, value -> handler.proxy(TOPIC, Text.class).apply(value))) { Assertions.assertEquals("callback:value", caller.proxy(TOPIC, Text.class).apply("value")); @@ -195,10 +198,10 @@ void servesIndependentBidirectionalTopicsWithoutClosingTheBorrowedChannel() thro right.forward = left::deliver; AtomicInteger nullArguments = new AtomicInteger(); AtomicInteger notifications = new AtomicInteger(); - final DDSubscription borrowed = left.subscribe("other", (connection, message) -> { }); + final DDMessagingSubscription borrowed = left.subscribe("other", (connection, message) -> { }); try (var caller = MessagingRpcEndpoint.open( - topic -> new DDRpcTransport(DDRequestReplyTransport.forChannel(left, topic)), CODEC, OPTIONS); - var handler = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); + topic -> new DDMessagingRpcTransport(DDMessagingRequestReplyTransport.forChannel(left, topic)), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), CODEC, OPTIONS); var leftNames = caller.register("names", Names.class, values -> List.of("left", values.getFirst())); var rightNames = handler.register("names", Names.class, values -> List.of("right", values.getFirst())); var otherNames = handler.register("other-names", Names.class, values -> List.of("other")); @@ -248,8 +251,8 @@ void isolatesGsonArgumentsAndResultsAcrossRealMessageChannels() throws Exception right.forward = left::deliver; AtomicReference received = new AtomicReference<>(); Item argument = new Item("argument", new ArrayList<>(List.of("initial"))); - try (var caller = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(left), CODEC, OPTIONS); - var handler = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(left), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), CODEC, OPTIONS); var service = handler.register("records", Records.class, value -> { received.set(value); value.tags().add("handler"); @@ -270,7 +273,7 @@ void isolatesGsonArgumentsAndResultsAcrossRealMessageChannels() throws Exception @Test void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDRpcTransport adapter = new DDRpcTransport(delegate); + DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); Received received = new Received(); MessagingRequestReplyMessage> request = request(endpoint, new JsonPrimitive("argument")); @@ -331,7 +334,7 @@ void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { @Test void roundTripsStructuredDiagnosticsAndExplicitNullMessagesWithoutChangingOuterFrames() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDRpcTransport adapter = new DDRpcTransport(delegate); + DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); Received received = new Received(); MessagingRequestReplyMessage> request = request(endpoint, null); @@ -373,7 +376,7 @@ void roundTripsStructuredDiagnosticsAndExplicitNullMessagesWithoutChangingOuterF @Test void maximumDiagnosticsFitTheFullFramedMessageBudgetUnderEscapingAndUnicode() throws Exception { FakeChannel channel = new FakeChannel(); - DDRpcTransport adapter = DDRpcTransport.forChannel(channel, "x".repeat(256)); + DDMessagingRpcTransport adapter = DDMessagingRpcTransport.forChannel(channel, "x".repeat(256)); UUID endpoint = UUID.randomUUID(); Received received = new Received(); MessagingRequestReplyMessage> request = request(endpoint, null); @@ -389,7 +392,7 @@ void maximumDiagnosticsFitTheFullFramedMessageBudgetUnderEscapingAndUnicode() th MessagingRequestReplyMessage> failure = request.response( MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION, details)); adapter.send(failure).toCompletableFuture().join(); - DDMessage wire = channel.sent.getLast(); + DDMessagingMessage wire = channel.sent.getLast(); Assertions.assertEquals("datadam.requestreply.v1", wire.type()); JsonObject outer = wire.payload(); Assertions.assertEquals(Set.of("requestId", "replyTo", "type", "data"), outer.keySet()); @@ -397,9 +400,9 @@ void maximumDiagnosticsFitTheFullFramedMessageBudgetUnderEscapingAndUnicode() th Assertions.assertEquals(endpoint.toString(), outer.get("replyTo").getAsString()); Assertions.assertEquals("RESPONSE", outer.get("type").getAsString()); Assertions.assertEquals("dbeaver.rpc.v2", outer.getAsJsonObject("data").get("protocol").getAsString()); - String encoded = DDExternalDamProtocol.encode(wire); + String encoded = DDMessagingProtocol.encode(wire); Assertions.assertTrue(encoded.getBytes(StandardCharsets.UTF_8).length <= 65_536); - channel.deliver(DDExternalDamProtocol.decode(encoded)); + channel.deliver(DDMessagingProtocol.decode(encoded)); Assertions.assertEquals(failure, received.messages.getLast()); } Assertions.assertEquals(5, channel.sent.size()); @@ -410,7 +413,7 @@ void maximumDiagnosticsFitTheFullFramedMessageBudgetUnderEscapingAndUnicode() th @Test void rejectsMalformedExceptionDetailsInErrorEnvelopes() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDRpcTransport adapter = new DDRpcTransport(delegate); + DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); Received received = new Received(); MessagingRequestReplyMessage request = @@ -431,7 +434,7 @@ void rejectsMalformedExceptionDetailsInErrorEnvelopes() throws Exception { @Test void acceptsArbitraryJsonValuesAndNormalizesEncodedNull() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDRpcTransport adapter = new DDRpcTransport(delegate); + DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); Received received = new Received(); List values = List.of(JsonParser.parseString("{\"items\":[1,true,null]}"), @@ -458,7 +461,7 @@ void acceptsArbitraryJsonValuesAndNormalizesEncodedNull() throws Exception { @Test void isolatesSnapshotsEvenWhenTheBorrowedDelegateDoesNotCopy() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDRpcTransport adapter = new DDRpcTransport(delegate); + DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); Received received = new Received(); try (var subscription = adapter.subscribe(endpoint, received)) { @@ -486,7 +489,7 @@ void isolatesSnapshotsEvenWhenTheBorrowedDelegateDoesNotCopy() throws Exception @Test void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDRpcTransport adapter = new DDRpcTransport(delegate); + DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); Received received = new Received(); UUID endpoint = UUID.randomUUID(); MessagingRequestReplyMessage request = @@ -538,7 +541,7 @@ void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception @Test void dropsMalformedResponseAndNestedErrorShapes() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDRpcTransport adapter = new DDRpcTransport(delegate); + DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); Received received = new Received(); UUID endpoint = UUID.randomUUID(); MessagingRequestReplyMessage request = @@ -592,16 +595,16 @@ void dropsMalformedResponseAndNestedErrorShapes() throws Exception { @Test void requiresObjectEnvelopesThroughExistingDataDamFraming() throws Exception { FakeChannel channel = new FakeChannel(); - DDRpcTransport adapter = DDRpcTransport.forChannel(channel, TOPIC); + DDMessagingRpcTransport adapter = DDMessagingRpcTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); Received received = new Received(); try (var subscription = adapter.subscribe(endpoint, received)) { adapter.send(request(endpoint, null)).toCompletableFuture().join(); - DDMessage valid = channel.sent.getLast(); + DDMessagingMessage valid = channel.sent.getLast(); for (JsonElement value : List.of(new JsonPrimitive("text"), new JsonArray(), JsonNull.INSTANCE)) { JsonObject invalid = valid.payload(); invalid.add("data", value); - channel.deliver(new DDMessage(valid.topic(), valid.type(), invalid)); + channel.deliver(new DDMessagingMessage(valid.topic(), valid.type(), invalid)); } Assertions.assertTrue(received.messages.isEmpty()); channel.deliver(valid); @@ -612,7 +615,7 @@ void requiresObjectEnvelopesThroughExistingDataDamFraming() throws Exception { @Test void neutralRpcDistinguishesNestedErrorsOuterErrorsAndInvalidResponses() throws Exception { MemoryTransport delegate = new MemoryTransport(); - try (var endpoint = MessagingRpcEndpoint.open(topic -> new DDRpcTransport(delegate), CODEC, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(topic -> new DDMessagingRpcTransport(delegate), CODEC, OPTIONS)) { Text proxy = endpoint.proxy("text", Text.class); for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { delegate.forward = request -> delegate.deliver(request.response(errorEnvelope(error))); @@ -643,7 +646,7 @@ void neutralRpcDistinguishesNestedErrorsOuterErrorsAndInvalidResponses() throws void malformedAndUnknownVersionRepliesRemainUnansweredUntilTimeout() throws Exception { MemoryTransport delegate = new MemoryTransport(); MessagingRequestReplyOptions options = new MessagingRequestReplyOptions(Duration.ofMillis(100), 4, 2); - try (var endpoint = MessagingRpcEndpoint.open(topic -> new DDRpcTransport(delegate), CODEC, options)) { + try (var endpoint = MessagingRpcEndpoint.open(topic -> new DDMessagingRpcTransport(delegate), CODEC, options)) { for (Consumer mutation : List.>of( envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v1"), envelope -> envelope.addProperty("type", "UNKNOWN"), @@ -666,9 +669,9 @@ void dispatchesRealRpcFailuresInsideSuccessfulRequestReplyResponses() throws Exc FakeChannel right = new FakeChannel(); left.forward = right::deliver; right.forward = left::deliver; - try (var caller = MessagingRequestReplyEndpoint.open(DDRpcTransport.forChannel(left, TOPIC), OPTIONS); - var missingCaller = MessagingRequestReplyEndpoint.open(DDRpcTransport.forChannel(left, "missing"), OPTIONS); - var handler = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); + try (var caller = MessagingRequestReplyEndpoint.open(DDMessagingRpcTransport.forChannel(left, TOPIC), OPTIONS); + var missingCaller = MessagingRequestReplyEndpoint.open(DDMessagingRpcTransport.forChannel(left, "missing"), OPTIONS); + var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), CODEC, OPTIONS); var service = handler.register(TOPIC, Text.class, value -> { throw new IllegalStateException("Handler failed"); })) { @@ -702,8 +705,8 @@ void dispatchesRealRpcFailuresInsideSuccessfulRequestReplyResponses() throws Exc void proxyFailuresExposeApplicationDiagnosticsWithNestedAndSuppressedTracesAcrossReferenceFraming() { FakeChannel left = new FakeChannel(); FakeChannel right = new FakeChannel(); - left.forward = message -> right.deliver(DDExternalDamProtocol.decode(DDExternalDamProtocol.encode(message))); - right.forward = message -> left.deliver(DDExternalDamProtocol.decode(DDExternalDamProtocol.encode(message))); + left.forward = message -> right.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); + right.forward = message -> left.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); Throwable root = new UnsupportedOperationException("root cause"); Throwable nested = new IllegalArgumentException("nested cause", root); Throwable suppressed = new IllegalArgumentException("suppressed failure"); @@ -717,8 +720,8 @@ void proxyFailuresExposeApplicationDiagnosticsWithNestedAndSuppressedTracesAcros CountingCodec callerCodec = new CountingCodec(); CountingCodec handlerCodec = new CountingCodec(); AtomicInteger calls = new AtomicInteger(); - try (var caller = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(left), callerCodec, OPTIONS); - var handler = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(right), handlerCodec, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(left), callerCodec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), handlerCodec, OPTIONS); var service = handler.register(TOPIC, Text.class, value -> { calls.incrementAndGet(); throw application; @@ -763,8 +766,8 @@ void remoteArgumentAndResultCodecFailuresCarryDiagnosticsWithoutEncodingErrorsOr for (boolean encoding : List.of(false, true)) { FakeChannel left = new FakeChannel(); FakeChannel right = new FakeChannel(); - left.forward = message -> right.deliver(DDExternalDamProtocol.decode(DDExternalDamProtocol.encode(message))); - right.forward = message -> left.deliver(DDExternalDamProtocol.decode(DDExternalDamProtocol.encode(message))); + left.forward = message -> right.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); + right.forward = message -> left.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); CountingCodec callerCodec = new CountingCodec(); AtomicInteger encodes = new AtomicInteger(); AtomicInteger decodes = new AtomicInteger(); @@ -788,8 +791,8 @@ public Object decode(@Nullable JsonElement payload, @NotNull Type type) throws E return CODEC.decode(payload, type); } }; - try (var caller = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(left), callerCodec, OPTIONS); - var handler = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(right), handlerCodec, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(left), callerCodec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), handlerCodec, OPTIONS); var service = handler.register(TOPIC, Text.class, value -> { calls.incrementAndGet(); return value; @@ -817,7 +820,7 @@ public Object decode(@Nullable JsonElement payload, @NotNull Type type) throws E @Test void rejectsMismatchedOutgoingFramesBeforeCallingTheDelegate() { MemoryTransport delegate = new MemoryTransport(); - DDRpcTransport adapter = new DDRpcTransport(delegate); + DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); MessagingRequestReplyMessage> request = request(endpoint, null); List>> malformed = List.of( @@ -837,7 +840,7 @@ void rejectsMismatchedOutgoingFramesBeforeCallingTheDelegate() { @Test void preservesAsynchronousAndSynchronousSendFailuresWithoutRetriesOrWrapping() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDRpcTransport adapter = new DDRpcTransport(delegate); + DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); MessagingRequestReplyMessage> request = request(UUID.randomUUID(), null); CompletableFuture pending = new CompletableFuture<>(); delegate.sendResult = pending; @@ -870,7 +873,7 @@ void preservesAsynchronousAndSynchronousSendFailuresWithoutRetriesOrWrapping() t @Test void boundsFramingBeforeCopyingAndHoldsPermitsUntilActualSendCompletion() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDRpcTransport adapter = new DDRpcTransport(delegate); + DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); AtomicInteger copies = new AtomicInteger(); JsonElement value = new JsonElement() { @Override @@ -942,7 +945,7 @@ public JsonElement deepCopy() { @Test void releasesPermitsAfterSnapshotFramingFailures() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDRpcTransport adapter = new DDRpcTransport(delegate); + DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); IllegalStateException unavailable = new IllegalStateException("Snapshot unavailable"); JsonElement value = new JsonElement() { @Override @@ -965,7 +968,7 @@ public JsonElement deepCopy() { @Test void forwardsDisconnectsAndDelegatesSubscriptionOwnership() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDRpcTransport adapter = new DDRpcTransport(delegate); + DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); Received received = new Received(); MessagingSubscription subscription = adapter.subscribe(UUID.randomUUID(), received); Assertions.assertSame(delegate.subscription, subscription); @@ -977,7 +980,7 @@ void forwardsDisconnectsAndDelegatesSubscriptionOwnership() throws Exception { MemoryTransport failing = new MemoryTransport(); failing.subscribeFailure = unavailable; MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, - () -> new DDRpcTransport(failing).subscribe(UUID.randomUUID(), new Received())); + () -> new DDMessagingRpcTransport(failing).subscribe(UUID.randomUUID(), new Received())); Assertions.assertSame(unavailable, failure); } @@ -985,7 +988,7 @@ void forwardsDisconnectsAndDelegatesSubscriptionOwnership() throws Exception { void rollsBackDelegatedChannelSubscriptionsWithoutClosingTheBorrowedChannel() { FakeChannel channel = new FakeChannel(); channel.failTopic = TOPIC; - DDRpcTransport adapter = DDRpcTransport.forChannel(channel, TOPIC); + DDMessagingRpcTransport adapter = DDMessagingRpcTransport.forChannel(channel, TOPIC); MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); Assertions.assertSame(channel.subscriptionFailure, failure.getCause()); @@ -1000,7 +1003,7 @@ void rollsBackDelegatedChannelSubscriptionsWithoutClosingTheBorrowedChannel() { void failedLazyTopicSubscriptionRollsBackAndCanBeRetried() { FakeChannel channel = new FakeChannel(); channel.failTopic = TOPIC; - try (var endpoint = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(channel), CODEC, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(channel), CODEC, OPTIONS)) { MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> endpoint.proxy(TOPIC, Text.class)); Assertions.assertEquals(MessagingRpcException.Reason.SEND_FAILED, failure.reason()); Assertions.assertSame(channel.subscriptionFailure, failure.getCause().getCause()); @@ -1034,8 +1037,8 @@ public Object decode(@Nullable String payload, @NotNull Type type) { return payload; } }; - try (var caller = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(left), CODEC, OPTIONS); - var handler = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(right), CODEC, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(left), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), CODEC, OPTIONS); var service = handler.register(TOPIC, Text.class, value -> value)) { MessagingRpcException sendFailure = Assertions.assertThrows(MessagingRpcException.class, () -> caller.proxy(TOPIC, Text.class, incompatible).apply("incompatible")); @@ -1059,8 +1062,8 @@ public Object decode(@Nullable String payload, @NotNull Type type) { @Test void channelDisconnectTerminatesPendingRpcAndClosingLeavesTheChannelIntact() throws Exception { FakeChannel channel = new FakeChannel(); - channel.forward = message -> channel.setStatus(new DDChannelStatus(DDChannelState.DISCONNECTED, null)); - try (var endpoint = MessagingRpcEndpoint.open(DDRpcTransport.forChannel(channel), CODEC, OPTIONS)) { + channel.forward = message -> channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.DISCONNECTED, null)); + try (var endpoint = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(channel), CODEC, OPTIONS)) { MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> endpoint.proxy("text", Text.class).apply("request")); Assertions.assertEquals(MessagingRpcException.Reason.DISCONNECTED, failure.reason()); @@ -1289,27 +1292,27 @@ private void deliver(@NotNull MessagingRequestReplyMessage message) } } - private static final class FakeChannel implements DDMessageChannel { + private static final class FakeChannel implements DDMessagingChannel { private final List subscriptions = new CopyOnWriteArrayList<>(); - private final List> observers = new CopyOnWriteArrayList<>(); - private final List sent = new CopyOnWriteArrayList<>(); + private final List> observers = new CopyOnWriteArrayList<>(); + private final List sent = new CopyOnWriteArrayList<>(); private final RuntimeException subscriptionFailure = new IllegalStateException("Subscription unavailable"); - private DDChannelStatus status = new DDChannelStatus(DDChannelState.CONNECTED, CONNECTION); + private DDMessagingChannelStatus status = new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, CONNECTION); @Nullable private String failTopic; @Nullable - private Consumer forward; + private Consumer forward; private boolean closed; @Override @NotNull - public DDChannelStatus getStatus() { + public DDMessagingChannelStatus getStatus() { return status; } @Override @NotNull - public DDSubscription observeState(@NotNull Consumer observer) { + public DDMessagingSubscription observeState(@NotNull Consumer observer) { observers.add(observer); observer.accept(status); return () -> observers.remove(observer); @@ -1317,7 +1320,7 @@ public DDSubscription observeState(@NotNull Consumer observer) @Override @NotNull - public CompletionStage send(@NotNull DDMessage message) { + public CompletionStage send(@NotNull DDMessagingMessage message) { sent.add(message); if (forward != null) { forward.accept(message); @@ -1327,7 +1330,7 @@ public CompletionStage send(@NotNull DDMessage message) { @Override @NotNull - public DDSubscription subscribe(@NotNull String topic, @NotNull DDMessageListener listener) { + public DDMessagingSubscription subscribe(@NotNull String topic, @NotNull DDMessagingMessageListener listener) { if (topic.equals(failTopic)) { throw subscriptionFailure; } @@ -1338,11 +1341,11 @@ public DDSubscription subscribe(@NotNull String topic, @NotNull DDMessageListene @Override @NotNull - public DDSubscription addConnectionListener(@NotNull DDConnectionListener listener) { + public DDMessagingSubscription addConnectionListener(@NotNull DDMessagingConnectionListener listener) { throw new UnsupportedOperationException(); } - private void deliver(@NotNull DDMessage message) { + private void deliver(@NotNull DDMessagingMessage message) { for (TopicSubscription subscription : subscriptions) { if (subscription.active && subscription.topic.equals(message.topic())) { subscription.listener.onMessage(CONNECTION, message); @@ -1350,7 +1353,7 @@ private void deliver(@NotNull DDMessage message) { } } - private void setStatus(@NotNull DDChannelStatus next) { + private void setStatus(@NotNull DDMessagingChannelStatus next) { status = next; observers.forEach(observer -> observer.accept(next)); } @@ -1363,10 +1366,10 @@ public void close() { private static final class TopicSubscription { private final String topic; - private final DDMessageListener listener; + private final DDMessagingMessageListener listener; private volatile boolean active = true; - private TopicSubscription(@NotNull String topic, @NotNull DDMessageListener listener) { + private TopicSubscription(@NotNull String topic, @NotNull DDMessagingMessageListener listener) { this.topic = topic; this.listener = listener; } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatusTest.java similarity index 62% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java rename to apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatusTest.java index 8df8ff4..aceee96 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/external/api/transport/DDChannelStatusTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatusTest.java @@ -14,30 +14,30 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.external.api.transport; +package com.dbeaver.datadam.messaging.api.transport; -import com.dbeaver.datadam.external.api.model.DDConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -class DDChannelStatusTest { - private static final DDConnectionInfo CONNECTION = new DDConnectionInfo("deployment", "run", "socket"); +class DDMessagingChannelStatusTest { + private static final DDMessagingConnectionInfo CONNECTION = new DDMessagingConnectionInfo("deployment", "run", "socket"); @Test void onlyConnectedStatusIsReadyAndCarriesConnectionInformation() { - for (DDChannelState state : DDChannelState.values()) { - boolean connected = state == DDChannelState.CONNECTED; - DDChannelStatus status = new DDChannelStatus(state, connected ? CONNECTION : null); + for (DDMessagingChannelState state : DDMessagingChannelState.values()) { + boolean connected = state == DDMessagingChannelState.CONNECTED; + DDMessagingChannelStatus status = new DDMessagingChannelStatus(state, connected ? CONNECTION : null); Assertions.assertEquals(connected, status.isReady()); Assertions.assertEquals(connected ? CONNECTION : null, status.connection()); Assertions.assertThrows(IllegalArgumentException.class, - () -> new DDChannelStatus(state, connected ? null : CONNECTION)); + () -> new DDMessagingChannelStatus(state, connected ? null : CONNECTION)); } } @Test void rejectsMissingState() { - Assertions.assertThrows(NullPointerException.class, () -> new DDChannelStatus(null, null)); + Assertions.assertThrows(NullPointerException.class, () -> new DDMessagingChannelStatus(null, null)); } } From 9af779ec75fd8b230ebc75ee193e34c3e623558d Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Mon, 26 Oct 2026 16:16:16 +0400 Subject: [PATCH 25/30] dbeaver/pro#11108 Rename messaging connection info to DAM connection info --- .../api/client/DDMessagingCallbackClient.java | 8 ++++---- ...tionInfo.java => DDDamConnectionInfo.java} | 4 ++-- .../DDMessagingRequestReplyTransport.java | 4 ++-- .../transport/DDMessagingChannelStatus.java | 4 ++-- .../DDMessagingConnectionListener.java | 6 +++--- .../transport/DDMessagingMessageListener.java | 4 ++-- .../client/DDMessagingCallbackClientTest.java | 4 ++-- .../api/model/DDMessagingMessageTest.java | 6 +++--- .../DDMessagingRequestReplyTransportTest.java | 20 +++++++++---------- .../DDMessagingRpcTopicsIntegrationTest.java | 4 ++-- .../api/rpc/DDMessagingRpcTransportTest.java | 4 ++-- .../DDMessagingChannelStatusTest.java | 4 ++-- 12 files changed, 36 insertions(+), 36 deletions(-) rename apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/{DDMessagingConnectionInfo.java => DDDamConnectionInfo.java} (92%) diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java index 4ed2c54..59f51f2 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java @@ -18,7 +18,7 @@ import com.dbeaver.datadam.messaging.api.DDMessagingApi; import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; -import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import com.google.gson.JsonElement; import org.jkiss.code.NotNull; @@ -49,7 +49,7 @@ public DDMessagingCallbackClient(@NotNull String serviceUrl, @NotNull String ser public void deliver( @NotNull UUID subscriptionId, @NotNull UUID accountId, - @NotNull DDMessagingConnectionInfo connection, + @NotNull DDDamConnectionInfo connection, @NotNull DDMessagingMessage message ) throws DDMessagingException { readDeliveryResult(request("DeliverExternalDamMessage", DELIVER_MUTATION, @@ -60,7 +60,7 @@ public void deliver( public CompletionStage deliverAsync( @NotNull UUID subscriptionId, @NotNull UUID accountId, - @NotNull DDMessagingConnectionInfo connection, + @NotNull DDDamConnectionInfo connection, @NotNull DDMessagingMessage message ) { return requestAsync("DeliverExternalDamMessage", DELIVER_MUTATION, @@ -72,7 +72,7 @@ public CompletionStage deliverAsync( private static Map deliveryVariables( @NotNull UUID subscriptionId, @NotNull UUID accountId, - @NotNull DDMessagingConnectionInfo connection, + @NotNull DDDamConnectionInfo connection, @NotNull DDMessagingMessage message ) { return Map.of( diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnectionInfo.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDDamConnectionInfo.java similarity index 92% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnectionInfo.java rename to apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDDamConnectionInfo.java index bddde8f..1e88f8f 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnectionInfo.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDDamConnectionInfo.java @@ -18,12 +18,12 @@ import org.jkiss.code.NotNull; -public record DDMessagingConnectionInfo( +public record DDDamConnectionInfo( @NotNull String deploymentId, @NotNull String applicationRunId, @NotNull String connectionId ) { - public DDMessagingConnectionInfo { + public DDDamConnectionInfo { DDMessagingMessage.requireText(deploymentId, "deploymentId"); DDMessagingMessage.requireText(applicationRunId, "applicationRunId"); DDMessagingMessage.requireText(connectionId, "connectionId"); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransport.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransport.java index 4ae6436..80db929 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransport.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransport.java @@ -17,7 +17,7 @@ package com.dbeaver.datadam.messaging.api.requestreply; import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; -import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannel; import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelStatus; @@ -331,7 +331,7 @@ private boolean updateState(@NotNull DDMessagingChannelStatus next) { return disconnected && enqueue(null); } - private void receive(@NotNull DDMessagingConnectionInfo connection, @NotNull DDMessagingMessage message, boolean requestTopic) { + private void receive(@NotNull DDDamConnectionInfo connection, @NotNull DDMessagingMessage message, boolean requestTopic) { MessagingRequestReplyMessage frame = decode(message); if (frame == null || (requestTopic ? frame.type() != MessagingRequestReplyMessage.Type.REQUEST : frame.type() == MessagingRequestReplyMessage.Type.REQUEST || !endpointId.equals(frame.replyTo()))) { diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatus.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatus.java index 6e2e354..d92e902 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatus.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatus.java @@ -16,13 +16,13 @@ */ package com.dbeaver.datadam.messaging.api.transport; -import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; import java.util.Objects; -public record DDMessagingChannelStatus(@NotNull DDMessagingChannelState state, @Nullable DDMessagingConnectionInfo connection) { +public record DDMessagingChannelStatus(@NotNull DDMessagingChannelState state, @Nullable DDDamConnectionInfo connection) { public DDMessagingChannelStatus { Objects.requireNonNull(state); if ((state == DDMessagingChannelState.CONNECTED) != (connection != null)) { diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingConnectionListener.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingConnectionListener.java index 8aec813..3cdf6d8 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingConnectionListener.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingConnectionListener.java @@ -16,12 +16,12 @@ */ package com.dbeaver.datadam.messaging.api.transport; -import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; import org.jkiss.code.NotNull; /** Receives physical connection lifecycle notifications. */ public interface DDMessagingConnectionListener { - void onConnected(@NotNull DDMessagingConnectionInfo connection); + void onConnected(@NotNull DDDamConnectionInfo connection); - void onDisconnected(@NotNull DDMessagingConnectionInfo connection); + void onDisconnected(@NotNull DDDamConnectionInfo connection); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingMessageListener.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingMessageListener.java index d2da501..5d0d8c0 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingMessageListener.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingMessageListener.java @@ -16,12 +16,12 @@ */ package com.dbeaver.datadam.messaging.api.transport; -import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import org.jkiss.code.NotNull; /** Receives topic messages with their source connection. */ @FunctionalInterface public interface DDMessagingMessageListener { - void onMessage(@NotNull DDMessagingConnectionInfo connection, @NotNull DDMessagingMessage message); + void onMessage(@NotNull DDDamConnectionInfo connection, @NotNull DDMessagingMessage message); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClientTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClientTest.java index 432d62b..9b93ef6 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClientTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClientTest.java @@ -18,7 +18,7 @@ import com.dbeaver.datadam.messaging.api.DDMessagingApi; import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; -import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -63,7 +63,7 @@ class DDMessagingCallbackClientTest { private static final String SECRET = "external-dam-callback-secret-2026-test"; private static final UUID SUBSCRIPTION_ID = UUID.randomUUID(); private static final UUID ACCOUNT_ID = UUID.randomUUID(); - private static final DDMessagingConnectionInfo CONNECTION = new DDMessagingConnectionInfo("deployment", "run", "socket"); + private static final DDDamConnectionInfo CONNECTION = new DDDamConnectionInfo("deployment", "run", "socket"); private static final DDMessagingMessage MESSAGE = new DDMessagingMessage("projects", "updated", JsonParser.parseString(""" {"nested":{"value":42},"nothing":null,"integer":9007199254740993, diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessageTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessageTest.java index 8870f36..838167d 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessageTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessageTest.java @@ -55,8 +55,8 @@ void rejectsBlankRoutingFields() { @Test void rejectsMissingConnectionIdentifiers() { - assertThrows(IllegalArgumentException.class, () -> new DDMessagingConnectionInfo(" ", "run-1", "connection-1")); - assertThrows(IllegalArgumentException.class, () -> new DDMessagingConnectionInfo("DI_desktop", " ", "connection-1")); - assertThrows(IllegalArgumentException.class, () -> new DDMessagingConnectionInfo("DI_desktop", "run-1", " ")); + assertThrows(IllegalArgumentException.class, () -> new DDDamConnectionInfo(" ", "run-1", "connection-1")); + assertThrows(IllegalArgumentException.class, () -> new DDDamConnectionInfo("DI_desktop", " ", "connection-1")); + assertThrows(IllegalArgumentException.class, () -> new DDDamConnectionInfo("DI_desktop", "run-1", " ")); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransportTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransportTest.java index 876abd0..0b8c541 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransportTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransportTest.java @@ -17,7 +17,7 @@ package com.dbeaver.datadam.messaging.api.requestreply; import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; -import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannel; import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelState; @@ -56,7 +56,7 @@ @Timeout(10) class DDMessagingRequestReplyTransportTest { private static final String TOPIC = "projects"; - private static final DDMessagingConnectionInfo CONNECTION = new DDMessagingConnectionInfo("desktop", "run", "socket"); + private static final DDDamConnectionInfo CONNECTION = new DDDamConnectionInfo("desktop", "run", "socket"); @Test void exchangesRequestsInBothDirectionsThroughExistingMessageChannels() throws Exception { @@ -219,16 +219,16 @@ void propagatesDisconnectAndAcceptsOnlyTheCurrentPhysicalSource() throws Excepti try (var subscription = adapter.subscribe(endpoint, received)) { adapter.send(request(endpoint)).toCompletableFuture().join(); DDMessagingMessage wire = channel.sent.getLast(); - channel.deliver(new DDMessagingConnectionInfo("other", "run", "socket"), wire); - channel.deliver(new DDMessagingConnectionInfo("desktop", "other", "socket"), wire); - channel.deliver(new DDMessagingConnectionInfo("desktop", "run", "other"), wire); + channel.deliver(new DDDamConnectionInfo("other", "run", "socket"), wire); + channel.deliver(new DDDamConnectionInfo("desktop", "other", "socket"), wire); + channel.deliver(new DDDamConnectionInfo("desktop", "run", "other"), wire); Assertions.assertTrue(received.messages.isEmpty()); channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.DISCONNECTED, null)); Assertions.assertEquals(1, received.disconnects); assertFailure(MessagingRequestReplyException.Reason.DISCONNECTED, adapter.send(request(endpoint))); channel.deliver(CONNECTION, wire); Assertions.assertTrue(received.messages.isEmpty()); - DDMessagingConnectionInfo replacement = new DDMessagingConnectionInfo("desktop", "new-run", "new-socket"); + DDDamConnectionInfo replacement = new DDDamConnectionInfo("desktop", "new-run", "new-socket"); channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, replacement)); channel.deliver(CONNECTION, wire); channel.deliver(replacement, wire); @@ -267,7 +267,7 @@ public void onDisconnect() { try { await(messageEntered); CountDownLatch replacing = new CountDownLatch(1); - DDMessagingConnectionInfo replacement = new DDMessagingConnectionInfo("desktop", "next-run", "next-socket"); + DDDamConnectionInfo replacement = new DDDamConnectionInfo("desktop", "next-run", "next-socket"); FutureTask state = async(() -> { replacing.countDown(); channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, replacement)); @@ -315,7 +315,7 @@ public void onDisconnect() { })) { adapter.send(request(endpoint)).toCompletableFuture().join(); DDMessagingMessage wire = channel.sent.getLast(); - DDMessagingConnectionInfo replacement = new DDMessagingConnectionInfo("desktop", "next-run", "next-socket"); + DDDamConnectionInfo replacement = new DDDamConnectionInfo("desktop", "next-run", "next-socket"); FutureTask state = async(() -> channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, replacement))); try { @@ -387,7 +387,7 @@ public void onDisconnect() { })) { adapter.send(request(endpoint)).toCompletableFuture().join(); DDMessagingMessage wire = channel.sent.getLast(); - DDMessagingConnectionInfo replacement = new DDMessagingConnectionInfo("desktop", "next-run", "next-socket"); + DDDamConnectionInfo replacement = new DDDamConnectionInfo("desktop", "next-run", "next-socket"); DDMessagingChannelStatus ready = new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, replacement); channel.status = ready; @@ -792,7 +792,7 @@ private void setStatus(@NotNull DDMessagingChannelStatus next) { List.copyOf(observers).forEach(observer -> observer.accept(next)); } - private void deliver(@NotNull DDMessagingConnectionInfo source, @NotNull DDMessagingMessage message) { + private void deliver(@NotNull DDDamConnectionInfo source, @NotNull DDMessagingMessage message) { for (TopicSubscription subscription : List.copyOf(subscriptions)) { if (subscription.active && subscription.topic.equals(message.topic())) { subscription.listener.onMessage(source, message); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java index 8340378..0fe07b0 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java @@ -16,7 +16,7 @@ */ package com.dbeaver.datadam.messaging.api.rpc; -import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannel; import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelState; @@ -48,7 +48,7 @@ @Timeout(10) class DDMessagingRpcTopicsIntegrationTest { - private static final DDMessagingConnectionInfo CONNECTION = new DDMessagingConnectionInfo("desktop", "run", "physical-connection"); + private static final DDDamConnectionInfo CONNECTION = new DDDamConnectionInfo("desktop", "run", "physical-connection"); private static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 16, 8); @Test diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java index 4cc9e22..d4cb4b8 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java @@ -17,7 +17,7 @@ package com.dbeaver.datadam.messaging.api.rpc; import com.dbeaver.datadam.messaging.api.DDMessagingProtocol; -import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; import com.dbeaver.datadam.messaging.api.requestreply.DDMessagingRequestReplyTransport; import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannel; @@ -77,7 +77,7 @@ @Timeout(10) public class DDMessagingRpcTransportTest { private static final String TOPIC = "custom.files"; - private static final DDMessagingConnectionInfo CONNECTION = new DDMessagingConnectionInfo("desktop", "run", "socket"); + private static final DDDamConnectionInfo CONNECTION = new DDDamConnectionInfo("desktop", "run", "socket"); private static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 16, 8); private static final MessagingRpcCodec CODEC = new GsonCodec(); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatusTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatusTest.java index aceee96..4b17e51 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatusTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatusTest.java @@ -16,12 +16,12 @@ */ package com.dbeaver.datadam.messaging.api.transport; -import com.dbeaver.datadam.messaging.api.model.DDMessagingConnectionInfo; +import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; class DDMessagingChannelStatusTest { - private static final DDMessagingConnectionInfo CONNECTION = new DDMessagingConnectionInfo("deployment", "run", "socket"); + private static final DDDamConnectionInfo CONNECTION = new DDDamConnectionInfo("deployment", "run", "socket"); @Test void onlyConnectedStatusIsReadyAndCarriesConnectionInformation() { From e675ff19a9013a3a9c1777e42bc82c3438447570 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Mon, 26 Oct 2026 16:29:41 +0400 Subject: [PATCH 26/30] dbeaver/pro#11108 Update backend messaging module references --- apis/com.dbeaver.datadam.messaging.api/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apis/com.dbeaver.datadam.messaging.api/README.md b/apis/com.dbeaver.datadam.messaging.api/README.md index a60d81d..e340ee3 100644 --- a/apis/com.dbeaver.datadam.messaging.api/README.md +++ b/apis/com.dbeaver.datadam.messaging.api/README.md @@ -132,7 +132,7 @@ neither side creates a delivery executor or per-subscription mailbox. `deliverAs HTTP. Its default deadline is 30 seconds, including listener execution, and an explicit duration remains supported. Timeout does not promise cancellation of the remote handler. -Spring services use the shared `com.dbeaver.datadam.external.base` module from the DataDam server repository. +Spring services use the shared `com.dbeaver.datadam.messaging.base` module from the DataDam server repository. It supplies the callback endpoint, local dispatch, `DDMessagingSubscription.close()` cancellation, and automatic renewal during idle periods. Configure `datadam.external-dam-client.service-url`, `datadam.external-dam-client.service-secret`, and `datadam.external-dam-client.callback-url` on Account and Storage. Set `service-secret` to the External DAM service @@ -277,7 +277,7 @@ The resulting `MessagingRpcEndpoint` is not parameterized by JSON or by any othe ### Ready endpoints in Account, Storage, and Desktop -Account and Storage already import `DDExternalDamClientConfiguration` from `com.dbeaver.datadam.external.base`. +Account and Storage already import `DDExternalDamClientConfiguration` from `com.dbeaver.datadam.messaging.base`. That configuration supplies the callback receiver, subscriptions, and a shared `DDExternalDamRpc` connection owner. Inject that owner once, select a Desktop connection, and obtain its endpoint: From fc86fe3e7445808e2b8421e78166be335e8b49a9 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Mon, 26 Oct 2026 16:53:09 +0400 Subject: [PATCH 27/30] dbeaver/pro#11108 Update messaging implementation references --- apis/com.dbeaver.datadam.messaging.api/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apis/com.dbeaver.datadam.messaging.api/README.md b/apis/com.dbeaver.datadam.messaging.api/README.md index e340ee3..fcb57e5 100644 --- a/apis/com.dbeaver.datadam.messaging.api/README.md +++ b/apis/com.dbeaver.datadam.messaging.api/README.md @@ -277,9 +277,9 @@ The resulting `MessagingRpcEndpoint` is not parameterized by JSON or by any othe ### Ready endpoints in Account, Storage, and Desktop -Account and Storage already import `DDExternalDamClientConfiguration` from `com.dbeaver.datadam.messaging.base`. +Account and Storage already import `DDMessagingClientConfiguration` from `com.dbeaver.datadam.messaging.base`. That configuration supplies the callback receiver, subscriptions, and a shared -`DDExternalDamRpc` connection owner. Inject that owner once, select a Desktop connection, and obtain its endpoint: +`DDMessagingRpc` connection owner. Inject that owner once, select a Desktop connection, and obtain its endpoint: ```java var connections = rpcConnections.getConnections(accountId); @@ -287,7 +287,7 @@ var selected = chooseConnection(connections); MessagingRpcEndpoint rpc = rpcConnections.forConnection(selected.connectionId()); ``` -`rpcConnections` is the `DDExternalDamRpc` Spring bean. `getConnections(accountId)` returns an immutable current +`rpcConnections` is the `DDMessagingRpc` Spring bean. `getConnections(accountId)` returns an immutable current snapshot without opening RPC endpoints; `chooseConnection` represents application logic or a user selection from those descriptions. Selection and release require only the unique physical connection ID, with no account wrapper. The owner resolves the account through the authenticated private relay API when opening an endpoint, so a known @@ -309,7 +309,7 @@ the owner supplied different options. Desktop obtains the corresponding endpoint from its existing connection service: ```java -var connections = DDDesktopConnectionService.getInstance(); +var connections = DDMessagingDesktopConnectionService.getInstance(); MessagingRpcEndpoint rpc = connections.getRpc(); ``` From 89dfa7f0bf9a9a01e5c5a203ae8d7ca8cef14a48 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Mon, 26 Oct 2026 18:00:43 +0400 Subject: [PATCH 28/30] dbeaver/pro#11108 Support flexible RPC method signatures --- .../MESSAGING.md | 24 +- .../README.md | 21 +- .../api/client/DDMessagingCallbackClient.java | 10 +- .../api/rpc/DDMessagingRpcTransport.java | 27 +- .../messaging/rpc/MessagingRpcContract.java | 14 +- .../messaging/rpc/MessagingRpcEndpoint.java | 34 +- .../messaging/rpc/MessagingRpcMessage.java | 17 +- .../DDMessagingRpcTopicsIntegrationTest.java | 1 + .../api/rpc/DDMessagingRpcTransportTest.java | 305 +++++++++++++++- .../rpc/MessagingRpcContractTest.java | 341 +++++++++++++++++- .../rpc/MessagingRpcEndpointFacadeTest.java | 2 +- .../rpc/MessagingRpcEndpointTest.java | 15 +- .../rpc/MessagingRpcMessageTest.java | 77 +++- 13 files changed, 797 insertions(+), 91 deletions(-) diff --git a/apis/com.dbeaver.datadam.messaging.api/MESSAGING.md b/apis/com.dbeaver.datadam.messaging.api/MESSAGING.md index 4297158..af9afda 100644 --- a/apis/com.dbeaver.datadam.messaging.api/MESSAGING.md +++ b/apis/com.dbeaver.datadam.messaging.api/MESSAGING.md @@ -152,6 +152,10 @@ adapter encodes the neutral protocol envelope; the local codec encodes applicati long-polling, or in-memory transport can implement this interface without changing RPC code. No WebSocket types or JSON library are used here. +Requests use `MessagingRpcMessage.request(operation, arguments)`. The `arguments` list is an immutable positional +snapshot that preserves null entries, and the request's `payload` is null. Responses and errors have an empty +`arguments` list; a response result is carried in `payload`. + ### Application-owned registrations and callers Obtain an endpoint once from the application's connection owner, then pass it to ordinary Java objects. Registration @@ -201,16 +205,20 @@ cleanup throws. A repeated `close()` does not wait for another thread's ongoing ### Contracts and codecs -- Contracts are public, nonsealed interfaces with at least one abstract operation and exactly one argument per method. +- Contracts are public, nonsealed interfaces with at least one abstract operation. Methods can take zero, one, or multiple + arguments, including varargs. Return values can be ordinary reference or primitive types, or `void`. Reference arguments and results can be null. - Inherited abstract operations and overloads are supported. Operation IDs combine the method name and its erased - argument type; only methods from the registered interface can be invoked. Incoming names never trigger class loading - or arbitrary reflective lookup on the implementation. -- Concrete generic argument and result types, such as `List`, reach the codec as `Type` values. Unresolved - type variables, generic methods, default methods, varargs, and `Future`/`CompletionStage` returns are rejected during - proxy construction or registration. Generic-parent substitutions are not inferred. Inherited operations with - ambiguous generic argument or result types must be explicitly redeclared in the public contract. -- `MessagingRpcCodec.encode(value, type)` and `decode(payload, type)` are selected per proxy or implementation registration. Implementations must handle + argument types in declaration order as `methodName(type1,type2,...)`; a zero-argument method uses `foo()`. + A varargs parameter uses its array type and occupies one argument position. Only methods from the registered interface + can be invoked. Incoming names never trigger class loading or arbitrary reflective lookup on the implementation. +- Concrete generic argument and result types, such as `List`, reach the codec as `Type` values for each argument + and result. Unresolved type variables, generic methods, default methods, and `Future`/`CompletionStage` returns are + rejected during proxy construction or registration. Generic-parent substitutions are not inferred. Inherited operations + with ambiguous generic argument or result types must be explicitly redeclared in the public contract. +- `MessagingRpcCodec.encode(value, type)` and `decode(payload, type)` are selected per proxy or implementation registration + and invoked independently for each argument in declaration order and for the result. Zero-argument calls do not invoke + the argument codec; varargs are encoded and decoded as one array value. Implementations must handle nulls and concurrent calls. Serialization remains application-owned; the RPC core does not construct application types or select a serialization framework. Payload immutability and snapshot requirements are the same as request/reply. Peers must use compatible contracts and codecs; no automatic contract negotiation or class transfer is performed. diff --git a/apis/com.dbeaver.datadam.messaging.api/README.md b/apis/com.dbeaver.datadam.messaging.api/README.md index fcb57e5..852c140 100644 --- a/apis/com.dbeaver.datadam.messaging.api/README.md +++ b/apis/com.dbeaver.datadam.messaging.api/README.md @@ -271,7 +271,8 @@ The application must arrange the intended receiver's subscription before calling the same topic are not service discovery. Replica routing and exact callback-process ownership constraints still apply. `DDMessagingGsonRpcCodec` retains the supplied `Gson` instance, including application adapters and naming policies, and preserves -the declared generic `Type` passed by RPC. Encoded JSON null is represented as a nullable application payload. The +the declared generic `Type` passed by RPC for each argument and result. Each argument is encoded and decoded independently; +a varargs parameter is one array argument. Encoded JSON null is represented as a nullable application payload. The neutral messaging packages remain independent of Gson, and each proxy or registration can supply another compatible codec. The resulting `MessagingRpcEndpoint` is not parameterized by JSON or by any other payload representation. @@ -380,19 +381,23 @@ obtain the new endpoint and register the appropriate implementations again. Back process-local; the existing exact-owner callback routing requirement still applies. The inner JSON envelope uses `protocol: "dbeaver.rpc.v2"`, `type`, and `data`, plus `operation` for requests -or a fixed `error` code for failures. Error envelopes contain `exception`: either null or an object with string `type`, +or a fixed `error` code for failures. Request `data` is always a positional JSON array: `[]` for no arguments, +`[value]` for one argument, `[null]` for one null argument, or `[value1, value2]` for multiple arguments. A varargs +array occupies one position, so a single string varargs parameter can produce `[["a", "b"]]`. +Error envelopes contain `exception`: either null or an object with string `type`, nullable string `message`, string `stackTrace`, and boolean `truncated`. The receiver formats causes and suppressed -exceptions locally; no `Throwable` object is serialized and the application codec is not involved. Application `data` -can be any JSON value, including null; the outer DataDam message remains an object and retains its existing -request/reply correlation and source validation. Malformed inner envelopes +exceptions locally; no `Throwable` object is serialized and the application codec is not involved. Each encoded argument +and response `data` can be any JSON value, including null; the outer DataDam message remains an object and retains its +existing request/reply correlation and source validation. Malformed inner envelopes are ignored without exposing raw contents. Transport validation and RPC validation do not replace authentication. The adapter bounds framing, snapshot copying, and outstanding sends to 128 operations without a waiting queue. Cancellation of a returned send future does not free its slot until the underlying send actually finishes. -Interfaces declare ordinary one-argument methods returning values or `void`; applications do not implement transport -listeners. A `void` call waits for handler completion. Local serialization errors and transport failures become unchecked -`MessagingRpcException`. Remote handler and codec failures preserve their codes and expose `remoteException()` with bounded +Interfaces declare ordinary methods with zero, one, or multiple arguments, including varargs, returning values or `void`; +applications do not implement transport listeners. A `void` call waits for handler completion. Local serialization errors +and transport failures become unchecked `MessagingRpcException`. Remote handler and codec failures preserve their codes +and expose `remoteException()` with bounded type, message, and stack-trace text; ordinary exception logging includes the remote trace through a local diagnostic cause. Limits are 256 UTF-16 code units for the type, 1,024 for the message, and 8,192 for the trace, with `truncated` indicating incomplete diagnostics. Errors without exception details have null `remoteException()`. diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java index 59f51f2..563fb30 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java @@ -31,6 +31,8 @@ import java.util.concurrent.CompletionStage; public final class DDMessagingCallbackClient extends DDMessagingGraphQlClient { + private static final String DELIVER_FIELD = "deliverExternalDamMessage"; + private static final String DELIVER_OPERATION = "DeliverExternalDamMessage"; private static final String DELIVER_MUTATION = """ mutation DeliverExternalDamMessage($subscriptionId: ID!, $accountId: ID!, $connection: ExternalDamConnectionInfoInput!, $message: ExternalDamMessageInput!) { @@ -52,8 +54,8 @@ public void deliver( @NotNull DDDamConnectionInfo connection, @NotNull DDMessagingMessage message ) throws DDMessagingException { - readDeliveryResult(request("DeliverExternalDamMessage", DELIVER_MUTATION, - deliveryVariables(subscriptionId, accountId, connection, message), "deliverExternalDamMessage")); + readDeliveryResult(request(DELIVER_OPERATION, DELIVER_MUTATION, + deliveryVariables(subscriptionId, accountId, connection, message), DELIVER_FIELD)); } @NotNull @@ -63,8 +65,8 @@ public CompletionStage deliverAsync( @NotNull DDDamConnectionInfo connection, @NotNull DDMessagingMessage message ) { - return requestAsync("DeliverExternalDamMessage", DELIVER_MUTATION, - deliveryVariables(subscriptionId, accountId, connection, message), "deliverExternalDamMessage", + return requestAsync(DELIVER_OPERATION, DELIVER_MUTATION, + deliveryVariables(subscriptionId, accountId, connection, message), DELIVER_FIELD, DDMessagingCallbackClient::readDeliveryResult); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransport.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransport.java index 8abedea..d662eb0 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransport.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransport.java @@ -25,12 +25,15 @@ import com.dbeaver.messaging.rpc.MessagingRpcExceptionDetails; import com.dbeaver.messaging.rpc.MessagingRpcMessage; import com.dbeaver.messaging.rpc.MessagingRpcTransport; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonNull; import com.google.gson.JsonObject; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -157,8 +160,14 @@ private static JsonObject encode(@NotNull MessagingRpcMessage messa } else { if (message.type() == MessagingRpcMessage.Type.REQUEST) { envelope.addProperty(FIELD_OPERATION, message.operation()); + JsonArray arguments = new JsonArray(); + for (JsonElement argument : message.arguments()) { + arguments.add(argument == null ? JsonNull.INSTANCE : argument.deepCopy()); + } + envelope.add(FIELD_DATA, arguments); + } else { + envelope.add(FIELD_DATA, message.payload() == null ? JsonNull.INSTANCE : message.payload().deepCopy()); } - envelope.add(FIELD_DATA, message.payload() == null ? JsonNull.INSTANCE : message.payload().deepCopy()); } return envelope; } @@ -200,10 +209,18 @@ private static MessagingRequestReplyMessage> de if (data == null || envelope.size() != (type == MessagingRpcMessage.Type.REQUEST ? 4 : 3)) { return null; } - JsonElement snapshot = data.isJsonNull() ? null : data.deepCopy(); - payload = type == MessagingRpcMessage.Type.REQUEST - ? MessagingRpcMessage.request(string(envelope, FIELD_OPERATION), snapshot) - : MessagingRpcMessage.response(snapshot); + if (type == MessagingRpcMessage.Type.REQUEST) { + if (!data.isJsonArray()) { + return null; + } + List arguments = new ArrayList<>(data.getAsJsonArray().size()); + for (JsonElement argument : data.getAsJsonArray()) { + arguments.add(argument.isJsonNull() ? null : argument.deepCopy()); + } + payload = MessagingRpcMessage.request(string(envelope, FIELD_OPERATION), arguments); + } else { + payload = MessagingRpcMessage.response(data.isJsonNull() ? null : data.deepCopy()); + } } return new MessagingRequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), payload, message.error()); } catch (IllegalArgumentException e) { diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java index 6261822..fe9ba79 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java @@ -33,6 +33,7 @@ import java.util.Objects; import java.util.concurrent.CompletionStage; import java.util.concurrent.Future; +import java.util.stream.Collectors; final class MessagingRpcContract { private static final Map, Class> BOXED = Map.of( @@ -66,7 +67,7 @@ static MessagingRpcContract create(@NotNull Class type) { Map operations = new HashMap<>(); declarations.forEach((id, methods) -> operations.put(id, selectOperation(methods))); for (Method method : type.getMethods()) { - if (method.isBridge() && method.getParameterCount() == 1 && !operations.containsKey(operationId(method))) { + if (method.isBridge() && !operations.containsKey(operationId(method))) { throw new IllegalArgumentException("RPC contract has an unresolved generic bridge operation"); } } @@ -88,7 +89,8 @@ Method operation(@NotNull String id) { @NotNull static String operationId(@NotNull Method method) { - return method.getName() + "(" + method.getParameterTypes()[0].getTypeName() + ")"; + return method.getName() + Arrays.stream(method.getParameterTypes()) + .map(Class::getTypeName).collect(Collectors.joining(",", "(", ")")); } static boolean accepts(@NotNull Class type, @Nullable Object value) { @@ -128,11 +130,13 @@ private static Method selectOperation(@NotNull List declarations) { } private static void validate(@NotNull Method method) { - if (method.isDefault() || method.isVarArgs() || method.getParameterCount() != 1 || method.getTypeParameters().length != 0 - || !isConcrete(method.getGenericParameterTypes()[0]) || !isConcrete(method.getGenericReturnType()) + if (method.isDefault() || method.getTypeParameters().length != 0 + || !Arrays.stream(method.getGenericParameterTypes()).allMatch(MessagingRpcContract::isConcrete) + || !isConcrete(method.getGenericReturnType()) || CompletionStage.class.isAssignableFrom(method.getReturnType()) || Future.class.isAssignableFrom(method.getReturnType()) || operationId(method).length() > 1024) { - throw new IllegalArgumentException("RPC operations must be abstract, synchronous methods with one concretely typed argument"); + throw new IllegalArgumentException( + "RPC operations must be abstract, synchronous methods with concrete argument and result types"); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java index e4f1a08..3b51674 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java @@ -26,7 +26,9 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Proxy; +import java.lang.reflect.Type; import java.time.Duration; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -202,7 +204,7 @@ private T proxy( }; } Method operation = Objects.requireNonNull(contract.operation(MessagingRpcContract.operationId(method))); - return invoke(selected, operation, Objects.requireNonNull(arguments)[0], bindingCodec, timeout); + return invoke(selected, operation, arguments == null ? new Object[0] : arguments, bindingCodec, timeout); }); return type.cast(proxy); } @@ -211,7 +213,7 @@ private T proxy( private Object invoke( @NotNull Topic topic, @NotNull Method operation, - @Nullable Object argument, + @NotNull Object[] arguments, @NotNull MessagingRpcCodec

codec, @NotNull Duration timeout ) { @@ -223,8 +225,12 @@ private Object invoke( } MessagingRpcMessage

request; try { - P payload = codec.encode(argument, operation.getGenericParameterTypes()[0]); - request = MessagingRpcMessage.request(MessagingRpcContract.operationId(operation), payload); + Type[] types = operation.getGenericParameterTypes(); + List

encoded = new ArrayList<>(arguments.length); + for (int index = 0; index < arguments.length; index++) { + encoded.add(codec.encode(arguments[index], types[index])); + } + request = MessagingRpcMessage.request(MessagingRpcContract.operationId(operation), encoded); } catch (Exception e) { throw codecFailure(e); } @@ -280,19 +286,27 @@ private MessagingRpcMessage

dispatch(@NotNull Topic topic, @NotNull Messaging if (operation == null) { return MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION); } - Object argument; + if (request.arguments().size() != operation.getParameterCount()) { + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST); + } + Object[] arguments = new Object[operation.getParameterCount()]; + Type[] types = operation.getGenericParameterTypes(); + Class[] parameterTypes = operation.getParameterTypes(); try { - argument = registration.codec.decode(request.payload(), operation.getGenericParameterTypes()[0]); + for (int index = 0; index < arguments.length; index++) { + Object argument = registration.codec.decode(request.arguments().get(index), types[index]); + if (!MessagingRpcContract.accepts(parameterTypes[index], argument)) { + return MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST); + } + arguments[index] = argument; + } } catch (Exception e) { restoreInterruption(e); return MessagingRpcMessage.failure(MessagingRpcMessage.Error.CODEC_FAILED, MessagingRpcExceptionDetails.capture(e)); } - if (!MessagingRpcContract.accepts(operation.getParameterTypes()[0], argument)) { - return MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST); - } Object result; try { - result = operation.invoke(registration.implementation, argument); + result = operation.invoke(registration.implementation, arguments); } catch (InvocationTargetException e) { restoreInterruption(e.getCause()); return MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED, diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java index 0b3a897..71bbe65 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java @@ -19,11 +19,15 @@ import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Objects; public record MessagingRpcMessage

( @NotNull Type type, @Nullable String operation, + @NotNull List

arguments, @Nullable P payload, @Nullable Error error, @Nullable MessagingRpcExceptionDetails exception @@ -38,27 +42,28 @@ public enum Error { public MessagingRpcMessage { Objects.requireNonNull(type); + arguments = Collections.unmodifiableList(new ArrayList<>(Objects.requireNonNull(arguments))); if (exception != null && type != Type.ERROR) { throw new IllegalArgumentException("RPC exception details require an error response"); } if (type == Type.REQUEST) { - if (operation == null || operation.isBlank() || operation.length() > 1024 || error != null) { + if (operation == null || operation.isBlank() || operation.length() > 1024 || payload != null || error != null) { throw new IllegalArgumentException("Invalid RPC request"); } - } else if (operation != null || type == Type.ERROR && (error == null || payload != null) + } else if (operation != null || !arguments.isEmpty() || type == Type.ERROR && (error == null || payload != null) || type == Type.RESPONSE && error != null) { throw new IllegalArgumentException("Invalid RPC response"); } } @NotNull - public static

MessagingRpcMessage

request(@NotNull String operation, @Nullable P payload) { - return new MessagingRpcMessage<>(Type.REQUEST, operation, payload, null, null); + public static

MessagingRpcMessage

request(@NotNull String operation, @NotNull List

arguments) { + return new MessagingRpcMessage<>(Type.REQUEST, operation, arguments, null, null, null); } @NotNull public static

MessagingRpcMessage

response(@Nullable P payload) { - return new MessagingRpcMessage<>(Type.RESPONSE, null, payload, null, null); + return new MessagingRpcMessage<>(Type.RESPONSE, null, List.of(), payload, null, null); } @NotNull @@ -68,6 +73,6 @@ public static

MessagingRpcMessage

failure(@NotNull Error error) { @NotNull public static

MessagingRpcMessage

failure(@NotNull Error error, @Nullable MessagingRpcExceptionDetails exception) { - return new MessagingRpcMessage<>(Type.ERROR, null, null, Objects.requireNonNull(error), exception); + return new MessagingRpcMessage<>(Type.ERROR, null, List.of(), null, Objects.requireNonNull(error), exception); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java index 0fe07b0..936bad5 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java @@ -92,6 +92,7 @@ void routesExactDeveloperTopicsBidirectionallyWithIndependentBindingCodecs() { JsonElement envelope = message.payload().get("data"); Assertions.assertFalse(envelope.getAsJsonObject().has("topic")); Assertions.assertEquals("dbeaver.rpc.v2", envelope.getAsJsonObject().get("protocol").getAsString()); + Assertions.assertEquals(1, envelope.getAsJsonObject().getAsJsonArray("data").size()); } } firstGreeting.close(); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java index d4cb4b8..71e98d7 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java @@ -57,6 +57,8 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Set; @@ -75,7 +77,7 @@ import java.util.function.Consumer; @Timeout(10) -public class DDMessagingRpcTransportTest { +class DDMessagingRpcTransportTest { private static final String TOPIC = "custom.files"; private static final DDDamConnectionInfo CONNECTION = new DDDamConnectionInfo("desktop", "run", "socket"); private static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 16, 8); @@ -270,6 +272,172 @@ void isolatesGsonArgumentsAndResultsAcrossRealMessageChannels() throws Exception } } + @Test + void roundTripsZeroArgumentsAndOverloadsUsingEveryParameterAcrossJsonFraming() { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = message -> right.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); + right.forward = message -> left.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); + var codec = new DDMessagingGsonRpcCodec(new Gson()); + Overloaded implementation = new Overloaded() { + @Override + @NotNull + public String apply() { + return "zero"; + } + + @Override + @NotNull + public String apply(@Nullable String first) { + return "one:" + first; + } + + @Override + @NotNull + public String apply(@Nullable String first, @Nullable String second) { + return "two:" + first + ":" + second; + } + + @Override + @NotNull + public String apply(@Nullable String first, int second) { + return "number:" + first + ":" + second; + } + + @Override + @NotNull + public String apply(@Nullable String first, @Nullable String second, @Nullable String third) { + return "three:" + first + ":" + second + ":" + third; + } + }; + try (var caller = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(left), codec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), codec, OPTIONS); + var service = handler.register(TOPIC, Overloaded.class, implementation)) { + Overloaded proxy = caller.proxy(TOPIC, Overloaded.class); + Assertions.assertEquals("zero", proxy.apply()); + Assertions.assertEquals("one:first", proxy.apply("first")); + Assertions.assertEquals("two:first:second", proxy.apply("first", "second")); + Assertions.assertEquals("number:first:2", proxy.apply("first", 2)); + Assertions.assertEquals("three:first:null:third", proxy.apply("first", null, "third")); + Assertions.assertEquals("one:null", proxy.apply(null)); + Assertions.assertEquals("two:null:null", proxy.apply(null, null)); + List expectedData = List.of("[]", "[\"first\"]", "[\"first\",\"second\"]", "[\"first\",2]", + "[\"first\",null,\"third\"]", "[null]", "[null,null]"); + String text = String.class.getTypeName(); + List expectedOperations = List.of("apply()", "apply(" + text + ")", "apply(" + text + "," + text + ")", + "apply(" + text + ",int)", "apply(" + text + "," + text + "," + text + ")", "apply(" + text + ")", + "apply(" + text + "," + text + ")"); + Assertions.assertEquals(expectedData.size(), left.sent.size()); + Assertions.assertEquals(expectedData.size(), right.sent.size()); + for (int i = 0; i < expectedData.size(); i++) { + JsonObject request = left.sent.get(i).payload().getAsJsonObject("data"); + Assertions.assertEquals(Set.of("protocol", "type", "operation", "data"), request.keySet()); + Assertions.assertEquals(expectedOperations.get(i), request.get("operation").getAsString()); + Assertions.assertEquals(JsonParser.parseString(expectedData.get(i)), request.get("data")); + JsonObject response = right.sent.get(i).payload().getAsJsonObject("data"); + Assertions.assertEquals(Set.of("protocol", "type", "data"), response.keySet()); + Assertions.assertEquals("RESPONSE", response.get("type").getAsString()); + Assertions.assertTrue(response.get("data").isJsonPrimitive()); + } + } + } + + @Test + void roundTripsVarargsAsOneArrayArgumentAcrossJsonFraming() { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = message -> right.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); + right.forward = message -> left.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); + var codec = new DDMessagingGsonRpcCodec(new Gson()); + try (var caller = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(left), codec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), codec, OPTIONS); + var service = handler.register(TOPIC, Varargs.class, + (prefix, values) -> prefix + ":" + (values == null ? "null" : Arrays.toString(values)))) { + Varargs proxy = caller.proxy(TOPIC, Varargs.class); + Assertions.assertEquals("empty:[]", proxy.apply("empty")); + Assertions.assertEquals("many:[first, null, third]", proxy.apply("many", "first", null, "third")); + Assertions.assertEquals("null:null", proxy.apply("null", (String[]) null)); + List expectedData = List.of("[\"empty\",[]]", "[\"many\",[\"first\",null,\"third\"]]", "[\"null\",null]"); + Assertions.assertEquals(expectedData.size(), left.sent.size()); + for (int i = 0; i < expectedData.size(); i++) { + JsonObject request = left.sent.get(i).payload().getAsJsonObject("data"); + Assertions.assertEquals("apply(" + String.class.getTypeName() + "," + String[].class.getTypeName() + ")", + request.get("operation").getAsString()); + Assertions.assertEquals(JsonParser.parseString(expectedData.get(i)), request.get("data")); + Assertions.assertEquals(2, request.getAsJsonArray("data").size()); + } + } + } + + @Test + void roundTripsEachArgumentsDeclaredRecordAndGenericTypesAcrossJsonFraming() throws Exception { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = message -> right.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); + right.forward = message -> left.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); + CountingCodec callerCodec = new CountingCodec(new DDMessagingGsonRpcCodec(new Gson())); + CountingCodec handlerCodec = new CountingCodec(new DDMessagingGsonRpcCodec(new Gson())); + Item selected = new Item("selected", List.of("chosen")); + List items = List.of(new Item("nested", List.of("tag"))); + List numbers = List.of(7, 8); + try (var caller = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(left), callerCodec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), handlerCodec, OPTIONS); + var service = handler.register(TOPIC, RecordArguments.class, (label, item, values, counts) -> { + Assertions.assertEquals(items, values); + Assertions.assertEquals(numbers, counts); + Assertions.assertInstanceOf(Item.class, values.getFirst()); + Assertions.assertInstanceOf(Integer.class, counts.getFirst()); + return new Item(label + ":" + (item == null ? "null" : item.name()) + ":" + counts.getFirst(), + values.getFirst().tags()); + })) { + RecordArguments proxy = caller.proxy(TOPIC, RecordArguments.class); + Assertions.assertEquals(new Item("label:selected:7", List.of("tag")), proxy.apply("label", selected, items, numbers)); + Assertions.assertEquals(new Item("null:null:7", List.of("tag")), proxy.apply(null, null, items, numbers)); + List argumentTypes = Arrays.asList(RecordArguments.class + .getMethod("apply", String.class, Item.class, List.class, List.class).getGenericParameterTypes()); + Assertions.assertEquals(8, callerCodec.encodedTypes.size()); + Assertions.assertEquals(argumentTypes, callerCodec.encodedTypes.subList(0, 4)); + Assertions.assertEquals(argumentTypes, callerCodec.encodedTypes.subList(4, 8)); + Assertions.assertEquals(callerCodec.encodedTypes, handlerCodec.decodedTypes); + Assertions.assertEquals(List.of(Item.class, Item.class), handlerCodec.encodedTypes); + Assertions.assertEquals(handlerCodec.encodedTypes, callerCodec.decodedTypes); + Assertions.assertEquals(JsonParser.parseString("[\"label\",{\"name\":\"selected\",\"tags\":[\"chosen\"]}," + + "[{\"name\":\"nested\",\"tags\":[\"tag\"]}],[7,8]]"), + left.sent.getFirst().payload().getAsJsonObject("data").get("data")); + Assertions.assertEquals(JsonParser.parseString("[null,null,[{\"name\":\"nested\",\"tags\":[\"tag\"]}],[7,8]]"), + left.sent.getLast().payload().getAsJsonObject("data").get("data")); + } + } + + @Test + void rejectsWrongArgumentCountsAndNullPrimitiveArgumentsAcrossJsonFraming() throws Exception { + FakeChannel left = new FakeChannel(); + FakeChannel right = new FakeChannel(); + left.forward = message -> right.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); + right.forward = message -> left.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); + AtomicInteger calls = new AtomicInteger(); + var codec = new DDMessagingGsonRpcCodec(new Gson()); + try (var caller = MessagingRequestReplyEndpoint.open(DDMessagingRpcTransport.forChannel(left, TOPIC), OPTIONS); + var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), codec, OPTIONS); + var service = handler.register(TOPIC, Numbers.class, (first, second, third) -> { + calls.incrementAndGet(); + return first + second + third; + })) { + for (List arguments : List.>of(List.of(), List.of(new JsonPrimitive(1)), + List.of(new JsonPrimitive(1), new JsonPrimitive(2)), + List.of(new JsonPrimitive(1), new JsonPrimitive(2), new JsonPrimitive(3), new JsonPrimitive(4)), + Arrays.asList(new JsonPrimitive(1), null, new JsonPrimitive(3)))) { + Assertions.assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST), + caller.request(MessagingRpcMessage.request("sum(int,int,int)", arguments))); + } + Assertions.assertEquals(0, calls.get()); + Assertions.assertEquals(MessagingRpcMessage.response(new JsonPrimitive(6)), + caller.request(MessagingRpcMessage.request("sum(int,int,int)", + List.of(new JsonPrimitive(1), new JsonPrimitive(2), new JsonPrimitive(3))))); + Assertions.assertEquals(1, calls.get()); + } + } + @Test void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { MemoryTransport delegate = new MemoryTransport(); @@ -286,7 +454,7 @@ void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { Assertions.assertEquals(MessagingRequestReplyMessage.Type.REQUEST, sent.type()); Assertions.assertEquals("dbeaver.rpc.v2", Objects.requireNonNull(sent.payload()).get("protocol").getAsString()); JsonObject expectedRequest = requestEnvelope(); - expectedRequest.addProperty("data", "argument"); + expectedRequest.add("data", JsonParser.parseString("[\"argument\"]")); Assertions.assertEquals(expectedRequest, sent.payload()); Assertions.assertFalse(sent.payload().has("topic")); delegate.deliver(sent); @@ -431,6 +599,44 @@ void rejectsMalformedExceptionDetailsInErrorEnvelopes() throws Exception { } } + @Test + void roundTripsZeroSingleAndMultipleArgumentsAsJsonArrays() throws Exception { + MemoryTransport delegate = new MemoryTransport(); + DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + UUID endpoint = UUID.randomUUID(); + Received received = new Received(); + List> cases = List.of(List.of(), Collections.singletonList(null), List.of(new JsonPrimitive("single")), + Arrays.asList(new JsonPrimitive("first"), null, new JsonPrimitive(3)), + List.of(JsonParser.parseString("[1,null,3]"), JsonNull.INSTANCE, JsonParser.parseString("{\"name\":\"record\"}"))); + try (var subscription = adapter.subscribe(endpoint, received)) { + for (List arguments : cases) { + MessagingRequestReplyMessage> request = MessagingRequestReplyMessage.request( + UUID.randomUUID(), endpoint, MessagingRpcMessage.request("apply", arguments)); + adapter.send(request).toCompletableFuture().join(); + MessagingRequestReplyMessage wire = delegate.sent.getLast(); + JsonObject envelope = JsonParser.parseString(Objects.requireNonNull(wire.payload()).toString()).getAsJsonObject(); + Assertions.assertEquals(Set.of("protocol", "type", "operation", "data"), envelope.keySet()); + JsonArray expected = new JsonArray(); + arguments.forEach(expected::add); + Assertions.assertEquals(expected, envelope.get("data")); + delegate.deliver(new MessagingRequestReplyMessage<>(wire.requestId(), wire.replyTo(), wire.type(), envelope, wire.error())); + MessagingRequestReplyMessage> decoded = received.messages.getLast(); + Assertions.assertEquals(request.requestId(), decoded.requestId()); + Assertions.assertEquals(endpoint, decoded.replyTo()); + Assertions.assertEquals(MessagingRequestReplyMessage.Type.REQUEST, decoded.type()); + MessagingRpcMessage rpc = Objects.requireNonNull(decoded.payload()); + Assertions.assertEquals(MessagingRpcMessage.Type.REQUEST, rpc.type()); + Assertions.assertEquals("apply", rpc.operation()); + Assertions.assertEquals(arguments.stream().map(value -> value == null || value.isJsonNull() ? null : value).toList(), + rpc.arguments()); + Assertions.assertNull(rpc.payload()); + Assertions.assertNull(rpc.error()); + Assertions.assertNull(rpc.exception()); + } + Assertions.assertEquals(cases.size(), received.messages.size()); + } + } + @Test void acceptsArbitraryJsonValuesAndNormalizesEncodedNull() throws Exception { MemoryTransport delegate = new MemoryTransport(); @@ -445,14 +651,21 @@ void acceptsArbitraryJsonValuesAndNormalizesEncodedNull() throws Exception { MessagingRequestReplyMessage> request = request(endpoint, value); adapter.send(request).toCompletableFuture().join(); MessagingRequestReplyMessage wire = delegate.sent.getLast(); - Assertions.assertEquals(value, Objects.requireNonNull(wire.payload()).get("data")); + JsonArray arguments = Objects.requireNonNull(wire.payload()).getAsJsonArray("data"); + Assertions.assertEquals(1, arguments.size()); + Assertions.assertEquals(value, arguments.get(0)); delegate.deliver(wire); + MessagingRpcMessage decoded = Objects.requireNonNull(received.messages.getLast().payload()); + Assertions.assertNull(decoded.payload()); + Assertions.assertEquals(1, decoded.arguments().size()); Assertions.assertEquals(value.isJsonNull() ? null : value, - Objects.requireNonNull(received.messages.getLast().payload()).payload()); + decoded.arguments().getFirst()); adapter.send(request.response(MessagingRpcMessage.response(value))).toCompletableFuture().join(); + Assertions.assertEquals(value, Objects.requireNonNull(delegate.sent.getLast().payload()).get("data")); delegate.deliver(delegate.sent.getLast()); Assertions.assertEquals(MessagingRpcMessage.Type.RESPONSE, Objects.requireNonNull(received.messages.getLast().payload()).type()); + Assertions.assertTrue(received.messages.getLast().payload().arguments().isEmpty()); Assertions.assertEquals(value.isJsonNull() ? null : value, received.messages.getLast().payload().payload()); } } @@ -472,15 +685,18 @@ void isolatesSnapshotsEvenWhenTheBorrowedDelegateDoesNotCopy() throws Exception adapter.send(type == MessagingRequestReplyMessage.Type.REQUEST ? request : request.response(MessagingRpcMessage.response(argument))); JsonObject wire = Objects.requireNonNull(delegate.sent.getLast().payload()); + JsonObject wireValue = (type == MessagingRequestReplyMessage.Type.REQUEST + ? wire.getAsJsonArray("data").get(0) : wire.get("data")).getAsJsonObject(); argument.getAsJsonArray("items").add("caller"); - Assertions.assertEquals(1, wire.getAsJsonObject("data").getAsJsonArray("items").size()); + Assertions.assertEquals(1, wireValue.getAsJsonArray("items").size()); delegate.deliver(delegate.sent.getLast()); - JsonObject decoded = Objects.requireNonNull(Objects.requireNonNull(received.messages.getLast().payload()).payload()) - .getAsJsonObject(); - wire.getAsJsonObject("data").getAsJsonArray("items").add("delegate"); + MessagingRpcMessage receivedMessage = Objects.requireNonNull(received.messages.getLast().payload()); + JsonObject decoded = Objects.requireNonNull(type == MessagingRequestReplyMessage.Type.REQUEST + ? receivedMessage.arguments().getFirst() : receivedMessage.payload()).getAsJsonObject(); + wireValue.getAsJsonArray("items").add("delegate"); Assertions.assertEquals(1, decoded.getAsJsonArray("items").size()); decoded.getAsJsonArray("items").add("listener"); - Assertions.assertEquals("delegate", wire.getAsJsonObject("data").getAsJsonArray("items").get(1).getAsString()); + Assertions.assertEquals("delegate", wireValue.getAsJsonArray("items").get(1).getAsString()); Assertions.assertEquals("caller", argument.getAsJsonArray("items").get(1).getAsString()); } } @@ -527,6 +743,12 @@ void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception mutation.accept(invalid); delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, invalid)); } + for (JsonElement data : List.of(JsonNull.INSTANCE, new JsonPrimitive("legacy scalar"), + new JsonPrimitive("[]"), new JsonPrimitive(1), new JsonPrimitive(true), new JsonObject())) { + JsonObject invalid = requestEnvelope(); + invalid.add("data", data); + delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, invalid)); + } Assertions.assertTrue(received.messages.isEmpty()); JsonObject boundary = requestEnvelope(); boundary.addProperty("operation", "x".repeat(1024)); @@ -534,6 +756,7 @@ void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception Assertions.assertEquals(1, received.messages.size()); MessagingRpcMessage accepted = Objects.requireNonNull(received.messages.getLast().payload()); Assertions.assertEquals("x".repeat(1024), accepted.operation()); + Assertions.assertEquals(Collections.singletonList(null), accepted.arguments()); Assertions.assertNull(accepted.payload()); } } @@ -676,21 +899,24 @@ void dispatchesRealRpcFailuresInsideSuccessfulRequestReplyResponses() throws Exc throw new IllegalStateException("Handler failed"); })) { handler.proxy("missing", Text.class); - MessagingRpcMessage missingService = missingCaller.request(MessagingRpcMessage.request("apply", null)); + MessagingRpcMessage missingService = + missingCaller.request(MessagingRpcMessage.request("apply", Collections.singletonList(null))); Assertions.assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_SERVICE), missingService); - MessagingRpcMessage missingOperation = caller.request(MessagingRpcMessage.request("missing", null)); + MessagingRpcMessage missingOperation = + caller.request(MessagingRpcMessage.request("missing", Collections.singletonList(null))); Assertions.assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION), missingOperation); String operation = "apply(" + String.class.getTypeName() + ")"; final JsonSyntaxException expectedCodecFailure = Assertions.assertThrows(JsonSyntaxException.class, () -> CODEC.decode(new JsonObject(), String.class)); - MessagingRpcMessage codecFailure = caller.request(MessagingRpcMessage.request(operation, new JsonObject())); + MessagingRpcMessage codecFailure = + caller.request(MessagingRpcMessage.request(operation, List.of(new JsonObject()))); Assertions.assertEquals(MessagingRpcMessage.Error.CODEC_FAILED, codecFailure.error()); MessagingRpcExceptionDetails codecDetails = Objects.requireNonNull(codecFailure.exception()); Assertions.assertEquals(expectedCodecFailure.getClass().getName(), codecDetails.type()); Assertions.assertEquals(expectedCodecFailure.getMessage(), codecDetails.message()); Assertions.assertTrue(codecDetails.stackTrace().startsWith(expectedCodecFailure + System.lineSeparator())); MessagingRpcMessage handlerFailure = - caller.request(MessagingRpcMessage.request(operation, new JsonPrimitive("value"))); + caller.request(MessagingRpcMessage.request(operation, List.of(new JsonPrimitive("value")))); Assertions.assertEquals(MessagingRpcMessage.Error.HANDLER_FAILED, handlerFailure.error()); MessagingRpcExceptionDetails handlerDetails = Objects.requireNonNull(handlerFailure.exception()); Assertions.assertEquals(IllegalStateException.class.getName(), handlerDetails.type()); @@ -1078,7 +1304,8 @@ private static MessagingRequestReplyMessage> re @NotNull UUID endpoint, @Nullable JsonElement value ) { - return MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, MessagingRpcMessage.request("apply", value)); + return MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, + MessagingRpcMessage.request("apply", Collections.singletonList(value))); } private static void assertSendFailure(@NotNull Throwable expected, @NotNull CompletionStage result) { @@ -1097,7 +1324,7 @@ private static void assertOverloaded(@NotNull CompletionStage result) { @NotNull private static JsonObject requestEnvelope() { return JsonParser.parseString("{\"protocol\":\"dbeaver.rpc.v2\",\"type\":\"REQUEST\"," - + "\"operation\":\"apply\",\"data\":null}").getAsJsonObject(); + + "\"operation\":\"apply\",\"data\":[null]}").getAsJsonObject(); } @NotNull @@ -1186,6 +1413,37 @@ public interface Notification { void accept(@Nullable String value); } + public interface Overloaded { + @NotNull + String apply(); + + @NotNull + String apply(@Nullable String first); + + @NotNull + String apply(@Nullable String first, @Nullable String second); + + @NotNull + String apply(@Nullable String first, int second); + + @NotNull + String apply(@Nullable String first, @Nullable String second, @Nullable String third); + } + + public interface Varargs { + @NotNull + String apply(@NotNull String prefix, @Nullable String... values); + } + + public interface RecordArguments { + @NotNull + Item apply(@Nullable String label, @Nullable Item selected, @NotNull List items, @NotNull List numbers); + } + + public interface Numbers { + int sum(int first, int second, int third); + } + public record Item(@NotNull String name, @NotNull List tags) { } @@ -1207,21 +1465,34 @@ public Object decode(@Nullable JsonElement payload, @NotNull Type type) { } private static final class CountingCodec implements MessagingRpcCodec { + private final MessagingRpcCodec codec; private final AtomicInteger encodes = new AtomicInteger(); private final AtomicInteger decodes = new AtomicInteger(); + private final List encodedTypes = new ArrayList<>(); + private final List decodedTypes = new ArrayList<>(); + + private CountingCodec() { + this(CODEC); + } + + private CountingCodec(@NotNull MessagingRpcCodec codec) { + this.codec = codec; + } @Override @Nullable public JsonElement encode(@Nullable Object value, @NotNull Type type) throws Exception { encodes.incrementAndGet(); - return CODEC.encode(value, type); + encodedTypes.add(type); + return codec.encode(value, type); } @Override @Nullable public Object decode(@Nullable JsonElement payload, @NotNull Type type) throws Exception { decodes.incrementAndGet(); - return CODEC.decode(payload, type); + decodedTypes.add(type); + return codec.decode(payload, type); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java index b1400d6..358ea0d 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java @@ -16,25 +16,31 @@ */ package com.dbeaver.messaging.rpc; +import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @Timeout(10) -public class MessagingRpcContractTest extends MessagingRpcTestSupport { +class MessagingRpcContractTest extends MessagingRpcTestSupport { @Test void rejectsInvalidContractsBeforeSendingAndLeavesTheTopicAvailable() throws Exception { var transport = new MemoryTransport(); @@ -42,8 +48,7 @@ void rejectsInvalidContractsBeforeSendingAndLeavesTheTopicAvailable() throws Exc try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { for (Class type : List.of( String.class, HiddenService.class, SealedService.class, GenericService.class, RawGenericService.class, - EmptyService.class, StaticOnlyService.class, ObjectOnlyService.class, - DefaultService.class, VarargsService.class, NoArgumentService.class, TwoArgumentService.class, + EmptyService.class, StaticOnlyService.class, ObjectOnlyService.class, DefaultService.class, GenericMethodService.class, FutureService.class, StageService.class, CompletableService.class )) { assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("service", type, codec), type.getName()); @@ -58,6 +63,263 @@ void rejectsInvalidContractsBeforeSendingAndLeavesTheTopicAvailable() throws Exc } } + @Test + void invokesZeroAndTwoArgumentOperationsWithTheDeclaredArgumentLists() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var zero = server.register("zero", NoArgumentService.class, () -> "zero", serverCodec); + var two = server.register("two", TwoArgumentService.class, (first, second) -> first + ":" + second, serverCodec)) { + assertEquals("zero", client.proxy("zero", NoArgumentService.class, clientCodec).echo()); + assertEquals(MessagingRpcMessage.request("echo()", List.of()), clientTransport.next().payload()); + assertTrue(clientCodec.encoded.isEmpty()); + assertTrue(serverCodec.decoded.isEmpty()); + + assertEquals("first:second", client.proxy("two", TwoArgumentService.class, clientCodec).echo("first", "second")); + assertEquals(MessagingRpcMessage.request("echo(java.lang.String,java.lang.String)", List.of("first", "second")), + clientTransport.next().payload()); + assertEquals(List.of(new CodecCall("first", String.class), new CodecCall("second", String.class)), clientCodec.encoded); + assertEquals(clientCodec.encoded, serverCodec.decoded); + assertEquals(List.of(new CodecCall("zero", String.class), new CodecCall("first:second", String.class)), serverCodec.encoded); + assertEquals(serverCodec.encoded, clientCodec.decoded); + } + } + + @Test + void preservesEachArgumentsGenericTypeAndNullableReferenceValue() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + var calls = new AtomicInteger(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("generic", ParameterizedArgumentsService.class, (values, groups, offset) -> { + calls.incrementAndGet(); + return values == null || groups == null ? null : Map.of("total", values.size() + groups.get("group").size() + offset); + }, serverCodec)) { + ParameterizedArgumentsService proxy = client.proxy("generic", ParameterizedArgumentsService.class, clientCodec); + List values = List.of("one", "two"); + Map> groups = Map.of("group", List.of(3, 4, 5)); + Map expected = Map.of("total", 12); + + assertEquals(expected, proxy.count(values, groups, 7)); + assertNull(proxy.count(null, groups, 8)); + assertNull(proxy.count(values, null, 9)); + assertEquals(3, calls.get()); + + var operation = ParameterizedArgumentsService.class.getMethod("count", List.class, Map.class, int.class); + var parameterTypes = operation.getGenericParameterTypes(); + assertEquals("java.util.List", parameterTypes[0].getTypeName()); + assertEquals("java.util.Map>", parameterTypes[1].getTypeName()); + assertEquals(int.class, parameterTypes[2]); + assertEquals(List.of( + new CodecCall(values, parameterTypes[0]), new CodecCall(groups, parameterTypes[1]), new CodecCall(7, int.class), + new CodecCall(null, parameterTypes[0]), new CodecCall(groups, parameterTypes[1]), new CodecCall(8, int.class), + new CodecCall(values, parameterTypes[0]), new CodecCall(null, parameterTypes[1]), new CodecCall(9, int.class) + ), clientCodec.encoded); + assertEquals(clientCodec.encoded, serverCodec.decoded); + var returnType = operation.getGenericReturnType(); + assertEquals(List.of(new CodecCall(expected, returnType), new CodecCall(null, returnType), new CodecCall(null, returnType)), + serverCodec.encoded); + assertEquals(serverCodec.encoded, clientCodec.decoded); + assertEquals(List.of(values, groups, 7), clientTransport.next().payload().arguments()); + assertEquals(Arrays.asList(null, groups, 8), clientTransport.next().payload().arguments()); + assertEquals(Arrays.asList(values, null, 9), clientTransport.next().payload().arguments()); + } + } + + @Test + void treatsVarargsAsOneFinalArrayArgumentIncludingEmptyAndNullArrays() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var clientCodec = new TestCodec(); + var serverCodec = new TestCodec(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var arrays = server.register("array", VarargsService.class, + values -> values == null ? "null" : String.join(",", values), serverCodec); + var prefixed = server.register("prefixed", PrefixedVarargsService.class, + (prefix, values) -> prefix + String.join(",", values), serverCodec)) { + VarargsService proxy = client.proxy("array", VarargsService.class, clientCodec); + String[] values = {"one", "two"}; + assertEquals("one,two", proxy.echo(values)); + assertEquals("", proxy.echo()); + assertEquals("null", proxy.echo((String[]) null)); + assertEquals("prefix:three,four", + client.proxy("prefixed", PrefixedVarargsService.class, clientCodec).echo("prefix:", "three", "four")); + + var arrayRequest = clientTransport.next().payload(); + assertEquals("echo(java.lang.String[])", arrayRequest.operation()); + assertEquals(1, arrayRequest.arguments().size()); + assertSame(values, arrayRequest.arguments().get(0)); + var emptyRequest = clientTransport.next().payload(); + assertEquals("echo(java.lang.String[])", emptyRequest.operation()); + assertEquals(1, emptyRequest.arguments().size()); + assertEquals(0, assertInstanceOf(String[].class, emptyRequest.arguments().get(0)).length); + assertEquals(Arrays.asList((Object) null), clientTransport.next().payload().arguments()); + var prefixedRequest = clientTransport.next().payload(); + assertEquals("echo(java.lang.String,java.lang.String[])", prefixedRequest.operation()); + assertEquals(2, prefixedRequest.arguments().size()); + assertEquals("prefix:", prefixedRequest.arguments().get(0)); + assertEquals(List.of("three", "four"), Arrays.asList(assertInstanceOf(String[].class, prefixedRequest.arguments().get(1)))); + assertEquals(List.of(String[].class, String[].class, String[].class, String.class, String[].class), + clientCodec.encoded.stream().map(CodecCall::type).toList()); + assertEquals(clientCodec.encoded, serverCodec.decoded); + assertEquals(serverCodec.encoded, clientCodec.decoded); + } + } + + @Test + void dispatchesOverloadsByEveryParameterAndArityWhileAcceptingCovariantBridges() throws Exception { + for (int arity : List.of(0, 2)) { + assertTrue(Arrays.stream(OverloadedArgumentsService.class.getDeclaredMethods()).anyMatch(method -> + method.isBridge() && method.isSynthetic() && method.getParameterCount() == arity)); + } + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + connect(clientTransport, serverTransport); + var implementation = new OverloadedArgumentsService() { + @NotNull + @Override + public String echo() { + return "zero"; + } + + @NotNull + @Override + public String echo(@NotNull String first) { + return "one:" + first; + } + + @NotNull + @Override + public String echo(@NotNull String first, int second) { + return "int:" + first + ":" + second; + } + + @NotNull + @Override + public String echo(@NotNull String first, long second) { + return "long:" + first + ":" + second; + } + + @NotNull + @Override + public String echo(@NotNull String first, int second, @NotNull String third) { + return "three:" + first + ":" + second + ":" + third; + } + }; + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("overloads", OverloadedArgumentsService.class, implementation, new TestCodec())) { + OverloadedArgumentsService proxy = client.proxy("overloads", OverloadedArgumentsService.class, new TestCodec()); + BaseArgumentsService base = proxy; + + assertEquals("zero", base.echo()); + assertEquals("zero", proxy.echo()); + assertEquals("one:value", proxy.echo("value")); + assertEquals("int:base:1", base.echo("base", 1)); + assertEquals("int:value:2", proxy.echo("value", 2)); + assertEquals("long:value:3", proxy.echo("value", 3L)); + assertEquals("three:value:4:last", proxy.echo("value", 4, "last")); + + assertEquals("echo()", clientTransport.next().payload().operation()); + assertEquals("echo()", clientTransport.next().payload().operation()); + assertEquals("echo(java.lang.String)", clientTransport.next().payload().operation()); + assertEquals("echo(java.lang.String,int)", clientTransport.next().payload().operation()); + assertEquals("echo(java.lang.String,int)", clientTransport.next().payload().operation()); + assertEquals("echo(java.lang.String,long)", clientTransport.next().payload().operation()); + assertEquals("echo(java.lang.String,int,java.lang.String)", clientTransport.next().payload().operation()); + } + } + + @Test + void rejectsWrongArgumentCountsBeforeDecodingAndFreshCallsRecover() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var codec = new TestCodec(); + var calls = new AtomicInteger(); + connect(clientTransport, serverTransport); + try (var client = MessagingRequestReplyEndpoint.open(clientTransport.forTopic("arguments"), OPTIONS); + var zeroClient = MessagingRequestReplyEndpoint.open(clientTransport.forTopic("zero"), OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("arguments", MixedArgumentsService.class, (first, second, third) -> { + calls.incrementAndGet(); + return first + ":" + second + ":" + third; + }, codec); + var zero = server.register("zero", NoArgumentService.class, () -> { + calls.incrementAndGet(); + return "zero"; + }, codec)) { + List> invalidArguments = List.of(List.of(), List.of("first"), List.of("first", 2), + List.of("first", 2, "third", "extra")); + for (List arguments : invalidArguments) { + int beforeCalls = calls.get(); + int beforeDecoded = codec.decoded.size(); + final int beforeEncoded = codec.encoded.size(); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST), + client.request(MessagingRpcMessage.request("echo(java.lang.String,int,java.lang.String)", arguments))); + assertEquals(beforeCalls, calls.get()); + assertEquals(beforeDecoded, codec.decoded.size()); + assertEquals(beforeEncoded, codec.encoded.size()); + + assertEquals(MessagingRpcMessage.response("first:2:third"), + client.request(MessagingRpcMessage.request("echo(java.lang.String,int,java.lang.String)", + List.of("first", 2, "third")))); + assertEquals(beforeCalls + 1, calls.get()); + assertEquals(beforeDecoded + 3, codec.decoded.size()); + } + int beforeCalls = calls.get(); + int beforeDecoded = codec.decoded.size(); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST), + zeroClient.request(MessagingRpcMessage.request("echo()", List.of("extra")))); + assertEquals(beforeCalls, calls.get()); + assertEquals(beforeDecoded, codec.decoded.size()); + assertEquals(MessagingRpcMessage.response("zero"), zeroClient.request(MessagingRpcMessage.request("echo()", List.of()))); + assertEquals(beforeCalls + 1, calls.get()); + assertEquals(beforeDecoded, codec.decoded.size()); + } + } + + @Test + void rejectsWrongDecodedValuesAtEveryArgumentPositionAndFreshCallsRecover() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var codec = new TestCodec(); + var calls = new AtomicInteger(); + connect(clientTransport, serverTransport); + try (var client = MessagingRequestReplyEndpoint.open(clientTransport.forTopic("arguments"), OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("arguments", MixedArgumentsService.class, (first, second, third) -> { + calls.incrementAndGet(); + return first + ":" + second + ":" + third; + }, codec)) { + List> invalidArguments = List.of(List.of(42, 2, "third"), Arrays.asList("first", null, "third"), + List.of("first", "wrong primitive", "third"), List.of("first", 2L, "third"), List.of("first", 2, 42)); + for (List arguments : invalidArguments) { + int beforeCalls = calls.get(); + final int beforeEncoded = codec.encoded.size(); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST), + client.request(MessagingRpcMessage.request("echo(java.lang.String,int,java.lang.String)", arguments))); + assertEquals(beforeCalls, calls.get()); + assertEquals(beforeEncoded, codec.encoded.size()); + + assertEquals(MessagingRpcMessage.response("null:2:null"), + client.request(MessagingRpcMessage.request("echo(java.lang.String,int,java.lang.String)", + Arrays.asList(null, 2, null)))); + assertEquals(beforeCalls + 1, calls.get()); + assertEquals(beforeEncoded + 1, codec.encoded.size()); + } + } + } + @Test void rejectsUnresolvedTypeVariablesInParameterizedOwnerTypes() { assertThrows(IllegalArgumentException.class, () -> MessagingRpcContract.create(RawOwnedGenericService.class)); @@ -80,6 +342,23 @@ void rejectsGenericParentBridgesWhoseErasedArgumentHasNoConcreteOperation() thro } } + @Test + void rejectsGenericParentBridgesWhoseLaterErasedArgumentHasNoConcreteOperation() throws Exception { + var bridge = GenericPairBridgeChild.class.getDeclaredMethod("echo", String.class, Object.class); + assertTrue(bridge.isBridge()); + assertTrue(bridge.isSynthetic()); + var transport = new MemoryTransport(); + var codec = new TestCodec(); + try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { + assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("bridge", GenericPairBridgeChild.class, codec)); + assertThrows(IllegalArgumentException.class, + () -> endpoint.register("bridge", GenericPairBridgeChild.class, (prefix, value) -> prefix + value, codec)); + assertTrue(codec.encoded.isEmpty()); + assertTrue(codec.decoded.isEmpty()); + assertTrue(transport.sent.isEmpty()); + } + } + @Test void rejectsUnrelatedParentsWithAmbiguousGenericParameterOrReturnTypesBeforeSending() throws Exception { var transport = new MemoryTransport(); @@ -198,6 +477,17 @@ public interface GenericBridgeChild extends GenericService { String echo(@NotNull String value); } + public interface GenericPairService { + @NotNull + String echo(@NotNull String prefix, @NotNull T value); + } + + public interface GenericPairBridgeChild extends GenericPairService { + @NotNull + @Override + String echo(@NotNull String prefix, @NotNull String value); + } + /** Inherits unresolved generic operation types. */ @SuppressWarnings("rawtypes") public interface RawGenericService extends GenericService { @@ -252,10 +542,9 @@ default String local(@NotNull String value) { } } - /** Defines an unsupported variable argument operation. */ public interface VarargsService { @NotNull - String echo(@NotNull String... values); + String echo(@Nullable String... values); } /** Defines an operation without an argument. */ @@ -270,6 +559,48 @@ public interface TwoArgumentService { String echo(@NotNull String first, @NotNull String second); } + public interface PrefixedVarargsService { + @NotNull + String echo(@NotNull String prefix, @NotNull String... values); + } + + public interface ParameterizedArgumentsService { + @Nullable + Map count(@Nullable List values, @Nullable Map> groups, int offset); + } + + public interface MixedArgumentsService { + @NotNull + String echo(@Nullable String first, int second, @Nullable String third); + } + + public interface BaseArgumentsService { + @NotNull + CharSequence echo(); + + @NotNull + CharSequence echo(@NotNull String first, int second); + } + + public interface OverloadedArgumentsService extends BaseArgumentsService { + @NotNull + @Override + String echo(); + + @NotNull + String echo(@NotNull String first); + + @NotNull + @Override + String echo(@NotNull String first, int second); + + @NotNull + String echo(@NotNull String first, long second); + + @NotNull + String echo(@NotNull String first, int second, @NotNull String third); + } + /** Defines an unresolved generic method. */ public interface GenericMethodService { @NotNull diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java index 01f78c5..aeaa026 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java @@ -215,7 +215,7 @@ void longerProxyTimeoutAndExplicitCodecDoNotChangeTheEndpointDefaults() throws E TextService second = endpoint.proxy("text", TextService.class, fallback); final Future extendedCall = async(() -> extended.apply("extended")); var extendedRequest = transport.next(); - assertEquals("custom:extended", extendedRequest.payload().payload()); + assertEquals(List.of("custom:extended"), extendedRequest.payload().arguments()); failure(MessagingRpcException.Reason.TIMEOUT, () -> first.apply("first")); var firstRequest = transport.next(); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java index 0d41f10..6701847 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java @@ -134,7 +134,7 @@ void passesDeclaredParameterizedTypesAndNullValuesThroughEveryCodecBoundary() th assertEquals(List.of(new CodecCall(expected, returnType), new CodecCall(null, returnType)), serverCodec.encoded); assertEquals(serverCodec.encoded, clientCodec.decoded); assertEquals("count(java.util.List)", clientTransport.next().payload().operation()); - assertNull(clientTransport.next().payload().payload()); + assertEquals(Arrays.asList((Object) null), clientTransport.next().payload().arguments()); assertEquals(MessagingRpcMessage.Type.RESPONSE, serverTransport.next().payload().type()); assertEquals(MessagingRpcMessage.response(null), serverTransport.next().payload()); } @@ -309,7 +309,7 @@ void reportsOriginalHandlerExceptionDetailsWithoutSerializingTheThrowableOrRetry assertFalse(failure.toString().contains("secret")); assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED, details), serverTransport.next().payload()); - assertEquals("secret argument", clientTransport.next().payload().payload()); + assertEquals(List.of("secret argument"), clientTransport.next().payload().arguments()); assertTrue(clientTransport.sent.isEmpty()); assertTrue(serverTransport.sent.isEmpty()); } @@ -442,7 +442,7 @@ void rejectsUnknownOperationsAndNonRequestEnvelopesBeforeDecoding() throws Excep }, codec)) { for (String operation : List.of("missing(java.lang.String)", "echo(int)")) { assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION), - client.request(MessagingRpcMessage.request(operation, "value"))); + client.request(MessagingRpcMessage.request(operation, List.of("value")))); } for (MessagingRpcMessage invalid : List.of(MessagingRpcMessage.response("not a request"), MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED))) { @@ -460,7 +460,8 @@ void rejectsMissingResponseEnvelopesRequestsUsedAsRepliesAndNonemptyVoidReplies( var codec = new TestCodec(); try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { EchoService proxy = endpoint.proxy("echo", EchoService.class, codec); - List> invalid = Arrays.asList(null, MessagingRpcMessage.request("echo(java.lang.String)", "value")); + List> invalid = Arrays.asList(null, + MessagingRpcMessage.request("echo(java.lang.String)", List.of("value"))); for (MessagingRpcMessage reply : invalid) { transport.sender = request -> { transport.deliver(request.response(reply)); @@ -584,7 +585,7 @@ void disconnectFailsPendingCallsAndFreshCallsDoNotReplayThem() throws Exception assertTrue(transport.sent.isEmpty()); Future fresh = async(() -> proxy.echo("fresh")); var freshRequest = transport.next(); - assertEquals("fresh", freshRequest.payload().payload()); + assertEquals(List.of("fresh"), freshRequest.payload().arguments()); transport.deliver(oldRequest.response(MessagingRpcMessage.response("late"))); assertFalse(fresh.isDone()); transport.deliver(freshRequest.response(MessagingRpcMessage.response("fresh reply"))); @@ -605,7 +606,7 @@ void usesTheConfiguredTimeoutWithoutRetryingTheEncodedRequest() throws Exception () -> endpoint.proxy("echo", EchoService.class, codec).echo("expire")); assertNull(failure.remoteError()); - assertEquals("expire", transport.next().payload().payload()); + assertEquals(List.of("expire"), transport.next().payload().arguments()); assertTrue(transport.sent.isEmpty()); assertEquals(List.of(new CodecCall("expire", String.class)), codec.encoded); assertTrue(codec.decoded.isEmpty()); @@ -784,7 +785,7 @@ void processesParallelCallsAndConcurrentDecodesWithOutOfOrderCompletion() throws } var order = new ArrayList(); for (int index = 0; index < results.size(); index++) { - order.add(assertInstanceOf(String.class, clientTransport.next().payload().payload())); + order.add(assertInstanceOf(String.class, clientTransport.next().payload().arguments().get(0))); } await(decoding); releaseDecoding.countDown(); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java index 1a62f3b..5a14236 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java +++ b/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java @@ -19,6 +19,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -31,23 +33,60 @@ class MessagingRpcMessageTest { @Test void preservesOpaquePayloadsAndAllowsNullApplicationValues() { Object payload = new Object(); - MessagingRpcMessage request = MessagingRpcMessage.request("echo(java.lang.String)", payload); + MessagingRpcMessage request = MessagingRpcMessage.request("echo(java.lang.String)", List.of(payload)); assertEquals(MessagingRpcMessage.Type.REQUEST, request.type()); assertEquals("echo(java.lang.String)", request.operation()); - assertSame(payload, request.payload()); + assertEquals(List.of(payload), request.arguments()); + assertSame(payload, request.arguments().get(0)); + assertNull(request.payload()); assertNull(request.error()); - assertNull(MessagingRpcMessage.request("echo(java.lang.String)", null).payload()); + assertNull(request.exception()); + assertEquals(Arrays.asList((Object) null), + MessagingRpcMessage.request("echo(java.lang.String)", Arrays.asList((Object) null)).arguments()); MessagingRpcMessage response = MessagingRpcMessage.response(payload); assertEquals(MessagingRpcMessage.Type.RESPONSE, response.type()); assertSame(payload, response.payload()); + assertEquals(List.of(), response.arguments()); assertNull(response.operation()); assertNull(response.error()); - assertEquals(new MessagingRpcMessage<>(MessagingRpcMessage.Type.RESPONSE, null, null, null, null), + assertEquals(new MessagingRpcMessage<>(MessagingRpcMessage.Type.RESPONSE, null, List.of(), null, null, null), MessagingRpcMessage.response(null)); } + @Test + void snapshotsArgumentsWithoutCopyingApplicationValuesAndAllowsNullElements() { + for (boolean factory : List.of(false, true)) { + var value = new ArrayList<>(List.of("original")); + var arguments = new ArrayList(Arrays.asList(value, null)); + MessagingRpcMessage request = factory + ? MessagingRpcMessage.request("echo(java.util.List,java.lang.String)", arguments) + : new MessagingRpcMessage<>(MessagingRpcMessage.Type.REQUEST, + "echo(java.util.List,java.lang.String)", arguments, null, null, null); + + arguments.set(0, "replacement"); + arguments.clear(); + + assertEquals(Arrays.asList(value, null), request.arguments()); + assertSame(value, request.arguments().get(0)); + value.add("updated"); + assertEquals(List.of("original", "updated"), request.arguments().get(0)); + assertThrows(UnsupportedOperationException.class, () -> request.arguments().add("extra")); + assertThrows(UnsupportedOperationException.class, () -> request.arguments().set(0, "replacement")); + } + } + + @Test + void requiresANonNullArgumentListForEveryMessageType() { + assertThrows(NullPointerException.class, () -> MessagingRpcMessage.request("echo()", null)); + for (MessagingRpcMessage.Type type : MessagingRpcMessage.Type.values()) { + String operation = type == MessagingRpcMessage.Type.REQUEST ? "echo()" : null; + MessagingRpcMessage.Error error = type == MessagingRpcMessage.Type.ERROR ? MessagingRpcMessage.Error.HANDLER_FAILED : null; + assertThrows(NullPointerException.class, () -> new MessagingRpcMessage<>(type, operation, null, null, error, null)); + } + } + @Test void failuresCanCarryACodeWithoutExceptionDetails() { for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { @@ -55,6 +94,7 @@ void failuresCanCarryACodeWithoutExceptionDetails() { assertEquals(MessagingRpcMessage.Type.ERROR, failure.type()); assertEquals(error, failure.error()); + assertEquals(List.of(), failure.arguments()); assertNull(failure.operation()); assertNull(failure.payload()); assertNull(failure.exception()); @@ -72,39 +112,46 @@ void exceptionDetailsBelongOnlyToErrorResponsesAndNeverToApplicationPayloads() { assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED), MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED, null)); assertThrows(IllegalArgumentException.class, - () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.REQUEST, "echo(int)", 1, null, details)); + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.REQUEST, "echo(int)", List.of(1), null, null, details)); assertThrows(IllegalArgumentException.class, - () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.RESPONSE, null, 1, null, details)); + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.RESPONSE, null, List.of(), 1, null, details)); } @Test void validatesOperationBoundsWithoutRejectingTheBoundary() { String operation = "o".repeat(1024); - MessagingRpcMessage request = MessagingRpcMessage.request(operation, null); + MessagingRpcMessage request = MessagingRpcMessage.request(operation, List.of()); assertEquals(operation, request.operation()); for (String invalid : List.of("", " ", "\n", "o".repeat(1025))) { - assertThrows(IllegalArgumentException.class, () -> MessagingRpcMessage.request(invalid, null)); + assertThrows(IllegalArgumentException.class, () -> MessagingRpcMessage.request(invalid, List.of())); } - assertThrows(IllegalArgumentException.class, () -> MessagingRpcMessage.request(null, null)); - assertThrows(NullPointerException.class, () -> new MessagingRpcMessage<>(null, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> MessagingRpcMessage.request(null, List.of())); + assertThrows(NullPointerException.class, () -> new MessagingRpcMessage<>(null, null, List.of(), null, null, null)); } @Test void rejectsRoutingFieldsOnRepliesAndInconsistentPayloadOrErrorFields() { for (MessagingRpcMessage.Type type : List.of(MessagingRpcMessage.Type.RESPONSE, MessagingRpcMessage.Type.ERROR)) { MessagingRpcMessage.Error error = type == MessagingRpcMessage.Type.ERROR ? MessagingRpcMessage.Error.HANDLER_FAILED : null; - assertThrows(IllegalArgumentException.class, () -> new MessagingRpcMessage<>(type, "echo(int)", null, error, null)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(type, "echo(int)", List.of(), null, error, null)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(type, null, List.of("argument"), null, error, null)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(type, null, Arrays.asList((Object) null), null, error, null)); } assertThrows(IllegalArgumentException.class, - () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.ERROR, null, null, null, null)); + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.ERROR, null, List.of(), null, null, null)); + assertThrows(IllegalArgumentException.class, + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.REQUEST, "echo(int)", List.of(1), "private payload", null, null)); for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { assertThrows(IllegalArgumentException.class, - () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.REQUEST, "echo(int)", null, error, null)); + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.REQUEST, "echo(int)", List.of(1), null, error, null)); assertThrows(IllegalArgumentException.class, - () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.RESPONSE, null, null, error, null)); + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.RESPONSE, null, List.of(), null, error, null)); assertThrows(IllegalArgumentException.class, - () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.ERROR, null, "private payload", error, null)); + () -> new MessagingRpcMessage<>(MessagingRpcMessage.Type.ERROR, null, List.of(), "private payload", error, null)); } } } From 5a2e5ab51215fe7a31fb4641f006f830be967132 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Mon, 26 Oct 2026 20:16:02 +0400 Subject: [PATCH 29/30] dbeaver/pro#11108 Adopt Remote DAM session naming and address API review findings --- README.md | 6 +- .../MESSAGING.md | 23 +- .../META-INF/MANIFEST.MF | 21 + .../README.md | 249 +++++++++++ .../build.properties | 0 .../pom.xml | 4 +- .../dam/remote/api/DDRemoteDamApi.java} | 6 +- .../dam/remote/api/DDRemoteDamProtocol.java} | 46 +- .../client/DDRemoteDamCallbackClient.java} | 48 +-- .../remote/api/client/DDRemoteDamClient.java} | 152 +++---- .../api/client/DDRemoteDamGraphQlClient.java} | 48 +-- .../api/exception/DDRemoteDamException.java} | 8 +- .../model/DDRemoteDamHandshakeRequest.java} | 18 +- .../model/DDRemoteDamHandshakeResponse.java} | 8 +- .../remote/api/model/DDRemoteDamMessage.java} | 6 +- .../remote/api/model/DDRemoteDamSession.java} | 22 +- .../api/model/DDRemoteDamSessionInfo.java} | 18 +- .../DDRemoteDamRequestReplyTransport.java} | 66 +-- .../api/rpc/DDRemoteDamGsonRpcCodec.java} | 6 +- .../api/rpc/DDRemoteDamRpcTransport.java} | 24 +- .../api/transport/DDRemoteDamChannel.java} | 18 +- .../transport/DDRemoteDamChannelState.java} | 4 +- .../transport/DDRemoteDamChannelStatus.java} | 14 +- .../DDRemoteDamMessageListener.java} | 12 +- .../DDRemoteDamSessionListener.java} | 12 +- .../transport/DDRemoteDamSubscription.java} | 4 +- .../requestreply/MessagingRequestHandler.java | 0 .../MessagingRequestReplyEndpoint.java | 0 .../MessagingRequestReplyException.java | 0 .../MessagingRequestReplyMessage.java | 0 .../MessagingRequestReplyOptions.java | 0 .../MessagingRequestReplyTransport.java | 0 .../requestreply/MessagingSubscription.java | 0 .../messaging/rpc/MessagingRpcCodec.java | 0 .../messaging/rpc/MessagingRpcContract.java | 27 +- .../messaging/rpc/MessagingRpcEndpoint.java | 5 +- .../messaging/rpc/MessagingRpcException.java | 0 .../rpc/MessagingRpcExceptionDetails.java | 0 .../messaging/rpc/MessagingRpcMessage.java | 0 .../messaging/rpc/MessagingRpcTransport.java | 0 .../dam/remote/api/DDRemoteDamApiTest.java} | 32 +- .../remote/api/DDRemoteDamProtocolTest.java} | 88 ++-- .../DDRemoteDamCallbackClientTest.java} | 74 ++-- .../api/client/DDRemoteDamClientTest.java} | 378 ++++++++-------- .../api/model/DDRemoteDamMessageTest.java} | 20 +- .../api/model/DDRemoteDamSessionTest.java} | 20 +- ...DDRemoteDamRequestReplyTransportTest.java} | 248 +++++------ .../api/rpc/DDRemoteDamGsonRpcCodecTest.java} | 10 +- .../DDRemoteDamRpcTopicsIntegrationTest.java} | 92 ++-- .../api/rpc/DDRemoteDamRpcTransportTest.java} | 204 ++++----- .../DDRemoteDamChannelStatusTest.java} | 22 +- .../MessagingRequestReplyDeadlineTest.java | 0 .../MessagingRequestReplyEndpointTest.java | 0 .../MessagingRequestReplyMessageTest.java | 0 .../MessagingRequestReplyOptionsTest.java | 0 .../rpc/MessagingRpcContractTest.java | 120 +++++- .../rpc/MessagingRpcEndpointFacadeTest.java | 0 .../rpc/MessagingRpcEndpointTest.java | 0 .../rpc/MessagingRpcExceptionDetailsTest.java | 0 .../rpc/MessagingRpcExceptionTest.java | 0 .../rpc/MessagingRpcMessageTest.java | 0 .../rpc/MessagingRpcTestSupport.java | 0 .../rpc/fixtures/HiddenInheritedService.java | 0 .../rpc/fixtures/PublicInheritedService.java | 0 .../META-INF/MANIFEST.MF | 21 - .../README.md | 404 ------------------ apis/pom.xml | 2 +- 67 files changed, 1301 insertions(+), 1309 deletions(-) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/MESSAGING.md (92%) create mode 100644 apis/com.dbeaver.datadam.dam.remote.api/META-INF/MANIFEST.MF create mode 100644 apis/com.dbeaver.datadam.dam.remote.api/README.md rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/build.properties (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/pom.xml (95%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/DDMessagingApi.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApi.java} (95%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/DDMessagingProtocol.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocol.java} (73%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClient.java} (64%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingClient.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClient.java} (59%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingGraphQlClient.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamGraphQlClient.java} (85%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/exception/DDMessagingException.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/exception/DDRemoteDamException.java} (77%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingHandshakeRequest.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeRequest.java} (60%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingHandshakeResponse.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeResponse.java} (74%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessage.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessage.java} (91%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnection.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSession.java} (57%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDDamConnectionInfo.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionInfo.java} (60%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransport.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransport.java} (87%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingGsonRpcCodec.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodec.java} (89%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransport.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransport.java} (93%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannel.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannel.java} (56%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelState.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelState.java} (88%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatus.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatus.java} (65%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingMessageListener.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamMessageListener.java} (63%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingConnectionListener.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSessionListener.java} (65%) rename apis/{com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingSubscription.java => com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSubscription.java} (86%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestHandler.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpoint.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyException.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessage.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptions.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyTransport.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/main/java/com/dbeaver/messaging/requestreply/MessagingSubscription.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcCodec.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java (88%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java (98%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcException.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetails.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcTransport.java (100%) rename apis/{com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/DDMessagingApiTest.java => com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApiTest.java} (76%) rename apis/{com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/DDMessagingProtocolTest.java => com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocolTest.java} (62%) rename apis/{com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClientTest.java => com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClientTest.java} (86%) rename apis/{com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingClientTest.java => com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClientTest.java} (76%) rename apis/{com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessageTest.java => com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessageTest.java} (79%) rename apis/{com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnectionTest.java => com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionTest.java} (61%) rename apis/{com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransportTest.java => com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransportTest.java} (77%) rename apis/{com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingGsonRpcCodecTest.java => com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodecTest.java} (91%) rename apis/{com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java => com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTopicsIntegrationTest.java} (61%) rename apis/{com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java => com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransportTest.java} (91%) rename apis/{com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatusTest.java => com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatusTest.java} (55%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyDeadlineTest.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpointTest.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessageTest.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptionsTest.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java (86%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetailsTest.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionTest.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcTestSupport.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java (100%) rename apis/{com.dbeaver.datadam.messaging.api => com.dbeaver.datadam.dam.remote.api}/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java (100%) delete mode 100644 apis/com.dbeaver.datadam.messaging.api/META-INF/MANIFEST.MF delete mode 100644 apis/com.dbeaver.datadam.messaging.api/README.md diff --git a/README.md b/README.md index d4060eb..d9018d2 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ This repository contains API for global DBeaver ecosystem. OSGI APIs and Service for DataDam integration Used by DBeaver, dbvr, CloudBeaver and by DataDam itself -## Messaging API +## Remote DAM API -[`apis/com.dbeaver.datadam.messaging.api`](apis/com.dbeaver.datadam.messaging.api/README.md) contains the External DAM +[`apis/com.dbeaver.datadam.dam.remote.api`](apis/com.dbeaver.datadam.dam.remote.api/README.md) contains the Remote DAM contracts, clients and transport adapters, together with the messaging implementation. Java packages are -`com.dbeaver.datadam.messaging.api.*` and `com.dbeaver.messaging.*`; messaging is not a separate Maven or OSGi module. +`com.dbeaver.datadam.dam.remote.api.*` and `com.dbeaver.messaging.*`; messaging is not a separate Maven or OSGi module. ## SSO API diff --git a/apis/com.dbeaver.datadam.messaging.api/MESSAGING.md b/apis/com.dbeaver.datadam.dam.remote.api/MESSAGING.md similarity index 92% rename from apis/com.dbeaver.datadam.messaging.api/MESSAGING.md rename to apis/com.dbeaver.datadam.dam.remote.api/MESSAGING.md index af9afda..cb933f8 100644 --- a/apis/com.dbeaver.datadam.messaging.api/MESSAGING.md +++ b/apis/com.dbeaver.datadam.dam.remote.api/MESSAGING.md @@ -1,9 +1,10 @@ # Messaging: request/reply and typed RPC The `com.dbeaver.messaging.requestreply` and `com.dbeaver.messaging.rpc` packages live in the -`com.dbeaver.datadam.messaging.api` module. They supply transport-independent request/reply and typed RPC for Java 21. +`com.dbeaver.datadam.dam.remote.api` module. They supply transport-independent request/reply and typed RPC for Java 21. These packages do not depend on DataDam, HTTP, WebSocket, JSON, Spring, or Eclipse runtime services; their only non-JDK -production dependency supplies nullability annotations. DataDam adapters and codecs live in separate packages in the same module. +production dependency supplies nullability annotations. Unsupported RPC methods are logged using the JDK logging API. +DataDam adapters and codecs live in separate packages in the same module. ## Usage @@ -87,7 +88,7 @@ Implement `MessagingRequestReplyTransport

` to integrate a message transport: Responses and disconnects do not use this dispatch hook. A serialized receiver must dispatch requests elsewhere if handlers can wait synchronously for replies arriving through that same receiver. - Closing the returned `MessagingSubscription` removes receiving routes and releases adapter-owned resources. It must - leave a caller-owned physical connection available to other consumers. + leave a caller-owned physical session available to other consumers. - The adapter owns wire validation, payload snapshots, message size limits, and bounded transport send work. It must not introduce automatic retries or an offline business-message queue. @@ -109,7 +110,7 @@ public interface Documents { } ``` -The connection owner creates one reusable endpoint over a topic-scoped transport factory. The receiver supplies +The session owner creates one reusable endpoint over a topic-scoped transport factory. The receiver supplies its own codec when registering an implementation: ```java @@ -158,7 +159,7 @@ snapshot that preserves null entries, and the request's `payload` is null. Respo ### Application-owned registrations and callers -Obtain an endpoint once from the application's connection owner, then pass it to ordinary Java objects. Registration +Obtain an endpoint once from the application's session owner, then pass it to ordinary Java objects. Registration and proxy creation are runtime operations and do not require Spring or a separate bean for each business interface. ```java @@ -198,7 +199,7 @@ public final class DocumentCaller { Keep the receiving `MessagingSubscription` while the implementation is available, then close it to unregister. A proxy is an ordinary business interface, not a registration handle, and requires no separate close. Application code sharing -an endpoint closes its own registrations; the connection owner closes the endpoint when its scope ends. Owners can +an endpoint closes its own registrations; the session owner closes the endpoint when its scope ends. Owners can use `isClosed()` to reject new use once closure starts, and `isCloseComplete()` before releasing a cached endpoint's scope reservation. The latter becomes true after handler and receiving-subscription cleanup finishes, including when cleanup throws. A repeated `close()` does not wait for another thread's ongoing cleanup. @@ -213,9 +214,13 @@ cleanup throws. A repeated `close()` does not wait for another thread's ongoing A varargs parameter uses its array type and occupies one argument position. Only methods from the registered interface can be invoked. Incoming names never trigger class loading or arbitrary reflective lookup on the implementation. - Concrete generic argument and result types, such as `List`, reach the codec as `Type` values for each argument - and result. Unresolved type variables, generic methods, default methods, and `Future`/`CompletionStage` returns are - rejected during proxy construction or registration. Generic-parent substitutions are not inferred. Inherited operations - with ambiguous generic argument or result types must be explicitly redeclared in the public contract. + and result. Methods with unresolved type variables, generic methods, default methods, and `Future`/`CompletionStage` + returns are logged and skipped during proxy construction or registration; supported methods remain available. + Calling a skipped method on a proxy fails locally with `IllegalArgumentException` without encoding or sending a request; + incoming calls to skipped operations receive `UNKNOWN_OPERATION`. Contracts with no usable operations are rejected. + Contracts must still be public, nonsealed interfaces without unresolved interface type parameters. Generic-parent + substitutions are not inferred; unresolved erased bridges remain invalid. Inherited operations with ambiguous generic + argument or result types must be explicitly redeclared in the public contract. - `MessagingRpcCodec.encode(value, type)` and `decode(payload, type)` are selected per proxy or implementation registration and invoked independently for each argument in declaration order and for the result. Zero-argument calls do not invoke the argument codec; varargs are encoded and decoded as one array value. Implementations must handle diff --git a/apis/com.dbeaver.datadam.dam.remote.api/META-INF/MANIFEST.MF b/apis/com.dbeaver.datadam.dam.remote.api/META-INF/MANIFEST.MF new file mode 100644 index 0000000..512e18a --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/META-INF/MANIFEST.MF @@ -0,0 +1,21 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Vendor: DBeaver Corp +Bundle-Name: DataDam Remote DAM API +Bundle-SymbolicName: com.dbeaver.datadam.dam.remote.api +Bundle-Version: 0.0.17.qualifier +Bundle-RequiredExecutionEnvironment: JavaSE-21 +Export-Package: com.dbeaver.datadam.dam.remote.api, + com.dbeaver.datadam.dam.remote.api.client, + com.dbeaver.datadam.dam.remote.api.exception, + com.dbeaver.datadam.dam.remote.api.model, + com.dbeaver.datadam.dam.remote.api.requestreply, + com.dbeaver.datadam.dam.remote.api.rpc, + com.dbeaver.datadam.dam.remote.api.transport, + com.dbeaver.messaging.requestreply, + com.dbeaver.messaging.rpc +Bundle-ActivationPolicy: lazy +Require-Bundle: org.jkiss.utils, + com.dbeaver.rest.client;visibility:=reexport, + com.google.gson +Automatic-Module-Name: com.dbeaver.datadam.dam.remote.api diff --git a/apis/com.dbeaver.datadam.dam.remote.api/README.md b/apis/com.dbeaver.datadam.dam.remote.api/README.md new file mode 100644 index 0000000..649a7d5 --- /dev/null +++ b/apis/com.dbeaver.datadam.dam.remote.api/README.md @@ -0,0 +1,249 @@ +# Remote DAM API + +The `com.dbeaver.datadam.dam.remote.api` module contains Remote DAM WebSocket contracts, backend-only GraphQL clients, +and transport-independent messaging packages. A Remote DAM peer can be a Desktop application or a future Managed cluster. +Its session identifies the authenticated DAM transport scope, independently of any database connection. + +Java package names are `com.dbeaver.datadam.dam.remote.api.*` and `com.dbeaver.messaging.*`. +See [Messaging](MESSAGING.md) for the neutral request/reply and typed RPC contracts. + +## Session identity and handshake + +The WebSocket handshake sends `DDRemoteDamHandshakeRequest` with `appDeploymentId`, `appInstanceId`, `product`, and +`version`. `DDRemoteDamHandshakeResponse` returns the assigned `damSessionId`. Wire control types remain `hello` and +`ready`, and the WebSocket path remains `/ws`. Control messages have `type` and an object `payload`; application +messages additionally have `topic`. Application messages may use any nonblank type, including the control type names. + +`DDRemoteDamSessionInfo` contains `appDeploymentId`, `appInstanceId`, and `damSessionId`. +`DDRemoteDamSession` adds `product` and `version` for registry presentation. All fields are nonblank strings. +Concurrent sessions may share application deployment and instance IDs; consumers must use `damSessionId` to distinguish +them. Product and version are peer-supplied presentation metadata, not authenticated identity or authorization claims. + +## Internal GraphQL contract + +`DDRemoteDamClient` posts JSON requests to `/internal/graphql` on the configured relay service root URL. +The service implementation must expose this contract; adding a client alone does not deploy the endpoint. + +```graphql +scalar JSON + +type Query { + externalDamSessions(accountId: ID!): [ExternalDamSession!]! + externalDamSessionAccount(damSessionId: ID!): ID +} + +type Mutation { + sendExternalDamMessage(accountId: ID!, damSessionId: ID!, message: ExternalDamMessageInput!): Boolean! + registerExternalDamSubscription(input: ExternalDamSubscriptionInput!): Int! + unregisterExternalDamSubscription(subscriberId: String!, subscriptionId: ID!): Boolean! +} + +type ExternalDamSession { + appDeploymentId: String! + appInstanceId: String! + damSessionId: ID! + product: String! + version: String! +} + +input ExternalDamMessageInput { + topic: String! + type: String! + payload: JSON! +} + +input ExternalDamSubscriptionInput { + subscriberId: String! + subscriptionId: ID! + accountId: ID! + damSessionId: ID + topic: String! + callbackUrl: String! + callbackSecret: String! +} +``` + +- `accountId` is a UUID, mandatory for account-wide session lists, sends, and subscription registration. Sends use the + account and DAM session ID together. +- `externalDamSessionAccount` resolves a unique session ID to the account from its verified identity. It returns null + for a missing or not-ready session; the client reports this as checked `DDRemoteDamException`. This backend-only lookup + does not authorize end-user access to that account. +- Session lists are immutable client-side snapshots of currently ready transport scopes, not persisted installations + or session history. +- Message payloads are JSON objects passed through GraphQL variables, never interpolated into query text or encoded as + JSON strings. `DDRemoteDamMessage` takes defensive payload snapshots. +- A send result of `true` confirms completion of the server's WebSocket write, not peer receipt or processing. + `false` means no ready session exists for that account and session ID, including a session belonging to another account. +- A session closing after lookup, queue rejection, an invalid message, or a write failure produces a GraphQL error. + HTTP errors, GraphQL errors including partial data, and malformed responses become `DDRemoteDamException`. +- The client never retries mutations or queues messages offline. After timeout or network failure, delivery may be + unknown; callers must not blindly replay non-idempotent events. +- Sessions currently live in one relay process. Until owner-aware routing exists, calls must reach the process owning + the session; a missing result from another replica does not prove global offline status. + +## Authentication and configuration + +`DDRemoteDamClient` takes a service root URL and the External DAM service secret. It never logs in or acquires a peer token. +The URL must use HTTP or HTTPS, have no credentials, query, fragment, or non-root path, and use a valid port. +HTTP is supported for trusted private service networks; use HTTPS across untrusted networks. + +The service secret contains 32–512 printable, non-space ASCII characters. It authenticates backend requests and callbacks +through `X-DD-Service-Token`. Subscription registration also carries that same secret in its `callbackSecret` variable; +protect that backend-only request and never log its body. Secrets must not appear in URLs or GraphQL query text. +Redirects are disabled. HTTP establishment uses the shared REST client's 5-second connect timeout; responses have a +10-second deadline. HTTP error bodies, GraphQL error details, and malformed response contents are not copied into client +exceptions. + +The secret authenticates a trusted backend, not an end user. Backends exposing operations to users must derive `accountId` +from authorized account context. A DAM session ID alone is not end-user authorization; expose only authorized account +selections. The server validates the secret before internal GraphQL execution and fails closed when it is missing or +invalid. Browser sessions and peer JWTs must not grant access. Keep the service secret in backend runtime configuration; +it is separate from the Account backchannel secret used to verify peer JWTs. + +```java +var client = new DDRemoteDamClient(serviceUrl, serviceSecret); +var sessions = client.getSessions(accountId); +boolean written = client.sendMessage(accountId, damSessionId, message); +``` + +Use one configured client per service endpoint. No deployment-specific URL or secret is packaged here. +`getSessionsAsync`, `getSessionAccountAsync`, `sendMessageAsync`, `registerSubscriptionAsync`, and +`unregisterSubscriptionAsync` use the JDK HTTP client's nonblocking API without a wrapper executor. Synchronous methods +remain available. The deadline includes the response body. Cancelling a stage attempts to stop the HTTP exchange but +does not promise cancellation of an operation already running remotely. + +## Backend callback subscriptions + +`registerSubscription(subscriberId, subscriptionId, accountId, damSessionId, topic, callbackUrl, callbackSecret)` returns +the lease in seconds. The receiving process generates the UUID `subscriptionId`; repeated registration with the same +scope and callback renews it. A null `damSessionId` subscribes to matching account messages, including future sessions. +A non-null ID filters one physical session and does not follow a replacement session. + +Callback service root URLs and secrets follow the same validation as relay URLs and service secrets. Only authenticated +trusted backends may register callbacks. Restrict outbound relay network access and use HTTPS outside trusted networks. +The relay keeps registrations in memory, with no configured subscriber list or separate service-registration step. +The subscriber ID identifies the receiver and does not determine business topics. + +`DDRemoteDamCallbackClient` posts to `/internal/external-dam/graphql`. Callback variables are `subscriptionId`, `accountId`, +`session`, and `message`. The session input is `ExternalDamSessionInfoInput` with `appDeploymentId`, `appInstanceId`, and +`damSessionId`. The mutation remains `deliverExternalDamMessage(subscriptionId: ..., accountId: ..., session: ..., message: ...)`. + +The receiver invokes its listener on the HTTP request thread and acknowledges after it returns. Successful acknowledgement +extends the lease. Concurrent callbacks may invoke a listener concurrently without ordering guarantees; listeners must be +thread-safe. The RPC reply carries the business result. `unregisterSubscription` handles cancellation and cleanup of +unknown local registrations. The relay stops starting callbacks on cancellation or expiry; already started callbacks may +finish. Failed callbacks are not replayed or retried. In-flight callbacks are bounded without a per-subscription executor +or mailbox. `deliverAsync` is nonblocking, with a default 30-second deadline including listener execution; explicit +durations are supported. Timeout does not promise remote handler cancellation. + +Spring services use the shared `com.dbeaver.datadam.messaging.base` server module and its +`DDRemoteDamClientConfiguration`. It supplies callback reception, local dispatch, subscription cancellation, and idle +renewal. Existing settings are `datadam.external-dam-client.service-url`, `datadam.external-dam-client.service-secret`, +and `datadam.external-dam-client.callback-url`. There is no separate callback-secret setting. The configured service secret +is used for requests, callback authentication, and the registration's `callbackSecret` field. + +Select `subscriptions.forSession(accountId, damSessionId)` or `subscriptions.forAccount(accountId)`, then subscribe by +topic. The protocol retains account and session scope for ownership checks. Handlers do not renew registrations. +Every callback destination must reach the exact receiving process owning its local registration. +Peer-side subscriptions remain local: authenticated messages are dispatched to local topic listeners without announcing +handlers to the relay. + +## Channels and request/reply + +`DDRemoteDamChannel.getStatus()` returns immutable `DDRemoteDamChannelStatus` in `DISCONNECTED`, `CONNECTING`, `CONNECTED`, +or `CLOSED` state. Only `CONNECTED` carries `session()` information; `isReady()` tests readiness. +`observeState(observer)` delivers the current state and ordered changes without a snapshot/subscription race. Its returned +subscription closes only that observer. Initial notification can be synchronous; later callbacks run on the notifying +thread. `addSessionListener` remains transitions-only, calling `onConnected` and `onDisconnected` with session information. + +`DDRemoteDamRequestReplyTransport` adapts a scoped channel to `MessagingRequestReplyEndpoint`: + +```java +var transport = DDRemoteDamRequestReplyTransport.forChannel(channel, "documents"); +try (var requests = MessagingRequestReplyEndpoint.open(transport)) { + var response = requests.request(payload); +} +``` + +A backend can instead obtain its transport from `subscriptions.forSession(accountId, damSessionId).requestReply("documents")`. +Open a physical-channel endpoint after readiness. Opening while disconnected or closed fails without retaining +subscriptions. Endpoints can stay installed across reconnects, but pending calls fail at disconnection and are not replayed. +The peer installs `requests.handle(payload -> process(payload))`. Use distinct topics for independently handled services. +Each endpoint installs its private reply route before accepting calls; replies return to the originating backend process. +Null responses represent successful completion without a value. This lower layer exposes protocol error codes without +application exception details; typed RPC adds its own diagnostics. + +The bridge message type remains `datadam.requestreply.v1`. Envelopes contain `requestId`, `replyTo`, `type`, and either +`data` or `error`. `replyTo` is an endpoint UUID, not a backend address. Private topics remain +`datadam.reply.v1..` and fit metadata limits for maximum-length topics. +Closing an endpoint removes both receiving routes and owned work without closing a shared channel or subscription facade. +Backend request/reply is exposed only for `forSession`, with no implicit selection from account subscriptions. + +`request` blocks a worker or virtual thread. Ordinary sends and HTTP callback acknowledgements have transport-only +meaning. Backend sends use nonblocking HTTP, limited to 128 in flight without a send pool or waiting queue. Facade closure +rejects new sends and invalidates later completions; an already started HTTP send can finish. Physical channels notify +disconnect immediately; backend callbacks have no physical session-state feed, so pending calls expire by deadline. +Timeout or interruption can leave remote outcome unknown; requests and responses are never automatically retried. + +The direct-channel adapter orders messages and disconnections through one bounded receiving mailbox. Source validation +and mailbox insertion share a lifecycle lock; callbacks run outside it. A newly visible source inserts its generation +boundary before its first message. Delayed observers read current status rather than reverting to an old source. +The mailbox holds 128 callbacks without an executor. Excess frames are dropped; disconnection takes priority over a full +batch. Listener failure closes the receiving routes. Backend callbacks invoke listeners directly on concurrent HTTP threads. +The neutral endpoint owns no executor. Blocking handlers must stay off a serialized receiver needed for their replies. + +## Typed RPC + +`com.dbeaver.messaging.rpc` supplies typed proxies and implementation registration independently of DataDam and WebSocket. +`DDRemoteDamRpcTransport` adapts request/reply to `MessagingRpcMessage`. Application serialization is selected +through a codec, for example `new DDRemoteDamGsonRpcCodec(new Gson())`. The codec retains the application's Gson adapters +and naming policies, preserves declared generic types, and handles each argument independently. A varargs array is one +argument; JSON null is a nullable payload. Each proxy and registration can use a different compatible codec. + +```java +try (var rpc = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(channel), codec); + var registration = rpc.register("dam.projects", DamProjects.class, implementation)) { + ServerDocuments documents = rpc.proxy("server.documents", ServerDocuments.class); + serveApplication(documents); +} +``` + +Backend scopes wrap `scope.requestReply(topic)` in `DDRemoteDamRpcTransport` and use the same endpoint API. An optional +borrowed `requestExecutor` runs admitted incoming requests; otherwise the receiving thread runs them. Responses and +disconnects never use that executor. Application/platform owners must keep blocking handlers off WebSocket delivery. +The transport owns no execution service. + +The developer's topic is the actual request topic. Routes open lazily and are locally reused, with no automatic Account, +Storage, or service prefix. Registering locally does not register a remote service. Arrange the intended receiver's +subscription before calling; duplicate backend receivers are not service discovery. Exact callback-owner routing still +applies. + +Backend `DDRemoteDamRpc` owners expose `getSessions(accountId)`, `forSession(damSessionId)`, and `closeSession(damSessionId)`. +Listing returns an immutable snapshot without opening endpoints. Selection is application-owned; opening resolves the +account through the authenticated relay API and preserves ownership checks. Known session IDs work without listing or +local discovery caches. Repeated lookup shares an open endpoint; identifiers are not repeated on each business call. +Session owners provide a default `DDRemoteDamGsonRpcCodec`; explicit codec overloads override it for one binding only. +Proxy timeout overloads accept `Duration`, without changing other proxies or the shared session; the default is 30 seconds +unless owner options specify otherwise. + +Obtain an endpoint from the application's ready session owner and pass it to ordinary objects. Endpoint access does not +start login or wait for readiness. Register implementations at runtime and retain their registration handles until they +should stop accepting calls. Proxies need no separate close. The session owner owns the shared endpoint. Transient +reconnects retain endpoints and registrations; authentication identity changes and shutdown invalidate them. Replacing a +closed endpoint does not transfer registrations. Backend owners close missing sessions after successful reconciliation; +failed registry reads preserve endpoints. Asynchronous opening results are shared while callers wait with a timeout. +Reconciliation and renewal use platform scheduling, without owner-created executors. Owners and caches remain process-local. + +The inner envelope remains `protocol: "dbeaver.rpc.v2"`, `type`, and `data`, with `operation` on requests or a fixed `error` +code on failures. Request data is positional: `[]`, `[value]`, `[null]`, or `[value1, value2]`; varargs can be `[["a", "b"]]`. +Error `exception` is null or an object with string `type`, nullable `message`, string `stackTrace`, and boolean `truncated`. +Causes and suppressed exceptions are formatted remotely; no `Throwable` is serialized and application codecs are not used. +Each argument and response can be any JSON value, while the outer topic message remains an object. Existing correlation, +source validation, and authentication still apply. Malformed envelopes are ignored without exposing raw contents. + +Framing, snapshot copying, and outstanding sends are bounded to 128 operations without a waiting queue. Cancellation does +not release a send slot until its underlying operation finishes. Business contracts use ordinary synchronous methods; +`void` waits for completion. Failures become unchecked `MessagingRpcException`; remote handler/codec failures preserve +their codes and bounded `remoteException()` diagnostics, also exposed through a local diagnostic cause for logging. +Bounds are 256 UTF-16 units for type, 1,024 for message, and 8,192 for trace. `truncated` marks incomplete details; code-only +errors have null diagnostics. See [Messaging](MESSAGING.md) for contract shapes, concurrency, ownership, and timeout semantics. diff --git a/apis/com.dbeaver.datadam.messaging.api/build.properties b/apis/com.dbeaver.datadam.dam.remote.api/build.properties similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/build.properties rename to apis/com.dbeaver.datadam.dam.remote.api/build.properties diff --git a/apis/com.dbeaver.datadam.messaging.api/pom.xml b/apis/com.dbeaver.datadam.dam.remote.api/pom.xml similarity index 95% rename from apis/com.dbeaver.datadam.messaging.api/pom.xml rename to apis/com.dbeaver.datadam.dam.remote.api/pom.xml index a635509..f990c5b 100644 --- a/apis/com.dbeaver.datadam.messaging.api/pom.xml +++ b/apis/com.dbeaver.datadam.dam.remote.api/pom.xml @@ -10,9 +10,9 @@ ../pom.xml - com.dbeaver.datadam.messaging.api + com.dbeaver.datadam.dam.remote.api eclipse-plugin - DataDam Messaging API + DataDam Remote DAM API diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/DDMessagingApi.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApi.java similarity index 95% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/DDMessagingApi.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApi.java index f67ce8e..d9db679 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/DDMessagingApi.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApi.java @@ -14,13 +14,13 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api; +package com.dbeaver.datadam.dam.remote.api; import org.jkiss.code.Nullable; import java.net.URI; -public final class DDMessagingApi { +public final class DDRemoteDamApi { public static final String GRAPHQL_PATH = "/internal/graphql"; public static final String CALLBACK_GRAPHQL_PATH = "/internal/external-dam/graphql"; public static final String HEADER_SERVICE_TOKEN = "X-DD-Service-Token"; @@ -31,7 +31,7 @@ public final class DDMessagingApi { private static final int MAX_SUBSCRIBER_ID_LENGTH = 256; private static final int MAX_SERVICE_URL_LENGTH = 4096; - private DDMessagingApi() { + private DDRemoteDamApi() { } public static boolean isValidSubscriberId(@Nullable String subscriberId) { diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/DDMessagingProtocol.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocol.java similarity index 73% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/DDMessagingProtocol.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocol.java index 6720f99..80d1e32 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/DDMessagingProtocol.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocol.java @@ -14,18 +14,18 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api; +package com.dbeaver.datadam.dam.remote.api; -import com.dbeaver.datadam.messaging.api.model.DDMessagingHandshakeRequest; -import com.dbeaver.datadam.messaging.api.model.DDMessagingHandshakeResponse; -import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamHandshakeRequest; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamHandshakeResponse; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import org.jkiss.code.NotNull; -public final class DDMessagingProtocol { +public final class DDRemoteDamProtocol { public static final String WEBSOCKET_PATH = "/ws"; public static final String TYPE_HANDSHAKE_REQUEST = "hello"; public static final String TYPE_HANDSHAKE_RESPONSE = "ready"; @@ -33,26 +33,26 @@ public final class DDMessagingProtocol { private static final String FIELD_TOPIC = "topic"; private static final String FIELD_TYPE = "type"; private static final String FIELD_PAYLOAD = "payload"; - private static final String FIELD_DEPLOYMENT_ID = "deploymentId"; - private static final String FIELD_APPLICATION_RUN_ID = "applicationRunId"; + private static final String FIELD_APP_DEPLOYMENT_ID = "appDeploymentId"; + private static final String FIELD_APP_INSTANCE_ID = "appInstanceId"; private static final String FIELD_PRODUCT = "product"; private static final String FIELD_VERSION = "version"; - private static final String FIELD_CONNECTION_ID = "connectionId"; + private static final String FIELD_DAM_SESSION_ID = "damSessionId"; - private DDMessagingProtocol() { + private DDRemoteDamProtocol() { } @NotNull - public static String encode(@NotNull DDMessagingMessage message) { + public static String encode(@NotNull DDRemoteDamMessage message) { JsonObject envelope = envelope(message.type(), message.payload()); envelope.addProperty(FIELD_TOPIC, message.topic()); return envelope.toString(); } @NotNull - public static DDMessagingMessage decode(@NotNull String message) { + public static DDRemoteDamMessage decode(@NotNull String message) { JsonObject envelope = parseEnvelope(message); - return new DDMessagingMessage( + return new DDRemoteDamMessage( readString(envelope, FIELD_TOPIC), readString(envelope, FIELD_TYPE), readPayload(envelope) @@ -60,36 +60,36 @@ public static DDMessagingMessage decode(@NotNull String message) { } @NotNull - public static String encodeHandshakeRequest(@NotNull DDMessagingHandshakeRequest hello) { + public static String encodeHandshakeRequest(@NotNull DDRemoteDamHandshakeRequest hello) { JsonObject payload = new JsonObject(); - payload.addProperty(FIELD_DEPLOYMENT_ID, hello.deploymentId()); - payload.addProperty(FIELD_APPLICATION_RUN_ID, hello.applicationRunId()); + payload.addProperty(FIELD_APP_DEPLOYMENT_ID, hello.appDeploymentId()); + payload.addProperty(FIELD_APP_INSTANCE_ID, hello.appInstanceId()); payload.addProperty(FIELD_PRODUCT, hello.product()); payload.addProperty(FIELD_VERSION, hello.version()); return envelope(TYPE_HANDSHAKE_REQUEST, payload).toString(); } @NotNull - public static DDMessagingHandshakeRequest decodeHandshakeRequest(@NotNull String message) { + public static DDRemoteDamHandshakeRequest decodeHandshakeRequest(@NotNull String message) { JsonObject payload = readControlPayload(message, TYPE_HANDSHAKE_REQUEST); - return new DDMessagingHandshakeRequest( - readString(payload, FIELD_DEPLOYMENT_ID), - readString(payload, FIELD_APPLICATION_RUN_ID), + return new DDRemoteDamHandshakeRequest( + readString(payload, FIELD_APP_DEPLOYMENT_ID), + readString(payload, FIELD_APP_INSTANCE_ID), readString(payload, FIELD_PRODUCT), readString(payload, FIELD_VERSION) ); } @NotNull - public static String encodeHandshakeResponse(@NotNull DDMessagingHandshakeResponse ready) { + public static String encodeHandshakeResponse(@NotNull DDRemoteDamHandshakeResponse ready) { JsonObject payload = new JsonObject(); - payload.addProperty(FIELD_CONNECTION_ID, ready.connectionId()); + payload.addProperty(FIELD_DAM_SESSION_ID, ready.damSessionId()); return envelope(TYPE_HANDSHAKE_RESPONSE, payload).toString(); } @NotNull - public static DDMessagingHandshakeResponse decodeHandshakeResponse(@NotNull String message) { - return new DDMessagingHandshakeResponse(readString(readControlPayload(message, TYPE_HANDSHAKE_RESPONSE), FIELD_CONNECTION_ID)); + public static DDRemoteDamHandshakeResponse decodeHandshakeResponse(@NotNull String message) { + return new DDRemoteDamHandshakeResponse(readString(readControlPayload(message, TYPE_HANDSHAKE_RESPONSE), FIELD_DAM_SESSION_ID)); } @NotNull diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClient.java similarity index 64% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClient.java index 563fb30..c2eb3c7 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClient.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClient.java @@ -14,12 +14,12 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.client; +package com.dbeaver.datadam.dam.remote.api.client; -import com.dbeaver.datadam.messaging.api.DDMessagingApi; -import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; -import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; -import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; +import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi; +import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; import com.google.gson.JsonElement; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; @@ -30,64 +30,64 @@ import java.util.UUID; import java.util.concurrent.CompletionStage; -public final class DDMessagingCallbackClient extends DDMessagingGraphQlClient { +public final class DDRemoteDamCallbackClient extends DDRemoteDamGraphQlClient { private static final String DELIVER_FIELD = "deliverExternalDamMessage"; private static final String DELIVER_OPERATION = "DeliverExternalDamMessage"; private static final String DELIVER_MUTATION = """ mutation DeliverExternalDamMessage($subscriptionId: ID!, $accountId: ID!, - $connection: ExternalDamConnectionInfoInput!, $message: ExternalDamMessageInput!) { - deliverExternalDamMessage(subscriptionId: $subscriptionId, accountId: $accountId, connection: $connection, message: $message) + $session: ExternalDamSessionInfoInput!, $message: ExternalDamMessageInput!) { + deliverExternalDamMessage(subscriptionId: $subscriptionId, accountId: $accountId, session: $session, message: $message) } """; - public DDMessagingCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret) { + public DDRemoteDamCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret) { this(serviceUrl, serviceSecret, Duration.ofSeconds(30)); } - public DDMessagingCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull Duration deliveryTimeout) { - super(serviceUrl, serviceSecret, DDMessagingApi.CALLBACK_GRAPHQL_PATH, timeoutMillis(deliveryTimeout)); + public DDRemoteDamCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull Duration deliveryTimeout) { + super(serviceUrl, serviceSecret, DDRemoteDamApi.CALLBACK_GRAPHQL_PATH, timeoutMillis(deliveryTimeout)); } public void deliver( @NotNull UUID subscriptionId, @NotNull UUID accountId, - @NotNull DDDamConnectionInfo connection, - @NotNull DDMessagingMessage message - ) throws DDMessagingException { + @NotNull DDRemoteDamSessionInfo session, + @NotNull DDRemoteDamMessage message + ) throws DDRemoteDamException { readDeliveryResult(request(DELIVER_OPERATION, DELIVER_MUTATION, - deliveryVariables(subscriptionId, accountId, connection, message), DELIVER_FIELD)); + deliveryVariables(subscriptionId, accountId, session, message), DELIVER_FIELD)); } @NotNull public CompletionStage deliverAsync( @NotNull UUID subscriptionId, @NotNull UUID accountId, - @NotNull DDDamConnectionInfo connection, - @NotNull DDMessagingMessage message + @NotNull DDRemoteDamSessionInfo session, + @NotNull DDRemoteDamMessage message ) { return requestAsync(DELIVER_OPERATION, DELIVER_MUTATION, - deliveryVariables(subscriptionId, accountId, connection, message), DELIVER_FIELD, - DDMessagingCallbackClient::readDeliveryResult); + deliveryVariables(subscriptionId, accountId, session, message), DELIVER_FIELD, + DDRemoteDamCallbackClient::readDeliveryResult); } @NotNull private static Map deliveryVariables( @NotNull UUID subscriptionId, @NotNull UUID accountId, - @NotNull DDDamConnectionInfo connection, - @NotNull DDMessagingMessage message + @NotNull DDRemoteDamSessionInfo session, + @NotNull DDRemoteDamMessage message ) { return Map.of( "subscriptionId", Objects.requireNonNull(subscriptionId).toString(), "accountId", Objects.requireNonNull(accountId).toString(), - "connection", Objects.requireNonNull(connection), "message", Objects.requireNonNull(message) + "session", Objects.requireNonNull(session), "message", Objects.requireNonNull(message) ); } @Nullable - private static Void readDeliveryResult(@NotNull JsonElement result) throws DDMessagingException { + private static Void readDeliveryResult(@NotNull JsonElement result) throws DDRemoteDamException { if (!readBoolean(result)) { - throw new DDMessagingException("External DAM callback was not accepted"); + throw new DDRemoteDamException("External DAM callback was not accepted"); } return null; } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingClient.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClient.java similarity index 59% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingClient.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClient.java index c0081e3..fa20b8f 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingClient.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClient.java @@ -14,12 +14,12 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.client; +package com.dbeaver.datadam.dam.remote.api.client; -import com.dbeaver.datadam.messaging.api.DDMessagingApi; -import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; -import com.dbeaver.datadam.messaging.api.model.DDMessagingConnection; -import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; +import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi; +import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSession; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import org.jkiss.code.NotNull; @@ -33,29 +33,29 @@ import java.util.UUID; import java.util.concurrent.CompletionStage; -public final class DDMessagingClient extends DDMessagingGraphQlClient { +public final class DDRemoteDamClient extends DDRemoteDamGraphQlClient { private static final int READ_TIMEOUT_MILLIS = 10_000; - private static final String CONNECTIONS_FIELD = "externalDamConnections"; - private static final String CONNECTION_ACCOUNT_FIELD = "externalDamConnectionAccount"; + private static final String SESSIONS_FIELD = "externalDamSessions"; + private static final String SESSION_ACCOUNT_FIELD = "externalDamSessionAccount"; private static final String SEND_FIELD = "sendExternalDamMessage"; - private static final String CONNECTIONS_OPERATION = "ExternalDamConnections"; - private static final String CONNECTION_ACCOUNT_OPERATION = "ExternalDamConnectionAccount"; + private static final String SESSIONS_OPERATION = "ExternalDamSessions"; + private static final String SESSION_ACCOUNT_OPERATION = "ExternalDamSessionAccount"; private static final String SEND_OPERATION = "SendExternalDamMessage"; - private static final String CONNECTIONS_QUERY = """ - query ExternalDamConnections($accountId: ID!) { - externalDamConnections(accountId: $accountId) { - deploymentId applicationRunId connectionId product version + private static final String SESSIONS_QUERY = """ + query ExternalDamSessions($accountId: ID!) { + externalDamSessions(accountId: $accountId) { + appDeploymentId appInstanceId damSessionId product version } } """; - private static final String CONNECTION_ACCOUNT_QUERY = """ - query ExternalDamConnectionAccount($connectionId: ID!) { - externalDamConnectionAccount(connectionId: $connectionId) + private static final String SESSION_ACCOUNT_QUERY = """ + query ExternalDamSessionAccount($damSessionId: ID!) { + externalDamSessionAccount(damSessionId: $damSessionId) } """; private static final String SEND_MUTATION = """ - mutation SendExternalDamMessage($accountId: ID!, $connectionId: ID!, $message: ExternalDamMessageInput!) { - sendExternalDamMessage(accountId: $accountId, connectionId: $connectionId, message: $message) + mutation SendExternalDamMessage($accountId: ID!, $damSessionId: ID!, $message: ExternalDamMessageInput!) { + sendExternalDamMessage(accountId: $accountId, damSessionId: $damSessionId, message: $message) } """; private static final String REGISTER_MUTATION = """ @@ -69,60 +69,60 @@ mutation UnregisterExternalDamSubscription($subscriberId: String!, $subscription } """; - public DDMessagingClient(@NotNull String serviceUrl, @NotNull String serviceSecret) { - super(serviceUrl, serviceSecret, DDMessagingApi.GRAPHQL_PATH, READ_TIMEOUT_MILLIS); + public DDRemoteDamClient(@NotNull String serviceUrl, @NotNull String serviceSecret) { + super(serviceUrl, serviceSecret, DDRemoteDamApi.GRAPHQL_PATH, READ_TIMEOUT_MILLIS); } @NotNull - public List getConnections(@NotNull UUID accountId) throws DDMessagingException { - return readConnections(request(CONNECTIONS_OPERATION, CONNECTIONS_QUERY, - Map.of("accountId", Objects.requireNonNull(accountId).toString()), CONNECTIONS_FIELD)); + public List getSessions(@NotNull UUID accountId) throws DDRemoteDamException { + return readSessions(request(SESSIONS_OPERATION, SESSIONS_QUERY, + Map.of("accountId", Objects.requireNonNull(accountId).toString()), SESSIONS_FIELD)); } @NotNull - public CompletionStage> getConnectionsAsync(@NotNull UUID accountId) { - return requestAsync(CONNECTIONS_OPERATION, CONNECTIONS_QUERY, - Map.of("accountId", Objects.requireNonNull(accountId).toString()), CONNECTIONS_FIELD, DDMessagingClient::readConnections); + public CompletionStage> getSessionsAsync(@NotNull UUID accountId) { + return requestAsync(SESSIONS_OPERATION, SESSIONS_QUERY, + Map.of("accountId", Objects.requireNonNull(accountId).toString()), SESSIONS_FIELD, DDRemoteDamClient::readSessions); } @NotNull - public UUID getConnectionAccount(@NotNull String connectionId) throws DDMessagingException { - return readConnectionAccount(request(CONNECTION_ACCOUNT_OPERATION, CONNECTION_ACCOUNT_QUERY, - connectionAccountVariables(connectionId), CONNECTION_ACCOUNT_FIELD)); + public UUID getSessionAccount(@NotNull String damSessionId) throws DDRemoteDamException { + return readSessionAccount(request(SESSION_ACCOUNT_OPERATION, SESSION_ACCOUNT_QUERY, + sessionAccountVariables(damSessionId), SESSION_ACCOUNT_FIELD)); } @NotNull - public CompletionStage getConnectionAccountAsync(@NotNull String connectionId) { - return requestAsync(CONNECTION_ACCOUNT_OPERATION, CONNECTION_ACCOUNT_QUERY, - connectionAccountVariables(connectionId), CONNECTION_ACCOUNT_FIELD, DDMessagingClient::readConnectionAccount); + public CompletionStage getSessionAccountAsync(@NotNull String damSessionId) { + return requestAsync(SESSION_ACCOUNT_OPERATION, SESSION_ACCOUNT_QUERY, + sessionAccountVariables(damSessionId), SESSION_ACCOUNT_FIELD, DDRemoteDamClient::readSessionAccount); } - public boolean sendMessage(@NotNull UUID accountId, @NotNull String connectionId, @NotNull DDMessagingMessage message) - throws DDMessagingException { - return readBoolean(request(SEND_OPERATION, SEND_MUTATION, messageVariables(accountId, connectionId, message), SEND_FIELD)); + public boolean sendMessage(@NotNull UUID accountId, @NotNull String damSessionId, @NotNull DDRemoteDamMessage message) + throws DDRemoteDamException { + return readBoolean(request(SEND_OPERATION, SEND_MUTATION, messageVariables(accountId, damSessionId, message), SEND_FIELD)); } @NotNull public CompletionStage sendMessageAsync( @NotNull UUID accountId, - @NotNull String connectionId, - @NotNull DDMessagingMessage message + @NotNull String damSessionId, + @NotNull DDRemoteDamMessage message ) { - return requestAsync(SEND_OPERATION, SEND_MUTATION, messageVariables(accountId, connectionId, message), SEND_FIELD, - DDMessagingGraphQlClient::readBoolean); + return requestAsync(SEND_OPERATION, SEND_MUTATION, messageVariables(accountId, damSessionId, message), SEND_FIELD, + DDRemoteDamGraphQlClient::readBoolean); } public int registerSubscription( @NotNull String subscriberId, @NotNull UUID subscriptionId, @NotNull UUID accountId, - @Nullable String connectionId, + @Nullable String damSessionId, @NotNull String topic, @NotNull String callbackUrl, @NotNull String callbackSecret - ) throws DDMessagingException { + ) throws DDRemoteDamException { return readLease(request("RegisterExternalDamSubscription", REGISTER_MUTATION, - subscriptionVariables(subscriberId, subscriptionId, accountId, connectionId, topic, callbackUrl, callbackSecret), + subscriptionVariables(subscriberId, subscriptionId, accountId, damSessionId, topic, callbackUrl, callbackSecret), "registerExternalDamSubscription")); } @@ -131,17 +131,17 @@ public CompletionStage registerSubscriptionAsync( @NotNull String subscriberId, @NotNull UUID subscriptionId, @NotNull UUID accountId, - @Nullable String connectionId, + @Nullable String damSessionId, @NotNull String topic, @NotNull String callbackUrl, @NotNull String callbackSecret ) { return requestAsync("RegisterExternalDamSubscription", REGISTER_MUTATION, - subscriptionVariables(subscriberId, subscriptionId, accountId, connectionId, topic, callbackUrl, callbackSecret), - "registerExternalDamSubscription", DDMessagingClient::readLease); + subscriptionVariables(subscriberId, subscriptionId, accountId, damSessionId, topic, callbackUrl, callbackSecret), + "registerExternalDamSubscription", DDRemoteDamClient::readLease); } - public boolean unregisterSubscription(@NotNull String subscriberId, @NotNull UUID subscriptionId) throws DDMessagingException { + public boolean unregisterSubscription(@NotNull String subscriberId, @NotNull UUID subscriptionId) throws DDRemoteDamException { return readBoolean(request("UnregisterExternalDamSubscription", UNREGISTER_MUTATION, unsubscriptionVariables(subscriberId, subscriptionId), "unregisterExternalDamSubscription")); } @@ -150,27 +150,27 @@ public boolean unregisterSubscription(@NotNull String subscriberId, @NotNull UUI public CompletionStage unregisterSubscriptionAsync(@NotNull String subscriberId, @NotNull UUID subscriptionId) { return requestAsync("UnregisterExternalDamSubscription", UNREGISTER_MUTATION, unsubscriptionVariables(subscriberId, subscriptionId), "unregisterExternalDamSubscription", - DDMessagingGraphQlClient::readBoolean); + DDRemoteDamGraphQlClient::readBoolean); } @NotNull - private static List readConnections(@NotNull JsonElement result) throws DDMessagingException { + private static List readSessions(@NotNull JsonElement result) throws DDRemoteDamException { if (!result.isJsonArray()) { throw invalidResponse(); } - List connections = new ArrayList<>(); + List sessions = new ArrayList<>(); for (JsonElement value : result.getAsJsonArray()) { - JsonObject connection = readObject(value); - connections.add(new DDMessagingConnection( - readString(connection, "deploymentId"), readString(connection, "applicationRunId"), - readString(connection, "connectionId"), readString(connection, "product"), readString(connection, "version") + JsonObject session = readObject(value); + sessions.add(new DDRemoteDamSession( + readString(session, "appDeploymentId"), readString(session, "appInstanceId"), + readString(session, "damSessionId"), readString(session, "product"), readString(session, "version") )); } - return List.copyOf(connections); + return List.copyOf(sessions); } @NotNull - private static UUID readConnectionAccount(@NotNull JsonElement result) throws DDMessagingException { + private static UUID readSessionAccount(@NotNull JsonElement result) throws DDRemoteDamException { if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isString()) { throw invalidResponse(); } @@ -186,14 +186,14 @@ private static UUID readConnectionAccount(@NotNull JsonElement result) throws DD return accountId; } - private static int readLease(@NotNull JsonElement result) throws DDMessagingException { + private static int readLease(@NotNull JsonElement result) throws DDRemoteDamException { if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isNumber()) { throw invalidResponse(); } try { int leaseSeconds = result.getAsBigDecimal().intValueExact(); - if (leaseSeconds >= DDMessagingApi.MIN_SUBSCRIPTION_LEASE_SECONDS - && leaseSeconds <= DDMessagingApi.MAX_SUBSCRIPTION_LEASE_SECONDS) { + if (leaseSeconds >= DDRemoteDamApi.MIN_SUBSCRIPTION_LEASE_SECONDS + && leaseSeconds <= DDRemoteDamApi.MAX_SUBSCRIPTION_LEASE_SECONDS) { return leaseSeconds; } } catch (ArithmeticException | NumberFormatException e) { @@ -203,24 +203,24 @@ private static int readLease(@NotNull JsonElement result) throws DDMessagingExce } @NotNull - private static Map connectionAccountVariables(@NotNull String connectionId) { - if (connectionId.isBlank() || connectionId.length() > 256) { - throw new IllegalArgumentException("Invalid connection ID"); + private static Map sessionAccountVariables(@NotNull String damSessionId) { + if (damSessionId.isBlank() || damSessionId.length() > 256) { + throw new IllegalArgumentException("Invalid session ID"); } - return Map.of("connectionId", connectionId); + return Map.of("damSessionId", damSessionId); } @NotNull private static Map messageVariables( @NotNull UUID accountId, - @NotNull String connectionId, - @NotNull DDMessagingMessage message + @NotNull String damSessionId, + @NotNull DDRemoteDamMessage message ) { - if (connectionId.isBlank()) { - throw new IllegalArgumentException("A connection ID is required"); + if (damSessionId.isBlank()) { + throw new IllegalArgumentException("A session ID is required"); } return Map.of( - "accountId", Objects.requireNonNull(accountId).toString(), "connectionId", connectionId, + "accountId", Objects.requireNonNull(accountId).toString(), "damSessionId", damSessionId, "message", Objects.requireNonNull(message) ); } @@ -230,27 +230,27 @@ private static int readLease(@NotNull JsonElement result) throws DDMessagingExce @NotNull String subscriberId, @NotNull UUID subscriptionId, @NotNull UUID accountId, - @Nullable String connectionId, + @Nullable String damSessionId, @NotNull String topic, @NotNull String callbackUrl, @NotNull String callbackSecret ) { requireSubscriberId(subscriberId); requireText(topic); - if (connectionId != null) { - requireText(connectionId); + if (damSessionId != null) { + requireText(damSessionId); } - if (!DDMessagingApi.isValidServiceUrl(callbackUrl)) { + if (!DDRemoteDamApi.isValidServiceUrl(callbackUrl)) { throw new IllegalArgumentException("Invalid External DAM callback URL"); } - if (!DDMessagingApi.isValidServiceSecret(callbackSecret)) { + if (!DDRemoteDamApi.isValidServiceSecret(callbackSecret)) { throw new IllegalArgumentException("Invalid External DAM callback secret"); } Map input = new HashMap<>(); input.put("subscriberId", subscriberId); input.put("subscriptionId", Objects.requireNonNull(subscriptionId).toString()); input.put("accountId", Objects.requireNonNull(accountId).toString()); - input.put("connectionId", connectionId); + input.put("damSessionId", damSessionId); input.put("topic", topic); input.put("callbackUrl", callbackUrl); input.put("callbackSecret", callbackSecret); @@ -264,7 +264,7 @@ private static int readLease(@NotNull JsonElement result) throws DDMessagingExce } @NotNull - private static String readString(@NotNull JsonObject object, @NotNull String field) throws DDMessagingException { + private static String readString(@NotNull JsonObject object, @NotNull String field) throws DDRemoteDamException { JsonElement value = object.get(field); if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank()) { throw invalidResponse(); @@ -273,7 +273,7 @@ private static String readString(@NotNull JsonObject object, @NotNull String fie } private static void requireSubscriberId(@Nullable String subscriberId) { - if (!DDMessagingApi.isValidSubscriberId(subscriberId)) { + if (!DDRemoteDamApi.isValidSubscriberId(subscriberId)) { throw new IllegalArgumentException("Invalid subscription metadata"); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingGraphQlClient.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamGraphQlClient.java similarity index 85% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingGraphQlClient.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamGraphQlClient.java index e6e982b..b646a17 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/client/DDMessagingGraphQlClient.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamGraphQlClient.java @@ -14,10 +14,10 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.client; +package com.dbeaver.datadam.dam.remote.api.client; -import com.dbeaver.datadam.messaging.api.DDMessagingApi; -import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; +import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi; +import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException; import com.dbeaver.rest.client.AbstractRestClient; import com.dbeaver.rest.client.MediaType; import com.google.gson.JsonElement; @@ -45,15 +45,15 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -abstract class DDMessagingGraphQlClient extends AbstractRestClient { +abstract class DDRemoteDamGraphQlClient extends AbstractRestClient { private final String serviceSecret; private final String endpoint; private final Duration requestTimeout; - DDMessagingGraphQlClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull String endpoint, int timeoutMillis) { + DDRemoteDamGraphQlClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull String endpoint, int timeoutMillis) { super(validateServiceUrl(serviceUrl), Math.min(DEFAULT_CONNECT_TIMEOUT, timeoutMillis), timeoutMillis, List.of(), HttpClient.Redirect.NEVER); - if (!DDMessagingApi.isValidServiceSecret(serviceSecret)) { + if (!DDRemoteDamApi.isValidServiceSecret(serviceSecret)) { throw new IllegalArgumentException("A valid External DAM service secret is required"); } this.serviceSecret = serviceSecret; @@ -67,7 +67,7 @@ final JsonElement request( @NotNull String query, @NotNull Map variables, @NotNull String field - ) throws DDMessagingException { + ) throws DDRemoteDamException { String response; try { response = execute(createRequest(operationName, query, variables), String.class); @@ -99,11 +99,11 @@ final CompletionStage requestAsync( } try { int code = response.statusCode(); - if (code < 200 || code >= 300) { + if (code < HttpConstants.CODE_OK || code >= HttpConstants.CODE_MULTIPLE_CHOICES) { throw mapErrorResponse(code, "", request.uri()); } return readResult(response.body(), field); - } catch (DDMessagingException e) { + } catch (DDRemoteDamException e) { throw new CompletionException(e); } }), bounded); @@ -124,7 +124,7 @@ final CompletionStage requestAsync( return propagateCancellation(response.thenApply(result -> { try { return reader.read(result); - } catch (DDMessagingException e) { + } catch (DDRemoteDamException e) { throw new CompletionException(e); } }), response); @@ -160,7 +160,7 @@ private HttpRequest.Builder createRequest( ) throws DBException { return HttpRequest.newBuilder(buildUri(CommonUtils.removeLeadingSlash(endpoint), Map.of())) .timeout(requestTimeout) - .header(DDMessagingApi.HEADER_SERVICE_TOKEN, serviceSecret) + .header(DDRemoteDamApi.HEADER_SERVICE_TOKEN, serviceSecret) .header(HttpConstants.HEADER_CONTENT_TYPE, MediaType.JSON.toString()) .header(HttpConstants.HEADER_ACCEPT, MediaType.JSON.toString()) .header(HttpConstants.HEADER_CACHE_CONTROL, HttpConstants.CACHE_CONTROL_NO_STORE) @@ -168,7 +168,7 @@ private HttpRequest.Builder createRequest( } @NotNull - private static JsonElement readResult(@NotNull String response, @NotNull String field) throws DDMessagingException { + private static JsonElement readResult(@NotNull String response, @NotNull String field) throws DDRemoteDamException { JsonObject envelope; try { envelope = readObject(JsonParser.parseString(response)); @@ -177,7 +177,7 @@ private static JsonElement readResult(@NotNull String response, @NotNull String } JsonElement errors = envelope.get("errors"); if (errors != null && (!errors.isJsonArray() || !errors.getAsJsonArray().isEmpty())) { - throw new DDMessagingException("External DAM GraphQL request failed"); + throw new DDRemoteDamException("External DAM GraphQL request failed"); } JsonElement result = readObject(envelope.get("data")).get(field); if (result == null || result.isJsonNull()) { @@ -188,11 +188,11 @@ private static JsonElement readResult(@NotNull String response, @NotNull String @Override @NotNull - protected DDMessagingException mapErrorResponse(int code, @NotNull String message, @NotNull URI uri) { - return new DDMessagingException("External DAM request failed: HTTP " + code); + protected DDRemoteDamException mapErrorResponse(int code, @NotNull String message, @NotNull URI uri) { + return new DDRemoteDamException("External DAM request failed: HTTP " + code); } - static boolean readBoolean(@NotNull JsonElement value) throws DDMessagingException { + static boolean readBoolean(@NotNull JsonElement value) throws DDRemoteDamException { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) { throw invalidResponse(); } @@ -200,7 +200,7 @@ static boolean readBoolean(@NotNull JsonElement value) throws DDMessagingExcepti } @NotNull - static JsonObject readObject(@Nullable JsonElement value) throws DDMessagingException { + static JsonObject readObject(@Nullable JsonElement value) throws DDRemoteDamException { if (value == null || !value.isJsonObject()) { throw invalidResponse(); } @@ -208,12 +208,12 @@ static JsonObject readObject(@Nullable JsonElement value) throws DDMessagingExce } @NotNull - static DDMessagingException invalidResponse() { - return new DDMessagingException("Invalid External DAM response"); + static DDRemoteDamException invalidResponse() { + return new DDRemoteDamException("Invalid External DAM response"); } @NotNull - private static DDMessagingException requestFailure(@NotNull Throwable error) { + private static DDRemoteDamException requestFailure(@NotNull Throwable error) { while (error instanceof CompletionException && error.getCause() != null) { error = error.getCause(); } @@ -222,13 +222,13 @@ private static DDMessagingException requestFailure(@NotNull Throwable error) { timeout.initCause(error); error = timeout; } - return error instanceof DDMessagingException failure - ? failure : new DDMessagingException("External DAM service request failed", error); + return error instanceof DDRemoteDamException failure + ? failure : new DDRemoteDamException("External DAM service request failed", error); } @NotNull private static String validateServiceUrl(@NotNull String serviceUrl) { - if (!DDMessagingApi.isValidServiceUrl(serviceUrl)) { + if (!DDRemoteDamApi.isValidServiceUrl(serviceUrl)) { throw new IllegalArgumentException("Invalid External DAM service URL"); } return serviceUrl; @@ -237,6 +237,6 @@ private static String validateServiceUrl(@NotNull String serviceUrl) { @FunctionalInterface interface ResultReader { @Nullable - T read(@NotNull JsonElement result) throws DDMessagingException; + T read(@NotNull JsonElement result) throws DDRemoteDamException; } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/exception/DDMessagingException.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/exception/DDRemoteDamException.java similarity index 77% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/exception/DDMessagingException.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/exception/DDRemoteDamException.java index 5b13f2f..25fa401 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/exception/DDMessagingException.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/exception/DDRemoteDamException.java @@ -14,17 +14,17 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.exception; +package com.dbeaver.datadam.dam.remote.api.exception; import org.jkiss.code.NotNull; import org.jkiss.dbeaver.DBException; -public class DDMessagingException extends DBException { - public DDMessagingException(@NotNull String message) { +public class DDRemoteDamException extends DBException { + public DDRemoteDamException(@NotNull String message) { super(message); } - public DDMessagingException(@NotNull String message, @NotNull Throwable cause) { + public DDRemoteDamException(@NotNull String message, @NotNull Throwable cause) { super(message, cause); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingHandshakeRequest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeRequest.java similarity index 60% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingHandshakeRequest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeRequest.java index 7d8029b..ff22925 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingHandshakeRequest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeRequest.java @@ -14,20 +14,20 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.model; +package com.dbeaver.datadam.dam.remote.api.model; import org.jkiss.code.NotNull; -public record DDMessagingHandshakeRequest( - @NotNull String deploymentId, - @NotNull String applicationRunId, +public record DDRemoteDamHandshakeRequest( + @NotNull String appDeploymentId, + @NotNull String appInstanceId, @NotNull String product, @NotNull String version ) { - public DDMessagingHandshakeRequest { - DDMessagingMessage.requireText(deploymentId, "deploymentId"); - DDMessagingMessage.requireText(applicationRunId, "applicationRunId"); - DDMessagingMessage.requireText(product, "product"); - DDMessagingMessage.requireText(version, "version"); + public DDRemoteDamHandshakeRequest { + DDRemoteDamMessage.requireText(appDeploymentId, "appDeploymentId"); + DDRemoteDamMessage.requireText(appInstanceId, "appInstanceId"); + DDRemoteDamMessage.requireText(product, "product"); + DDRemoteDamMessage.requireText(version, "version"); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingHandshakeResponse.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeResponse.java similarity index 74% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingHandshakeResponse.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeResponse.java index 4085269..2d38996 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingHandshakeResponse.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeResponse.java @@ -14,12 +14,12 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.model; +package com.dbeaver.datadam.dam.remote.api.model; import org.jkiss.code.NotNull; -public record DDMessagingHandshakeResponse(@NotNull String connectionId) { - public DDMessagingHandshakeResponse { - DDMessagingMessage.requireText(connectionId, "connectionId"); +public record DDRemoteDamHandshakeResponse(@NotNull String damSessionId) { + public DDRemoteDamHandshakeResponse { + DDRemoteDamMessage.requireText(damSessionId, "damSessionId"); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessage.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessage.java similarity index 91% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessage.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessage.java index c638936..4eaa3cf 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessage.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessage.java @@ -14,7 +14,7 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.model; +package com.dbeaver.datadam.dam.remote.api.model; import com.google.gson.JsonObject; import org.jkiss.code.NotNull; @@ -22,12 +22,12 @@ import java.util.Objects; -public record DDMessagingMessage( +public record DDRemoteDamMessage( @NotNull String topic, @NotNull String type, @NotNull JsonObject payload ) { - public DDMessagingMessage { + public DDRemoteDamMessage { requireText(topic, "topic"); requireText(type, "type"); payload = Objects.requireNonNull(payload, "payload").deepCopy(); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnection.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSession.java similarity index 57% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnection.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSession.java index 91267c7..3735860 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnection.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSession.java @@ -14,22 +14,22 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.model; +package com.dbeaver.datadam.dam.remote.api.model; import org.jkiss.code.NotNull; -public record DDMessagingConnection( - @NotNull String deploymentId, - @NotNull String applicationRunId, - @NotNull String connectionId, +public record DDRemoteDamSession( + @NotNull String appDeploymentId, + @NotNull String appInstanceId, + @NotNull String damSessionId, @NotNull String product, @NotNull String version ) { - public DDMessagingConnection { - DDMessagingMessage.requireText(deploymentId, "deploymentId"); - DDMessagingMessage.requireText(applicationRunId, "applicationRunId"); - DDMessagingMessage.requireText(connectionId, "connectionId"); - DDMessagingMessage.requireText(product, "product"); - DDMessagingMessage.requireText(version, "version"); + public DDRemoteDamSession { + DDRemoteDamMessage.requireText(appDeploymentId, "appDeploymentId"); + DDRemoteDamMessage.requireText(appInstanceId, "appInstanceId"); + DDRemoteDamMessage.requireText(damSessionId, "damSessionId"); + DDRemoteDamMessage.requireText(product, "product"); + DDRemoteDamMessage.requireText(version, "version"); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDDamConnectionInfo.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionInfo.java similarity index 60% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDDamConnectionInfo.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionInfo.java index 1e88f8f..c712772 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/model/DDDamConnectionInfo.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionInfo.java @@ -14,18 +14,18 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.model; +package com.dbeaver.datadam.dam.remote.api.model; import org.jkiss.code.NotNull; -public record DDDamConnectionInfo( - @NotNull String deploymentId, - @NotNull String applicationRunId, - @NotNull String connectionId +public record DDRemoteDamSessionInfo( + @NotNull String appDeploymentId, + @NotNull String appInstanceId, + @NotNull String damSessionId ) { - public DDDamConnectionInfo { - DDMessagingMessage.requireText(deploymentId, "deploymentId"); - DDMessagingMessage.requireText(applicationRunId, "applicationRunId"); - DDMessagingMessage.requireText(connectionId, "connectionId"); + public DDRemoteDamSessionInfo { + DDRemoteDamMessage.requireText(appDeploymentId, "appDeploymentId"); + DDRemoteDamMessage.requireText(appInstanceId, "appInstanceId"); + DDRemoteDamMessage.requireText(damSessionId, "damSessionId"); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransport.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransport.java similarity index 87% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransport.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransport.java index 80db929..ec55475 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransport.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransport.java @@ -14,15 +14,15 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.requestreply; +package com.dbeaver.datadam.dam.remote.api.requestreply; -import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; -import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; -import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannel; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelStatus; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingMessageListener; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingSubscription; +import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelStatus; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamMessageListener; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSubscription; import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; @@ -46,7 +46,7 @@ import java.util.concurrent.CompletionStage; import java.util.concurrent.Semaphore; -public final class DDMessagingRequestReplyTransport implements MessagingRequestReplyTransport, AutoCloseable { +public final class DDRemoteDamRequestReplyTransport implements MessagingRequestReplyTransport, AutoCloseable { private static final String MESSAGE_TYPE = "datadam.requestreply.v1"; private static final String FIELD_REQUEST_ID = "requestId"; private static final String FIELD_REPLY_TO = "replyTo"; @@ -62,7 +62,7 @@ public final class DDMessagingRequestReplyTransport implements MessagingRequestR private final Sender sender; private final TopicSubscriber subscriber; @Nullable - private final DDMessagingChannel channel; + private final DDRemoteDamChannel channel; @Nullable private Registration registration; private boolean claimed; @@ -71,25 +71,25 @@ public final class DDMessagingRequestReplyTransport implements MessagingRequestR @FunctionalInterface public interface Sender { @NotNull - CompletionStage send(@NotNull DDMessagingMessage message); + CompletionStage send(@NotNull DDRemoteDamMessage message); } /** Registers topic listeners for a selected peer. */ @FunctionalInterface public interface TopicSubscriber { @NotNull - DDMessagingSubscription subscribe(@NotNull String topic, @NotNull DDMessagingMessageListener listener) throws DDMessagingException; + DDRemoteDamSubscription subscribe(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) throws DDRemoteDamException; } - public DDMessagingRequestReplyTransport(@NotNull String topic, @NotNull Sender sender, @NotNull TopicSubscriber subscriber) { + public DDRemoteDamRequestReplyTransport(@NotNull String topic, @NotNull Sender sender, @NotNull TopicSubscriber subscriber) { this(topic, sender, subscriber, null); } - private DDMessagingRequestReplyTransport( + private DDRemoteDamRequestReplyTransport( @NotNull String topic, @NotNull Sender sender, @NotNull TopicSubscriber subscriber, - @Nullable DDMessagingChannel channel + @Nullable DDRemoteDamChannel channel ) { if (topic.isBlank() || topic.length() > 256) { throw new IllegalArgumentException("Expected a nonblank request/reply topic of at most 256 characters"); @@ -102,9 +102,9 @@ private DDMessagingRequestReplyTransport( } @NotNull - public static DDMessagingRequestReplyTransport forChannel(@NotNull DDMessagingChannel channel, @NotNull String topic) { + public static DDRemoteDamRequestReplyTransport forChannel(@NotNull DDRemoteDamChannel channel, @NotNull String topic) { Objects.requireNonNull(channel); - return new DDMessagingRequestReplyTransport(topic, channel::send, channel::subscribe, channel); + return new DDRemoteDamRequestReplyTransport(topic, channel::send, channel::subscribe, channel); } @Override @@ -126,8 +126,8 @@ public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listen opened.requireReady(); opened.add(channel.observeState(opened::state)); } - opened.add(subscriber.subscribe(opened.replyTopic, (connection, message) -> opened.receive(connection, message, false))); - opened.add(subscriber.subscribe(topic, (connection, message) -> opened.receive(connection, message, true))); + opened.add(subscriber.subscribe(opened.replyTopic, (session, message) -> opened.receive(session, message, false))); + opened.add(subscriber.subscribe(topic, (session, message) -> opened.receive(session, message, true))); if (channel != null) { opened.state(channel.getStatus()); } @@ -136,7 +136,7 @@ public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listen opened.opening = false; } return opened; - } catch (DDMessagingException | MessagingRequestReplyException | RuntimeException e) { + } catch (DDRemoteDamException | MessagingRequestReplyException | RuntimeException e) { try { opened.close(); } catch (RuntimeException closeFailure) { @@ -179,7 +179,7 @@ public CompletionStage send(@NotNull MessagingRequestReplyMessage result = new CompletableFuture<>(); - Objects.requireNonNull(sender.send(new DDMessagingMessage(target, MESSAGE_TYPE, envelope))).whenComplete((ignored, error) -> { + Objects.requireNonNull(sender.send(new DDRemoteDamMessage(target, MESSAGE_TYPE, envelope))).whenComplete((ignored, error) -> { sendBudget.release(); if (error == null) { result.complete(null); @@ -217,7 +217,7 @@ private static String replyPrefix(@NotNull String topic) { } @Nullable - private static MessagingRequestReplyMessage decode(@NotNull DDMessagingMessage message) { + private static MessagingRequestReplyMessage decode(@NotNull DDRemoteDamMessage message) { if (!MESSAGE_TYPE.equals(message.type())) { return null; } @@ -270,10 +270,10 @@ private final class Registration implements MessagingSubscription { private final UUID endpointId; private final String replyTopic; private final Listener listener; - private final List subscriptions = new ArrayList<>(); + private final List subscriptions = new ArrayList<>(); private final ArrayDeque deliveries = new ArrayDeque<>(); @Nullable - private DDMessagingChannelStatus status; + private DDRemoteDamChannelStatus status; private boolean opening = true; private boolean openingDisconnected; private boolean delivering; @@ -285,7 +285,7 @@ private Registration(@NotNull UUID endpointId, @NotNull Listener lis this.listener = listener; } - private void add(@NotNull DDMessagingSubscription subscription) { + private void add(@NotNull DDRemoteDamSubscription subscription) { synchronized (lock) { if (!closed) { subscriptions.add(Objects.requireNonNull(subscription)); @@ -306,7 +306,7 @@ private void requireReady() throws MessagingRequestReplyException { } } - private void state(@NotNull DDMessagingChannelStatus next) { + private void state(@NotNull DDRemoteDamChannelStatus next) { boolean drain; synchronized (lock) { if (closed) { @@ -320,18 +320,18 @@ private void state(@NotNull DDMessagingChannelStatus next) { } } - private boolean updateState(@NotNull DDMessagingChannelStatus next) { + private boolean updateState(@NotNull DDRemoteDamChannelStatus next) { if (next.equals(status)) { return false; } boolean disconnected = !next.isReady() || status != null && status.isReady() - && !Objects.equals(status.connection(), next.connection()); + && !Objects.equals(status.session(), next.session()); status = next; openingDisconnected |= opening && disconnected; return disconnected && enqueue(null); } - private void receive(@NotNull DDDamConnectionInfo connection, @NotNull DDMessagingMessage message, boolean requestTopic) { + private void receive(@NotNull DDRemoteDamSessionInfo session, @NotNull DDRemoteDamMessage message, boolean requestTopic) { MessagingRequestReplyMessage frame = decode(message); if (frame == null || (requestTopic ? frame.type() != MessagingRequestReplyMessage.Type.REQUEST : frame.type() == MessagingRequestReplyMessage.Type.REQUEST || !endpointId.equals(frame.replyTo()))) { @@ -343,8 +343,8 @@ private void receive(@NotNull DDDamConnectionInfo connection, @NotNull DDMessagi return; } if (channel != null) { - DDMessagingChannelStatus current = channel.getStatus(); - if (!current.isReady() || !connection.equals(current.connection())) { + DDRemoteDamChannelStatus current = channel.getStatus(); + if (!current.isReady() || !session.equals(current.session())) { return; } drain = updateState(current); @@ -415,7 +415,7 @@ private void drain() { @Override public void close() { - List stopped; + List stopped; synchronized (lock) { if (closed) { return; @@ -426,7 +426,7 @@ public void close() { subscriptions.clear(); } RuntimeException failure = null; - for (DDMessagingSubscription subscription : stopped.reversed()) { + for (DDRemoteDamSubscription subscription : stopped.reversed()) { try { subscription.close(); } catch (RuntimeException e) { diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingGsonRpcCodec.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodec.java similarity index 89% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingGsonRpcCodec.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodec.java index 647081d..2c5cd95 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingGsonRpcCodec.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodec.java @@ -14,7 +14,7 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.rpc; +package com.dbeaver.datadam.dam.remote.api.rpc; import com.dbeaver.messaging.rpc.MessagingRpcCodec; import com.google.gson.Gson; @@ -25,10 +25,10 @@ import java.lang.reflect.Type; import java.util.Objects; -public final class DDMessagingGsonRpcCodec implements MessagingRpcCodec { +public final class DDRemoteDamGsonRpcCodec implements MessagingRpcCodec { private final Gson gson; - public DDMessagingGsonRpcCodec(@NotNull Gson gson) { + public DDRemoteDamGsonRpcCodec(@NotNull Gson gson) { this.gson = Objects.requireNonNull(gson); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransport.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransport.java similarity index 93% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransport.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransport.java index d662eb0..a82c0ed 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransport.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransport.java @@ -14,10 +14,10 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.rpc; +package com.dbeaver.datadam.dam.remote.api.rpc; -import com.dbeaver.datadam.messaging.api.requestreply.DDMessagingRequestReplyTransport; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannel; +import com.dbeaver.datadam.dam.remote.api.requestreply.DDRemoteDamRequestReplyTransport; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel; import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport; @@ -41,7 +41,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.Semaphore; -public final class DDMessagingRpcTransport implements MessagingRequestReplyTransport> { +public final class DDRemoteDamRpcTransport implements MessagingRequestReplyTransport> { private static final String PROTOCOL = "dbeaver.rpc.v2"; private static final String FIELD_PROTOCOL = "protocol"; private static final String FIELD_TYPE = "type"; @@ -58,33 +58,33 @@ public final class DDMessagingRpcTransport implements MessagingRequestReplyTrans @Nullable private final Executor requestExecutor; - public DDMessagingRpcTransport(@NotNull MessagingRequestReplyTransport delegate) { + public DDRemoteDamRpcTransport(@NotNull MessagingRequestReplyTransport delegate) { this(delegate, null); } - public DDMessagingRpcTransport(@NotNull MessagingRequestReplyTransport delegate, @Nullable Executor requestExecutor) { + public DDRemoteDamRpcTransport(@NotNull MessagingRequestReplyTransport delegate, @Nullable Executor requestExecutor) { this.delegate = Objects.requireNonNull(delegate); this.requestExecutor = requestExecutor; } @NotNull - public static MessagingRpcTransport forChannel(@NotNull DDMessagingChannel channel) { + public static MessagingRpcTransport forChannel(@NotNull DDRemoteDamChannel channel) { Objects.requireNonNull(channel); return topic -> forChannel(channel, topic); } @NotNull - public static DDMessagingRpcTransport forChannel(@NotNull DDMessagingChannel channel, @NotNull String topic) { - return new DDMessagingRpcTransport(DDMessagingRequestReplyTransport.forChannel(channel, topic)); + public static DDRemoteDamRpcTransport forChannel(@NotNull DDRemoteDamChannel channel, @NotNull String topic) { + return new DDRemoteDamRpcTransport(DDRemoteDamRequestReplyTransport.forChannel(channel, topic)); } @NotNull - public static DDMessagingRpcTransport forChannel( - @NotNull DDMessagingChannel channel, + public static DDRemoteDamRpcTransport forChannel( + @NotNull DDRemoteDamChannel channel, @NotNull String topic, @NotNull Executor requestExecutor ) { - return new DDMessagingRpcTransport(DDMessagingRequestReplyTransport.forChannel(channel, topic), + return new DDRemoteDamRpcTransport(DDRemoteDamRequestReplyTransport.forChannel(channel, topic), Objects.requireNonNull(requestExecutor)); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannel.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannel.java similarity index 56% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannel.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannel.java index 0e2a418..939a3d9 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannel.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannel.java @@ -14,31 +14,31 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.transport; +package com.dbeaver.datadam.dam.remote.api.transport; -import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; import org.jkiss.code.NotNull; import java.util.concurrent.CompletionStage; import java.util.function.Consumer; -/** Provides asynchronous DataDam messaging and connection-state observation. */ -public interface DDMessagingChannel extends AutoCloseable { +/** Provides asynchronous DataDam messaging and session-state observation. */ +public interface DDRemoteDamChannel extends AutoCloseable { @NotNull - DDMessagingChannelStatus getStatus(); + DDRemoteDamChannelStatus getStatus(); @NotNull - DDMessagingSubscription observeState(@NotNull Consumer observer); + DDRemoteDamSubscription observeState(@NotNull Consumer observer); @NotNull - CompletionStage send(@NotNull DDMessagingMessage message); + CompletionStage send(@NotNull DDRemoteDamMessage message); @NotNull - DDMessagingSubscription subscribe(@NotNull String topic, @NotNull DDMessagingMessageListener listener); + DDRemoteDamSubscription subscribe(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener); @NotNull - DDMessagingSubscription addConnectionListener(@NotNull DDMessagingConnectionListener listener); + DDRemoteDamSubscription addSessionListener(@NotNull DDRemoteDamSessionListener listener); @Override void close(); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelState.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelState.java similarity index 88% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelState.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelState.java index 90b0f14..abfc0b2 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelState.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelState.java @@ -14,9 +14,9 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.transport; +package com.dbeaver.datadam.dam.remote.api.transport; -public enum DDMessagingChannelState { +public enum DDRemoteDamChannelState { DISCONNECTED, CONNECTING, CONNECTED, diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatus.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatus.java similarity index 65% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatus.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatus.java index d92e902..4622d25 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatus.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatus.java @@ -14,23 +14,23 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.transport; +package com.dbeaver.datadam.dam.remote.api.transport; -import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; import java.util.Objects; -public record DDMessagingChannelStatus(@NotNull DDMessagingChannelState state, @Nullable DDDamConnectionInfo connection) { - public DDMessagingChannelStatus { +public record DDRemoteDamChannelStatus(@NotNull DDRemoteDamChannelState state, @Nullable DDRemoteDamSessionInfo session) { + public DDRemoteDamChannelStatus { Objects.requireNonNull(state); - if ((state == DDMessagingChannelState.CONNECTED) != (connection != null)) { - throw new IllegalArgumentException("Only a connected channel has connection information"); + if ((state == DDRemoteDamChannelState.CONNECTED) != (session != null)) { + throw new IllegalArgumentException("Only a connected channel has session information"); } } public boolean isReady() { - return state == DDMessagingChannelState.CONNECTED; + return state == DDRemoteDamChannelState.CONNECTED; } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingMessageListener.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamMessageListener.java similarity index 63% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingMessageListener.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamMessageListener.java index 5d0d8c0..970fd7f 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingMessageListener.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamMessageListener.java @@ -14,14 +14,14 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.transport; +package com.dbeaver.datadam.dam.remote.api.transport; -import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; -import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; import org.jkiss.code.NotNull; -/** Receives topic messages with their source connection. */ +/** Receives topic messages with their source session. */ @FunctionalInterface -public interface DDMessagingMessageListener { - void onMessage(@NotNull DDDamConnectionInfo connection, @NotNull DDMessagingMessage message); +public interface DDRemoteDamMessageListener { + void onMessage(@NotNull DDRemoteDamSessionInfo session, @NotNull DDRemoteDamMessage message); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingConnectionListener.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSessionListener.java similarity index 65% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingConnectionListener.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSessionListener.java index 3cdf6d8..7d289fb 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingConnectionListener.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSessionListener.java @@ -14,14 +14,14 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.transport; +package com.dbeaver.datadam.dam.remote.api.transport; -import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; import org.jkiss.code.NotNull; -/** Receives physical connection lifecycle notifications. */ -public interface DDMessagingConnectionListener { - void onConnected(@NotNull DDDamConnectionInfo connection); +/** Receives physical session lifecycle notifications. */ +public interface DDRemoteDamSessionListener { + void onConnected(@NotNull DDRemoteDamSessionInfo session); - void onDisconnected(@NotNull DDDamConnectionInfo connection); + void onDisconnected(@NotNull DDRemoteDamSessionInfo session); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingSubscription.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSubscription.java similarity index 86% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingSubscription.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSubscription.java index be0b9f4..1d8d261 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingSubscription.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSubscription.java @@ -14,11 +14,11 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.transport; +package com.dbeaver.datadam.dam.remote.api.transport; /** Controls the lifetime of a DataDam listener registration. */ @FunctionalInterface -public interface DDMessagingSubscription extends AutoCloseable { +public interface DDRemoteDamSubscription extends AutoCloseable { @Override void close(); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestHandler.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestHandler.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestHandler.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestHandler.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpoint.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpoint.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpoint.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpoint.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyException.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyException.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyException.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyException.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessage.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessage.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessage.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessage.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptions.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptions.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptions.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptions.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyTransport.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyTransport.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyTransport.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyTransport.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingSubscription.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingSubscription.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingSubscription.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingSubscription.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcCodec.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcCodec.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcCodec.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcCodec.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java similarity index 88% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java index fe9ba79..b118095 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java @@ -27,15 +27,21 @@ import java.lang.reflect.WildcardType; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.CompletionStage; import java.util.concurrent.Future; +import java.util.logging.Level; +import java.util.logging.Logger; import java.util.stream.Collectors; final class MessagingRpcContract { + private static final Logger log = Logger.getLogger(MessagingRpcContract.class.getName()); private static final Map, Class> BOXED = Map.of( boolean.class, Boolean.class, byte.class, Byte.class, short.class, Short.class, char.class, Character.class, int.class, Integer.class, long.class, Long.class, float.class, Float.class, double.class, Double.class @@ -44,7 +50,7 @@ final class MessagingRpcContract { private final Map operations; private MessagingRpcContract(@NotNull Map operations) { - this.operations = Map.copyOf(operations); + this.operations = Collections.unmodifiableMap(new LinkedHashMap<>(operations)); } @NotNull @@ -53,21 +59,28 @@ static MessagingRpcContract create(@NotNull Class type) { if (!type.isInterface() || !Modifier.isPublic(type.getModifiers()) || type.isSealed() || type.getTypeParameters().length != 0) { throw new IllegalArgumentException("RPC contract must be a public, nonsealed interface with concrete types"); } - Map> declarations = new HashMap<>(); + Map> declarations = new LinkedHashMap<>(); + Set skipped = new HashSet<>(); for (Method method : type.getMethods()) { if (Modifier.isStatic(method.getModifiers()) || method.isBridge() || method.isSynthetic() || isObjectMethod(method)) { continue; } - validate(method); + try { + validate(method); + } catch (IllegalArgumentException e) { + skipped.add(operationId(method)); + log.log(Level.WARNING, "Skipping unsupported RPC method " + method.toGenericString(), e); + continue; + } declarations.computeIfAbsent(operationId(method), ignored -> new ArrayList<>()).add(method); } if (declarations.isEmpty()) { - throw new IllegalArgumentException("RPC contract must declare at least one operation"); + throw new IllegalArgumentException("RPC contract must declare at least one usable operation"); } - Map operations = new HashMap<>(); + Map operations = new LinkedHashMap<>(); declarations.forEach((id, methods) -> operations.put(id, selectOperation(methods))); for (Method method : type.getMethods()) { - if (method.isBridge() && !operations.containsKey(operationId(method))) { + if (method.isBridge() && !operations.containsKey(operationId(method)) && !skipped.contains(operationId(method))) { throw new IllegalArgumentException("RPC contract has an unresolved generic bridge operation"); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java similarity index 98% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java index 3b51674..664f06d 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java @@ -203,7 +203,10 @@ private T proxy( default -> throw new IllegalStateException("Unsupported proxy object method"); }; } - Method operation = Objects.requireNonNull(contract.operation(MessagingRpcContract.operationId(method))); + Method operation = contract.operation(MessagingRpcContract.operationId(method)); + if (operation == null) { + throw new IllegalArgumentException("Unsupported RPC operation: " + method.toGenericString()); + } return invoke(selected, operation, arguments == null ? new Object[0] : arguments, bindingCodec, timeout); }); return type.cast(proxy); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcException.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcException.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcException.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcException.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetails.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetails.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetails.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetails.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcTransport.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcTransport.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcTransport.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcTransport.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/DDMessagingApiTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApiTest.java similarity index 76% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/DDMessagingApiTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApiTest.java index 2ce3711..a4a13bd 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/DDMessagingApiTest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApiTest.java @@ -14,49 +14,49 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api; +package com.dbeaver.datadam.dam.remote.api; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -class DDMessagingApiTest { +class DDRemoteDamApiTest { @Test void acceptsSubscriberIdsWithSubscriptionMetadataCharactersAndLengthBoundaries() { for (String subscriberId : new String[] {"s", "service:account", "service account", "s".repeat(129), "s".repeat(256)}) { - Assertions.assertTrue(DDMessagingApi.isValidSubscriberId(subscriberId)); + Assertions.assertTrue(DDRemoteDamApi.isValidSubscriberId(subscriberId)); } } @Test void rejectsMissingBlankAndOversizedSubscriberIds() { for (String subscriberId : new String[] {null, "", " ", "\t\n", "s".repeat(257)}) { - Assertions.assertFalse(DDMessagingApi.isValidSubscriberId(subscriberId)); + Assertions.assertFalse(DDRemoteDamApi.isValidSubscriberId(subscriberId)); } } @Test void acceptsPrintableAsciiSecretsAtBothLengthBoundaries() { - Assertions.assertTrue(DDMessagingApi.isValidServiceSecret("!".repeat(32))); - Assertions.assertTrue(DDMessagingApi.isValidServiceSecret("~".repeat(512))); + Assertions.assertTrue(DDRemoteDamApi.isValidServiceSecret("!".repeat(32))); + Assertions.assertTrue(DDRemoteDamApi.isValidServiceSecret("~".repeat(512))); StringBuilder printable = new StringBuilder(); for (char value = 33; value <= 126; value++) { printable.append(value); } - Assertions.assertTrue(DDMessagingApi.isValidServiceSecret(printable.toString())); + Assertions.assertTrue(DDRemoteDamApi.isValidServiceSecret(printable.toString())); } @Test void rejectsMissingShortAndOversizedSecrets() { - Assertions.assertFalse(DDMessagingApi.isValidServiceSecret(null)); - Assertions.assertFalse(DDMessagingApi.isValidServiceSecret("")); - Assertions.assertFalse(DDMessagingApi.isValidServiceSecret("x".repeat(31))); - Assertions.assertFalse(DDMessagingApi.isValidServiceSecret("x".repeat(513))); + Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret(null)); + Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret("")); + Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret("x".repeat(31))); + Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret("x".repeat(513))); } @Test void rejectsSpacesControlCharactersAndNonAsciiSecrets() { for (String invalid : new String[] {" ", "\t", "\r", "\n", "\0", "\u007f", "\u0080", "é"}) { - Assertions.assertFalse(DDMessagingApi.isValidServiceSecret("x".repeat(31) + invalid)); + Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret("x".repeat(31) + invalid)); } } @@ -66,7 +66,7 @@ void acceptsHttpAndHttpsServiceOrigins() { "http://localhost", "http://localhost/", "https://example.com", "HTTPS://example.com/", "http://127.0.0.1:1", "https://example.com:65535/", "http://[::1]", "https://[::1]:8443/" }) { - Assertions.assertTrue(DDMessagingApi.isValidServiceUrl(url)); + Assertions.assertTrue(DDRemoteDamApi.isValidServiceUrl(url)); } } @@ -80,14 +80,14 @@ void rejectsMalformedServiceOrigins() { "https://example.com/%2f", "https://example.com:0", "https://example.com:65536", "https://example.com:-1", "https://example.com:port", "http://[::1", "http://example.com/\n" }) { - Assertions.assertFalse(DDMessagingApi.isValidServiceUrl(url)); + Assertions.assertFalse(DDRemoteDamApi.isValidServiceUrl(url)); } } @Test void boundsServiceUrlLength() { String prefix = "https://"; - Assertions.assertTrue(DDMessagingApi.isValidServiceUrl(prefix + "a".repeat(4096 - prefix.length()))); - Assertions.assertFalse(DDMessagingApi.isValidServiceUrl(prefix + "a".repeat(4097 - prefix.length()))); + Assertions.assertTrue(DDRemoteDamApi.isValidServiceUrl(prefix + "a".repeat(4096 - prefix.length()))); + Assertions.assertFalse(DDRemoteDamApi.isValidServiceUrl(prefix + "a".repeat(4097 - prefix.length()))); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/DDMessagingProtocolTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocolTest.java similarity index 62% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/DDMessagingProtocolTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocolTest.java index 4407a86..8c3118c 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/DDMessagingProtocolTest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocolTest.java @@ -14,11 +14,11 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api; +package com.dbeaver.datadam.dam.remote.api; -import com.dbeaver.datadam.messaging.api.model.DDMessagingHandshakeRequest; -import com.dbeaver.datadam.messaging.api.model.DDMessagingHandshakeResponse; -import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamHandshakeRequest; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamHandshakeResponse; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import org.junit.jupiter.api.Test; @@ -29,27 +29,27 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; -class DDMessagingProtocolTest { +class DDRemoteDamProtocolTest { @Test void roundTripsApplicationMessagesWithTopicTypeAndPayload() { JsonObject payload = new JsonObject(); payload.addProperty("projectId", "project-1"); - DDMessagingMessage message = new DDMessagingMessage("projects", "project.updated", payload); - String encoded = DDMessagingProtocol.encode(message); + DDRemoteDamMessage message = new DDRemoteDamMessage("projects", "project.updated", payload); + String encoded = DDRemoteDamProtocol.encode(message); assertEquals(JsonParser.parseString(""" {"topic":"projects","type":"project.updated","payload":{"projectId":"project-1"}} """), JsonParser.parseString(encoded)); - assertEquals(message, DDMessagingProtocol.decode(encoded)); + assertEquals(message, DDRemoteDamProtocol.decode(encoded)); } @Test void roundTripsHandshakeRequestWithoutAccountCredentialsOrProtocolVersion() { - DDMessagingHandshakeRequest hello = new DDMessagingHandshakeRequest("DI_desktop", "run-1", "DBeaver", "26.2.4"); - String encoded = DDMessagingProtocol.encodeHandshakeRequest(hello); + DDRemoteDamHandshakeRequest hello = new DDRemoteDamHandshakeRequest("DI_remoteDam", "run-1", "DBeaver", "26.2.4"); + String encoded = DDRemoteDamProtocol.encodeHandshakeRequest(hello); JsonObject envelope = JsonParser.parseString(encoded).getAsJsonObject(); - assertEquals(hello, DDMessagingProtocol.decodeHandshakeRequest(encoded)); + assertEquals(hello, DDRemoteDamProtocol.decodeHandshakeRequest(encoded)); assertEquals("hello", envelope.get("type").getAsString()); assertEquals(2, envelope.size()); assertEquals(4, envelope.getAsJsonObject("payload").size()); @@ -59,62 +59,62 @@ void roundTripsHandshakeRequestWithoutAccountCredentialsOrProtocolVersion() { } @Test - void roundTripsHandshakeResponseWithOnlyTheAssignedConnectionId() { - DDMessagingHandshakeResponse ready = new DDMessagingHandshakeResponse("connection-1"); - String encoded = DDMessagingProtocol.encodeHandshakeResponse(ready); + void roundTripsHandshakeResponseWithOnlyTheAssignedSessionId() { + DDRemoteDamHandshakeResponse ready = new DDRemoteDamHandshakeResponse("session-1"); + String encoded = DDRemoteDamProtocol.encodeHandshakeResponse(ready); JsonObject envelope = JsonParser.parseString(encoded).getAsJsonObject(); - assertEquals(ready, DDMessagingProtocol.decodeHandshakeResponse(encoded)); + assertEquals(ready, DDRemoteDamProtocol.decodeHandshakeResponse(encoded)); assertEquals("ready", envelope.get("type").getAsString()); assertEquals(1, envelope.getAsJsonObject("payload").size()); } @Test void keepsControlMessagesSeparateFromTopicMessages() { - String hello = DDMessagingProtocol.encodeHandshakeRequest( - new DDMessagingHandshakeRequest("DI_desktop", "run-1", "DBeaver", "26.2.4")); - String ready = DDMessagingProtocol.encodeHandshakeResponse(new DDMessagingHandshakeResponse("connection-1")); - - assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decode(hello)); - assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decode(ready)); - assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeRequest(ready)); - assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeResponse(hello)); + String hello = DDRemoteDamProtocol.encodeHandshakeRequest( + new DDRemoteDamHandshakeRequest("DI_remoteDam", "run-1", "DBeaver", "26.2.4")); + String ready = DDRemoteDamProtocol.encodeHandshakeResponse(new DDRemoteDamHandshakeResponse("session-1")); + + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(hello)); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(ready)); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(ready)); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeResponse(hello)); String event = """ {"topic":"projects","type":"project.updated","payload":{}} """; - assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeRequest(event)); - assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeResponse(event)); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(event)); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeResponse(event)); } @Test void rejectsControlMessagesWithAnyTopicField() { for (String control : List.of( - DDMessagingProtocol.encodeHandshakeRequest(new DDMessagingHandshakeRequest("DI_desktop", "run-1", "DBeaver", "26.2.4")), - DDMessagingProtocol.encodeHandshakeResponse(new DDMessagingHandshakeResponse("connection-1")) + DDRemoteDamProtocol.encodeHandshakeRequest(new DDRemoteDamHandshakeRequest("DI_remoteDam", "run-1", "DBeaver", "26.2.4")), + DDRemoteDamProtocol.encodeHandshakeResponse(new DDRemoteDamHandshakeResponse("session-1")) )) { for (String topic : List.of("\"projects\"", "null", "\"\"", "\" \"", "42", "true", "[]", "{}")) { JsonObject envelope = JsonParser.parseString(control).getAsJsonObject(); envelope.add("topic", JsonParser.parseString(topic)); String message = envelope.toString(); - assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeRequest(message)); - assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeResponse(message)); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(message)); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeResponse(message)); } } } @Test void allowsControlTypeNamesInApplicationMessages() { - for (String type : List.of(DDMessagingProtocol.TYPE_HANDSHAKE_REQUEST, DDMessagingProtocol.TYPE_HANDSHAKE_RESPONSE)) { - DDMessagingMessage message = new DDMessagingMessage("projects", type, new JsonObject()); + for (String type : List.of(DDRemoteDamProtocol.TYPE_HANDSHAKE_REQUEST, DDRemoteDamProtocol.TYPE_HANDSHAKE_RESPONSE)) { + DDRemoteDamMessage message = new DDRemoteDamMessage("projects", type, new JsonObject()); - assertEquals(message, DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); + assertEquals(message, DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); } } @Test void preservesUnknownApplicationTypesAndAdditionalEnvelopeFields() { - DDMessagingMessage message = DDMessagingProtocol.decode(""" + DDRemoteDamMessage message = DDRemoteDamProtocol.decode(""" {"topic":"future","type":"future.event","payload":{"value":42},"additional":true} """); @@ -131,7 +131,7 @@ void rejectsMalformedEnvelopesAndMissingFields() { "{\"topic\":\"projects\",\"payload\":{}}", "{\"type\":\"updated\",\"payload\":{}}" )) { - assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decode(message), message); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(message), message); } } @@ -144,7 +144,7 @@ void rejectsNonStringAndBlankTopicAndTypeFields() { """).getAsJsonObject(); envelope.add(field, JsonParser.parseString(value)); - assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decode(envelope.toString())); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(envelope.toString())); } } } @@ -154,34 +154,34 @@ void rejectsNonObjectPayloads() { for (String payload : List.of("null", "true", "42", "[]", "\"value\"")) { String message = "{\"topic\":\"projects\",\"type\":\"updated\",\"payload\":" + payload + "}"; - assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decode(message)); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(message)); } } @Test void rejectsMissingAndIncorrectlyTypedHandshakeRequestFields() { - String valid = DDMessagingProtocol.encodeHandshakeRequest( - new DDMessagingHandshakeRequest("DI_desktop", "run-1", "DBeaver", "26.2.4")); - for (String field : List.of("deploymentId", "applicationRunId", "product", "version")) { + String valid = DDRemoteDamProtocol.encodeHandshakeRequest( + new DDRemoteDamHandshakeRequest("DI_remoteDam", "run-1", "DBeaver", "26.2.4")); + for (String field : List.of("appDeploymentId", "appInstanceId", "product", "version")) { JsonObject missing = JsonParser.parseString(valid).getAsJsonObject(); missing.getAsJsonObject("payload").remove(field); - assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeRequest(missing.toString())); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(missing.toString())); for (String value : List.of("null", "true", "42", "[]", "{}", "\" \"")) { JsonObject invalid = JsonParser.parseString(valid).getAsJsonObject(); invalid.getAsJsonObject("payload").add(field, JsonParser.parseString(value)); - assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeRequest(invalid.toString())); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(invalid.toString())); } } } @Test - void rejectsHandshakeResponseWithoutAStringConnectionId() { - for (String payload : List.of("{}", "{\"connectionId\":null}", "{\"connectionId\":42}", "{\"connectionId\":\" \"}")) { + void rejectsHandshakeResponseWithoutAStringSessionId() { + for (String payload : List.of("{}", "{\"damSessionId\":null}", "{\"damSessionId\":42}", "{\"damSessionId\":\" \"}")) { String message = "{\"type\":\"ready\",\"payload\":" + payload + "}"; - assertThrows(IllegalArgumentException.class, () -> DDMessagingProtocol.decodeHandshakeResponse(message)); + assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeResponse(message)); } } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClientTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClientTest.java similarity index 86% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClientTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClientTest.java index 9b93ef6..fbde511 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingCallbackClientTest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClientTest.java @@ -14,12 +14,12 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.client; +package com.dbeaver.datadam.dam.remote.api.client; -import com.dbeaver.datadam.messaging.api.DDMessagingApi; -import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; -import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; -import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; +import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi; +import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.sun.net.httpserver.HttpExchange; @@ -59,12 +59,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @Timeout(10) -class DDMessagingCallbackClientTest { +class DDRemoteDamCallbackClientTest { private static final String SECRET = "external-dam-callback-secret-2026-test"; private static final UUID SUBSCRIPTION_ID = UUID.randomUUID(); private static final UUID ACCOUNT_ID = UUID.randomUUID(); - private static final DDDamConnectionInfo CONNECTION = new DDDamConnectionInfo("deployment", "run", "socket"); - private static final DDMessagingMessage MESSAGE = new DDMessagingMessage("projects", "updated", + private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("deployment", "run", "socket"); + private static final DDRemoteDamMessage MESSAGE = new DDRemoteDamMessage("projects", "updated", JsonParser.parseString(""" {"nested":{"value":42},"nothing":null,"integer":9007199254740993, "decimal":1.2345678901234567890123456789,"text":"Привет"} @@ -77,14 +77,14 @@ class DDMessagingCallbackClientTest { private final CountDownLatch requestReceived = new CountDownLatch(1); private final AtomicReference responseGate = new AtomicReference<>(); private HttpServer server; - private DDMessagingCallbackClient client; + private DDRemoteDamCallbackClient client; @BeforeEach void start() throws IOException { server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/", this::respond); server.start(); - client = new DDMessagingCallbackClient(url(), SECRET); + client = new DDRemoteDamCallbackClient(url(), SECRET); } @AfterEach @@ -95,7 +95,7 @@ void stop() { @Test void deliversTheExactCallbackWireWithIsolatedCredentialsAndAnOpaquePayload() throws Exception { for (String suffix : List.of("", "/")) { - client = new DDMessagingCallbackClient(url() + suffix, SECRET); + client = new DDRemoteDamCallbackClient(url() + suffix, SECRET); deliver(); CapturedRequest synchronous = request.get(); assertNull(deliverAsync().toCompletableFuture().get(5, TimeUnit.SECONDS)); @@ -109,16 +109,16 @@ void deliversTheExactCallbackWireWithIsolatedCredentialsAndAnOpaquePayload() thr JsonObject body = captured.body(); assertEquals("DeliverExternalDamMessage", body.get("operationName").getAsString()); String query = body.get("query").getAsString().replaceAll("\\s+", " "); - assertTrue(query.contains("$connection: ExternalDamConnectionInfoInput!")); + assertTrue(query.contains("$session: ExternalDamSessionInfoInput!")); assertTrue(query.contains("$message: ExternalDamMessageInput!")); assertTrue(query.contains("deliverExternalDamMessage(subscriptionId: $subscriptionId, accountId: $accountId, " - + "connection: $connection, message: $message)")); + + "session: $session, message: $message)")); JsonObject variables = body.getAsJsonObject("variables"); - assertEquals(Set.of("subscriptionId", "accountId", "connection", "message"), variables.keySet()); + assertEquals(Set.of("subscriptionId", "accountId", "session", "message"), variables.keySet()); assertEquals(SUBSCRIPTION_ID.toString(), variables.get("subscriptionId").getAsString()); assertEquals(ACCOUNT_ID.toString(), variables.get("accountId").getAsString()); - assertEquals(Set.of("deploymentId", "applicationRunId", "connectionId"), variables.getAsJsonObject("connection").keySet()); - assertEquals("socket", variables.getAsJsonObject("connection").get("connectionId").getAsString()); + assertEquals(Set.of("appDeploymentId", "appInstanceId", "damSessionId"), variables.getAsJsonObject("session").keySet()); + assertEquals("socket", variables.getAsJsonObject("session").get("damSessionId").getAsString()); assertEquals(MESSAGE.payload(), variables.getAsJsonObject("message").get("payload")); JsonObject payload = variables.getAsJsonObject("message").getAsJsonObject("payload"); assertEquals("9007199254740993", payload.get("integer").getAsString()); @@ -174,7 +174,7 @@ void allowsDefaultAsyncDeliveryToWaitLongerThanFiveSecondsForTheHandler() throws void rejectsFalseAcknowledgementsAsProtocolFailures() { response.set("{\"data\":{\"deliverExternalDamMessage\":false}}"); assertFailureOnce(); - DDMessagingException failure = assertAsyncFailureOnce(); + DDRemoteDamException failure = assertAsyncFailureOnce(); assertEquals("External DAM callback was not accepted", failure.getMessage()); } @@ -195,7 +195,7 @@ void rejectsHttpFailuresAndNeverFollowsRedirects() { for (int code : List.of(301, 302, 303, 307, 308, 400, 401, 403, 429, 500, 503)) { status.set(code); assertFailureOnce(); - DDMessagingException failure = assertAsyncFailureOnce(); + DDRemoteDamException failure = assertAsyncFailureOnce(); assertEquals("External DAM request failed: HTTP " + code, failure.getMessage()); } } @@ -203,16 +203,16 @@ void rejectsHttpFailuresAndNeverFollowsRedirects() { @Test void boundsDeliveryTimeAndDoesNotRetryTimedOutMessages() { responseDelay.set(1000); - client = new DDMessagingCallbackClient(url(), SECRET, Duration.ofMillis(250)); + client = new DDRemoteDamCallbackClient(url(), SECRET, Duration.ofMillis(250)); assertFailureOnce(); } @Test void preservesTheExplicitAsyncDeliveryTimeoutAndItsNetworkCause() { responseDelay.set(1000); - client = new DDMessagingCallbackClient(url(), SECRET, Duration.ofMillis(250)); + client = new DDRemoteDamCallbackClient(url(), SECRET, Duration.ofMillis(250)); - DDMessagingException failure = assertAsyncFailureOnce(); + DDRemoteDamException failure = assertAsyncFailureOnce(); assertEquals("External DAM service request failed", failure.getMessage()); assertInstanceOf(HttpTimeoutException.class, failure.getCause()); @@ -230,10 +230,10 @@ void cancelsTheUnderlyingBodyReadWhenTheCallerCancelsDelivery() throws Exception @Test void rejectsNullAsyncDeliveryArgumentsBeforeNetworkIo() { - assertThrows(NullPointerException.class, () -> client.deliverAsync(null, ACCOUNT_ID, CONNECTION, MESSAGE)); - assertThrows(NullPointerException.class, () -> client.deliverAsync(SUBSCRIPTION_ID, null, CONNECTION, MESSAGE)); + assertThrows(NullPointerException.class, () -> client.deliverAsync(null, ACCOUNT_ID, SESSION, MESSAGE)); + assertThrows(NullPointerException.class, () -> client.deliverAsync(SUBSCRIPTION_ID, null, SESSION, MESSAGE)); assertThrows(NullPointerException.class, () -> client.deliverAsync(SUBSCRIPTION_ID, ACCOUNT_ID, null, MESSAGE)); - assertThrows(NullPointerException.class, () -> client.deliverAsync(SUBSCRIPTION_ID, ACCOUNT_ID, CONNECTION, null)); + assertThrows(NullPointerException.class, () -> client.deliverAsync(SUBSCRIPTION_ID, ACCOUNT_ID, SESSION, null)); assertEquals(0, requestCount.get()); } @@ -241,38 +241,38 @@ void rejectsNullAsyncDeliveryArgumentsBeforeNetworkIo() { void rejectsUntrustedOriginFormsAndInvalidSecretsBeforeNetworkIo() { for (String origin : List.of("", "/relative", url() + "/prefix", url() + "?query=1", url() + "#fragment", "http://user:password@127.0.0.1", "ftp://127.0.0.1", "http://127.0.0.1:65536")) { - assertThrows(IllegalArgumentException.class, () -> new DDMessagingCallbackClient(origin, SECRET)); + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamCallbackClient(origin, SECRET)); } for (String secret : List.of("", "short", " ".repeat(32), "x".repeat(31) + "\n")) { - assertThrows(IllegalArgumentException.class, () -> new DDMessagingCallbackClient(url(), secret)); + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamCallbackClient(url(), secret)); } - assertThrows(IllegalArgumentException.class, () -> new DDMessagingCallbackClient(url(), SECRET, Duration.ZERO)); + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamCallbackClient(url(), SECRET, Duration.ZERO)); assertEquals(0, requestCount.get()); } - private void deliver() throws DDMessagingException { - client.deliver(SUBSCRIPTION_ID, ACCOUNT_ID, CONNECTION, MESSAGE); + private void deliver() throws DDRemoteDamException { + client.deliver(SUBSCRIPTION_ID, ACCOUNT_ID, SESSION, MESSAGE); } @NotNull private CompletionStage deliverAsync() { - return client.deliverAsync(SUBSCRIPTION_ID, ACCOUNT_ID, CONNECTION, MESSAGE); + return client.deliverAsync(SUBSCRIPTION_ID, ACCOUNT_ID, SESSION, MESSAGE); } private void assertFailureOnce() { int before = requestCount.get(); - DDMessagingException failure = assertThrows(DDMessagingException.class, this::deliver); + DDRemoteDamException failure = assertThrows(DDRemoteDamException.class, this::deliver); assertFalse(failure.toString().contains(SECRET)); assertEquals(before + 1, requestCount.get()); } @NotNull - private DDMessagingException assertAsyncFailureOnce() { + private DDRemoteDamException assertAsyncFailureOnce() { int before = requestCount.get(); CompletionStage stage = deliverAsync(); ExecutionException completion = assertThrows(ExecutionException.class, () -> stage.toCompletableFuture().get(5, TimeUnit.SECONDS)); - DDMessagingException failure = assertInstanceOf(DDMessagingException.class, completion.getCause()); + DDRemoteDamException failure = assertInstanceOf(DDRemoteDamException.class, completion.getCause()); StringWriter trace = new StringWriter(); failure.printStackTrace(new PrintWriter(trace)); assertFalse(trace.toString().contains(SECRET)); @@ -285,7 +285,7 @@ private void assertStalledBodyIsCancelled(boolean cancelDelivery) throws Excepti CountDownLatch resumeBody = new CountDownLatch(1); CountDownLatch probeFinished = new CountDownLatch(1); AtomicReference bodyFailure = new AtomicReference<>(); - server.createContext(DDMessagingApi.CALLBACK_GRAPHQL_PATH, exchange -> { + server.createContext(DDRemoteDamApi.CALLBACK_GRAPHQL_PATH, exchange -> { try { exchange.getRequestBody().readAllBytes(); requestCount.incrementAndGet(); @@ -312,7 +312,7 @@ private void assertStalledBodyIsCancelled(boolean cancelDelivery) throws Excepti probeFinished.countDown(); } }); - client = new DDMessagingCallbackClient(url(), SECRET, + client = new DDRemoteDamCallbackClient(url(), SECRET, cancelDelivery ? Duration.ofSeconds(5) : Duration.ofSeconds(1)); CompletableFuture delivery = deliverAsync().toCompletableFuture(); try { @@ -324,7 +324,7 @@ private void assertStalledBodyIsCancelled(boolean cancelDelivery) throws Excepti } else { ExecutionException completion = assertThrows(ExecutionException.class, () -> delivery.get(3, TimeUnit.SECONDS)); - DDMessagingException failure = assertInstanceOf(DDMessagingException.class, completion.getCause()); + DDRemoteDamException failure = assertInstanceOf(DDRemoteDamException.class, completion.getCause()); assertEquals("External DAM service request failed", failure.getMessage()); assertInstanceOf(HttpTimeoutException.class, failure.getCause()); StringWriter trace = new StringWriter(); @@ -353,7 +353,7 @@ private String url() { private void respond(@NotNull HttpExchange exchange) throws IOException { try { request.set(new CapturedRequest(exchange.getRequestMethod(), exchange.getRequestURI().toString(), - exchange.getRequestHeaders().getFirst(DDMessagingApi.HEADER_SERVICE_TOKEN), + exchange.getRequestHeaders().getFirst(DDRemoteDamApi.HEADER_SERVICE_TOKEN), exchange.getRequestHeaders().getFirst("Content-Type"), exchange.getRequestHeaders().getFirst("Cache-Control"), JsonParser.parseString(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject())); requestCount.incrementAndGet(); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingClientTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClientTest.java similarity index 76% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingClientTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClientTest.java index bd541ce..b4b1c6f 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/client/DDMessagingClientTest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClientTest.java @@ -14,12 +14,12 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.client; +package com.dbeaver.datadam.dam.remote.api.client; -import com.dbeaver.datadam.messaging.api.DDMessagingApi; -import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; -import com.dbeaver.datadam.messaging.api.model.DDMessagingConnection; -import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; +import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi; +import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSession; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonNull; @@ -65,15 +65,15 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @Timeout(30) -class DDMessagingClientTest { +class DDRemoteDamClientTest { private static final UUID ACCOUNT_ID = UUID.fromString("58c487c0-de34-4b45-83bb-705cb733cbcd"); private static final UUID OTHER_ACCOUNT_ID = UUID.fromString("678d5732-708f-441e-bc59-719b92af1d64"); private static final String SERVICE_SECRET = "external-dam-service-secret-2026-test"; private static final String CALLBACK_URL = "https://callback.example.com"; private static final String CALLBACK_SECRET = "external-dam-callback-secret-2026-test"; private static final String UPSTREAM_MARKER = "private-upstream-response-marker"; - private static final String CONNECTIONS_FIELD = "externalDamConnections"; - private static final String CONNECTION_ACCOUNT_FIELD = "externalDamConnectionAccount"; + private static final String SESSIONS_FIELD = "externalDamSessions"; + private static final String SESSION_ACCOUNT_FIELD = "externalDamSessionAccount"; private static final String SEND_FIELD = "sendExternalDamMessage"; private final AtomicReference responseJson = new AtomicReference<>("{}"); private final AtomicInteger responseStatus = new AtomicInteger(200); @@ -85,14 +85,14 @@ class DDMessagingClientTest { private final AtomicReference requestReceived = new AtomicReference<>(); private final AtomicReference responseGate = new AtomicReference<>(); private HttpServer server; - private DDMessagingClient client; + private DDRemoteDamClient client; @BeforeEach void setUp() throws IOException { server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/", this::handleRequest); server.start(); - client = new DDMessagingClient(serviceUrl(), SERVICE_SECRET); + client = new DDRemoteDamClient(serviceUrl(), SERVICE_SECRET); } @AfterEach @@ -104,20 +104,20 @@ void stop() { } @Test - void postsConnectionQueriesToTheInternalEndpointWithEitherRootUrlForm() throws DDMessagingException { - respond(CONNECTIONS_FIELD, new JsonArray()); + void postsSessionQueriesToTheInternalEndpointWithEitherRootUrlForm() throws DDRemoteDamException { + respond(SESSIONS_FIELD, new JsonArray()); for (String suffix : List.of("", "/")) { - var configured = new DDMessagingClient(serviceUrl() + suffix, SERVICE_SECRET); + var configured = new DDRemoteDamClient(serviceUrl() + suffix, SERVICE_SECRET); - assertEquals(List.of(), configured.getConnections(ACCOUNT_ID)); + assertEquals(List.of(), configured.getSessions(ACCOUNT_ID)); JsonObject body = posted(); assertEquals(Set.of("operationName", "query", "variables"), body.keySet()); - assertEquals("ExternalDamConnections", body.get("operationName").getAsString()); + assertEquals("ExternalDamSessions", body.get("operationName").getAsString()); String query = body.get("query").getAsString().replaceAll("\\s+", " "); - assertTrue(query.contains("query ExternalDamConnections($accountId: ID!)")); - assertTrue(query.contains("externalDamConnections(accountId: $accountId)")); - for (String field : List.of("deploymentId", "applicationRunId", "connectionId", "product", "version")) { + assertTrue(query.contains("query ExternalDamSessions($accountId: ID!)")); + assertTrue(query.contains("externalDamSessions(accountId: $accountId)")); + for (String field : List.of("appDeploymentId", "appInstanceId", "damSessionId", "product", "version")) { assertTrue(query.contains(field), field); } assertEquals(Set.of("accountId"), body.getAsJsonObject("variables").keySet()); @@ -129,27 +129,27 @@ void postsConnectionQueriesToTheInternalEndpointWithEitherRootUrlForm() throws D @Test void returnsAllAsyncOperationsBeforeDelayedResponsesAndParsesTheirResults() throws Exception { - JsonArray connections = new JsonArray(); - connections.add(connection("connection-1")); - respond(CONNECTIONS_FIELD, connections); + JsonArray sessions = new JsonArray(); + sessions.add(session("session-1")); + respond(SESSIONS_FIELD, sessions); - List result = awaitDelayedResponse(() -> client.getConnectionsAsync(ACCOUNT_ID)); + List result = awaitDelayedResponse(() -> client.getSessionsAsync(ACCOUNT_ID)); - assertEquals(List.of(new DDMessagingConnection("DI_desktop", "run-1", "connection-1", "DBeaver", "26.2.4")), result); + assertEquals(List.of(new DDRemoteDamSession("DI_remoteDam", "run-1", "session-1", "DBeaver", "26.2.4")), result); assertThrows(UnsupportedOperationException.class, () -> result.add(result.get(0))); - assertEquals("ExternalDamConnections", posted().get("operationName").getAsString()); + assertEquals("ExternalDamSessions", posted().get("operationName").getAsString()); assertEquals(ACCOUNT_ID.toString(), posted().getAsJsonObject("variables").get("accountId").getAsString()); - respond(CONNECTION_ACCOUNT_FIELD, JsonParser.parseString("\"" + ACCOUNT_ID + "\"")); - assertEquals(ACCOUNT_ID, awaitDelayedResponse(() -> client.getConnectionAccountAsync("connection-1"))); - assertEquals("ExternalDamConnectionAccount", posted().get("operationName").getAsString()); - assertEquals("connection-1", posted().getAsJsonObject("variables").get("connectionId").getAsString()); + respond(SESSION_ACCOUNT_FIELD, JsonParser.parseString("\"" + ACCOUNT_ID + "\"")); + assertEquals(ACCOUNT_ID, awaitDelayedResponse(() -> client.getSessionAccountAsync("session-1"))); + assertEquals("ExternalDamSessionAccount", posted().get("operationName").getAsString()); + assertEquals("session-1", posted().getAsJsonObject("variables").get("damSessionId").getAsString()); respond(SEND_FIELD, JsonParser.parseString("false")); - assertFalse(awaitDelayedResponse(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message()))); + assertFalse(awaitDelayedResponse(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message()))); assertEquals("SendExternalDamMessage", posted().get("operationName").getAsString()); assertEquals(ACCOUNT_ID.toString(), posted().getAsJsonObject("variables").get("accountId").getAsString()); - assertEquals("connection-1", posted().getAsJsonObject("variables").get("connectionId").getAsString()); + assertEquals("session-1", posted().getAsJsonObject("variables").get("damSessionId").getAsString()); UUID subscriptionId = UUID.randomUUID(); respond("registerExternalDamSubscription", JsonParser.parseString("60")); @@ -163,7 +163,7 @@ void returnsAllAsyncOperationsBeforeDelayedResponsesAndParsesTheirResults() thro assertEquals("projects", input.get("topic").getAsString()); assertEquals(CALLBACK_URL, input.get("callbackUrl").getAsString()); assertEquals(CALLBACK_SECRET, input.get("callbackSecret").getAsString()); - assertTrue(!input.has("connectionId") || input.get("connectionId").isJsonNull()); + assertTrue(!input.has("damSessionId") || input.get("damSessionId").isJsonNull()); respond("unregisterExternalDamSubscription", JsonParser.parseString("false")); assertFalse(awaitDelayedResponse(() -> client.unregisterSubscriptionAsync("service account", subscriptionId))); @@ -180,13 +180,13 @@ void sendsAsyncMessagesWithExactJsonNumbersNullsAndUtf8() throws Exception { {"integer":9007199254740993,"decimal":1.2345678901234567890123456789, "nothing":null,"nested":[null,{"text":"Привет"}]} """).getAsJsonObject(); - DDMessagingMessage message = new DDMessagingMessage("topic-\"\\\n", "type-\"\\\n", payload); + DDRemoteDamMessage message = new DDRemoteDamMessage("topic-\"\\\n", "type-\"\\\n", payload); respond(SEND_FIELD, JsonParser.parseString("true")); - assertTrue(client.sendMessageAsync(ACCOUNT_ID, "connection-\"\\\n", message).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertTrue(client.sendMessageAsync(ACCOUNT_ID, "session-\"\\\n", message).toCompletableFuture().get(5, TimeUnit.SECONDS)); JsonObject variables = posted().getAsJsonObject("variables"); - assertEquals("connection-\"\\\n", variables.get("connectionId").getAsString()); + assertEquals("session-\"\\\n", variables.get("damSessionId").getAsString()); JsonObject sent = variables.getAsJsonObject("message"); assertEquals(message.topic(), sent.get("topic").getAsString()); assertEquals(message.type(), sent.get("type").getAsString()); @@ -197,96 +197,96 @@ void sendsAsyncMessagesWithExactJsonNumbersNullsAndUtf8() throws Exception { } @Test - void resolvesTheConnectionOwnerWithOneAuthenticatedReadAndNoDiscovery() throws DDMessagingException { - String connectionId = "connection-\"\\\n) { injectedConnection }"; + void resolvesTheSessionOwnerWithOneAuthenticatedReadAndNoDiscovery() throws DDRemoteDamException { + String damSessionId = "session-\"\\\n) { injectedSession }"; for (UUID accountId : List.of(ACCOUNT_ID, OTHER_ACCOUNT_ID)) { - respond(CONNECTION_ACCOUNT_FIELD, JsonParser.parseString("\"" + accountId + "\"")); + respond(SESSION_ACCOUNT_FIELD, JsonParser.parseString("\"" + accountId + "\"")); - assertEquals(accountId, client.getConnectionAccount(connectionId)); + assertEquals(accountId, client.getSessionAccount(damSessionId)); JsonObject body = posted(); - assertEquals("ExternalDamConnectionAccount", body.get("operationName").getAsString()); + assertEquals("ExternalDamSessionAccount", body.get("operationName").getAsString()); String query = body.get("query").getAsString().replaceAll("\\s+", " "); - assertTrue(query.contains("query ExternalDamConnectionAccount($connectionId: ID!)")); - assertTrue(query.contains("externalDamConnectionAccount(connectionId: $connectionId)")); - assertEquals(Set.of("connectionId"), body.getAsJsonObject("variables").keySet()); - assertEquals(connectionId, body.getAsJsonObject("variables").get("connectionId").getAsString()); - assertFalse(query.contains("injectedConnection")); + assertTrue(query.contains("query ExternalDamSessionAccount($damSessionId: ID!)")); + assertTrue(query.contains("externalDamSessionAccount(damSessionId: $damSessionId)")); + assertEquals(Set.of("damSessionId"), body.getAsJsonObject("variables").keySet()); + assertEquals(damSessionId, body.getAsJsonObject("variables").get("damSessionId").getAsString()); + assertFalse(query.contains("injectedSession")); } assertEquals(2, requestCount.get()); } @Test - void rejectsUnavailableNonStringAndMalformedConnectionOwnersWithoutRetrying() { + void rejectsUnavailableNonStringAndMalformedSessionOwnersWithoutRetrying() { for (String value : List.of("null", "true", "false", "42", "{}", "[]", "\"\"", "\" \\t\"", "\"not-a-uuid\"", "\"1-1-1-1-1\"", "\"" + ACCOUNT_ID + " \"", "\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\"")) { - respond(CONNECTION_ACCOUNT_FIELD, JsonParser.parseString(value)); + respond(SESSION_ACCOUNT_FIELD, JsonParser.parseString(value)); - assertRequestFails(() -> client.getConnectionAccount("connection-1")); - assertAsyncRequestFails(() -> client.getConnectionAccountAsync("connection-1")); + assertRequestFails(() -> client.getSessionAccount("session-1")); + assertAsyncRequestFails(() -> client.getSessionAccountAsync("session-1")); } responseJson.set("{\"data\":{}}"); - assertRequestFails(() -> client.getConnectionAccount("connection-1")); - assertAsyncRequestFails(() -> client.getConnectionAccountAsync("connection-1")); + assertRequestFails(() -> client.getSessionAccount("session-1")); + assertAsyncRequestFails(() -> client.getSessionAccountAsync("session-1")); } @Test - void rejectsBlankAndOversizedConnectionOwnerLookupIdsBeforeSendingAnyRequest() { - for (String connectionId : List.of("", " ", "\t\n", "s".repeat(257))) { - assertThrows(IllegalArgumentException.class, () -> client.getConnectionAccount(connectionId)); - assertThrows(IllegalArgumentException.class, () -> client.getConnectionAccountAsync(connectionId)); + void rejectsBlankAndOversizedSessionOwnerLookupIdsBeforeSendingAnyRequest() { + for (String damSessionId : List.of("", " ", "\t\n", "s".repeat(257))) { + assertThrows(IllegalArgumentException.class, () -> client.getSessionAccount(damSessionId)); + assertThrows(IllegalArgumentException.class, () -> client.getSessionAccountAsync(damSessionId)); } assertEquals(0, requestCount.get()); } @Test - void preservesDistinctConcurrentConnectionsAndReturnsAnImmutableSnapshot() throws DDMessagingException { - JsonArray connections = new JsonArray(); - connections.add(connection("connection-1")); - connections.add(connection("connection-2")); - respond(CONNECTIONS_FIELD, connections); + void preservesDistinctConcurrentSessionsAndReturnsAnImmutableSnapshot() throws DDRemoteDamException { + JsonArray sessions = new JsonArray(); + sessions.add(session("session-1")); + sessions.add(session("session-2")); + respond(SESSIONS_FIELD, sessions); - List result = client.getConnections(ACCOUNT_ID); + List result = client.getSessions(ACCOUNT_ID); assertEquals(List.of( - new DDMessagingConnection("DI_desktop", "run-1", "connection-1", "DBeaver", "26.2.4"), - new DDMessagingConnection("DI_desktop", "run-1", "connection-2", "DBeaver", "26.2.4") + new DDRemoteDamSession("DI_remoteDam", "run-1", "session-1", "DBeaver", "26.2.4"), + new DDRemoteDamSession("DI_remoteDam", "run-1", "session-2", "DBeaver", "26.2.4") ), result); assertThrows(UnsupportedOperationException.class, () -> result.add(result.get(0))); assertThrows(UnsupportedOperationException.class, () -> result.set(0, result.get(1))); assertThrows(UnsupportedOperationException.class, () -> result.remove(0)); - respond(CONNECTIONS_FIELD, new JsonArray()); - List empty = client.getConnections(ACCOUNT_ID); + respond(SESSIONS_FIELD, new JsonArray()); + List empty = client.getSessions(ACCOUNT_ID); assertEquals(List.of(), empty); assertThrows(UnsupportedOperationException.class, () -> empty.add(result.get(0))); assertEquals(2, result.size()); - assertEquals("connection-1", result.get(0).connectionId()); + assertEquals("session-1", result.get(0).damSessionId()); assertEquals(2, requestCount.get()); } @Test - void scopesBothOperationsToTheAccountOnEveryRequest() throws DDMessagingException { - String connectionsQuery = null; + void scopesBothOperationsToTheAccountOnEveryRequest() throws DDRemoteDamException { + String sessionsQuery = null; String sendQuery = null; for (UUID accountId : List.of(ACCOUNT_ID, OTHER_ACCOUNT_ID)) { - respond(CONNECTIONS_FIELD, new JsonArray()); - assertEquals(List.of(), client.getConnections(accountId)); - JsonObject connectionsBody = posted(); - assertEquals(accountId.toString(), connectionsBody.getAsJsonObject("variables").get("accountId").getAsString()); - String currentConnectionsQuery = connectionsBody.get("query").getAsString(); - if (connectionsQuery != null) { - assertEquals(connectionsQuery, currentConnectionsQuery); + respond(SESSIONS_FIELD, new JsonArray()); + assertEquals(List.of(), client.getSessions(accountId)); + JsonObject sessionsBody = posted(); + assertEquals(accountId.toString(), sessionsBody.getAsJsonObject("variables").get("accountId").getAsString()); + String currentSessionsQuery = sessionsBody.get("query").getAsString(); + if (sessionsQuery != null) { + assertEquals(sessionsQuery, currentSessionsQuery); } - connectionsQuery = currentConnectionsQuery; - assertFalse(connectionsQuery.contains(accountId.toString())); + sessionsQuery = currentSessionsQuery; + assertFalse(sessionsQuery.contains(accountId.toString())); respond(SEND_FIELD, JsonParser.parseString("true")); - assertTrue(client.sendMessage(accountId, "connection-1", message())); + assertTrue(client.sendMessage(accountId, "session-1", message())); JsonObject sendBody = posted(); assertEquals(accountId.toString(), sendBody.getAsJsonObject("variables").get("accountId").getAsString()); - assertEquals("connection-1", sendBody.getAsJsonObject("variables").get("connectionId").getAsString()); + assertEquals("session-1", sendBody.getAsJsonObject("variables").get("damSessionId").getAsString()); String currentSendQuery = sendBody.get("query").getAsString(); if (sendQuery != null) { assertEquals(sendQuery, currentSendQuery); @@ -298,7 +298,7 @@ void scopesBothOperationsToTheAccountOnEveryRequest() throws DDMessagingExceptio } @Test - void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDMessagingException { + void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDRemoteDamException { JsonObject nested = new JsonObject(); nested.addProperty("name", "quoted \"value\""); nested.addProperty("path", "C:\\temp\\file"); @@ -312,19 +312,19 @@ void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDM payload.addProperty("text", "Привет"); String topic = "topic-\"\\\n{ injectedTopic }"; String type = "type-\"\\\n{ injectedType }"; - DDMessagingMessage message = new DDMessagingMessage(topic, type, payload); + DDRemoteDamMessage message = new DDRemoteDamMessage(topic, type, payload); respond(SEND_FIELD, JsonParser.parseString("true")); - String connectionId = "connection-\"\\\n) { injectedConnection }"; - assertTrue(client.sendMessage(ACCOUNT_ID, connectionId, message)); + String damSessionId = "session-\"\\\n) { injectedSession }"; + assertTrue(client.sendMessage(ACCOUNT_ID, damSessionId, message)); JsonObject body = posted(); assertEquals(Set.of("operationName", "query", "variables"), body.keySet()); assertEquals("SendExternalDamMessage", body.get("operationName").getAsString()); JsonObject variables = body.getAsJsonObject("variables"); - assertEquals(Set.of("accountId", "connectionId", "message"), variables.keySet()); + assertEquals(Set.of("accountId", "damSessionId", "message"), variables.keySet()); assertEquals(ACCOUNT_ID.toString(), variables.get("accountId").getAsString()); - assertEquals(connectionId, variables.get("connectionId").getAsString()); + assertEquals(damSessionId, variables.get("damSessionId").getAsString()); JsonObject input = variables.getAsJsonObject("message"); assertEquals(Set.of("topic", "type", "payload"), input.keySet()); assertEquals(topic, input.get("topic").getAsString()); @@ -334,10 +334,10 @@ void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDM String query = body.get("query").getAsString().replaceAll("\\s+", " "); assertTrue(query.contains("mutation SendExternalDamMessage(")); assertTrue(query.contains("$accountId: ID!")); - assertTrue(query.contains("$connectionId: ID!")); + assertTrue(query.contains("$damSessionId: ID!")); assertTrue(query.contains("$message: ExternalDamMessageInput!")); - assertTrue(query.contains("sendExternalDamMessage(accountId: $accountId, connectionId: $connectionId, message: $message)")); - for (String value : List.of(ACCOUNT_ID.toString(), connectionId, topic, type, "injectedConnection", "injectedTopic", + assertTrue(query.contains("sendExternalDamMessage(accountId: $accountId, damSessionId: $damSessionId, message: $message)")); + for (String value : List.of(ACCOUNT_ID.toString(), damSessionId, topic, type, "injectedSession", "injectedTopic", "injectedType", "Привет", "C:\\temp\\file")) { assertFalse(query.contains(value), value); } @@ -345,12 +345,12 @@ void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDM } @Test - void returnsTheSendBooleanWithoutAdditionalRequests() throws DDMessagingException { + void returnsTheSendBooleanWithoutAdditionalRequests() throws DDRemoteDamException { for (boolean written : List.of(true, false)) { respond(SEND_FIELD, JsonParser.parseString(Boolean.toString(written))); int before = requestCount.get(); - assertEquals(written, client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertEquals(written, client.sendMessage(ACCOUNT_ID, "session-1", message())); assertEquals(before + 1, requestCount.get()); assertEquals("SendExternalDamMessage", posted().get("operationName").getAsString()); @@ -358,15 +358,15 @@ void returnsTheSendBooleanWithoutAdditionalRequests() throws DDMessagingExceptio } @Test - void registersDynamicCallbacksAsVariablesAndPreservesStableIdsAndOptionalConnectionScope() throws DDMessagingException { + void registersDynamicCallbacksAsVariablesAndPreservesStableIdsAndOptionalSessionScope() throws DDRemoteDamException { UUID subscriptionId = UUID.randomUUID(); - for (String connectionId : List.of("", "socket")) { - String callbackUrl = connectionId.isEmpty() ? CALLBACK_URL : "http://127.0.0.1:8080/"; - String callbackSecret = connectionId.isEmpty() ? CALLBACK_SECRET : CALLBACK_SECRET + "\"\\"; + for (String damSessionId : List.of("", "socket")) { + String callbackUrl = damSessionId.isEmpty() ? CALLBACK_URL : "http://127.0.0.1:8080/"; + String callbackSecret = damSessionId.isEmpty() ? CALLBACK_SECRET : CALLBACK_SECRET + "\"\\"; respond("registerExternalDamSubscription", JsonParser.parseString("60")); assertEquals(60, client.registerSubscription("account", subscriptionId, ACCOUNT_ID, - connectionId.isEmpty() ? null : connectionId, "projects", callbackUrl, callbackSecret)); + damSessionId.isEmpty() ? null : damSessionId, "projects", callbackUrl, callbackSecret)); JsonObject body = posted(); assertEquals(Set.of("operationName", "query", "variables"), body.keySet()); @@ -382,12 +382,12 @@ void registersDynamicCallbacksAsVariablesAndPreservesStableIdsAndOptionalConnect assertEquals("projects", input.get("topic").getAsString()); assertEquals(callbackUrl, input.get("callbackUrl").getAsString()); assertTrue(callbackSecret.equals(input.get("callbackSecret").getAsString())); - if (connectionId.isEmpty()) { - assertTrue(!input.has("connectionId") || input.get("connectionId").isJsonNull()); + if (damSessionId.isEmpty()) { + assertTrue(!input.has("damSessionId") || input.get("damSessionId").isJsonNull()); } else { - assertEquals(connectionId, input.get("connectionId").getAsString()); + assertEquals(damSessionId, input.get("damSessionId").getAsString()); } - assertEquals(input.has("connectionId") ? 7 : 6, input.size()); + assertEquals(input.has("damSessionId") ? 7 : 6, input.size()); assertFalse(input.has("url")); assertFalse(input.has("serviceSecret")); assertFalse(query.contains(subscriptionId.toString())); @@ -398,7 +398,7 @@ void registersDynamicCallbacksAsVariablesAndPreservesStableIdsAndOptionalConnect } @Test - void registersTheServiceSecretAsTheCallbackSecret() throws DDMessagingException { + void registersTheServiceSecretAsTheCallbackSecret() throws DDRemoteDamException { respond("registerExternalDamSubscription", JsonParser.parseString("60")); assertEquals(60, client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, @@ -406,7 +406,7 @@ void registersTheServiceSecretAsTheCallbackSecret() throws DDMessagingException CapturedRequest request = latestRequest.get(); assertNotNull(request); - assertEquals(SERVICE_SECRET, request.header(DDMessagingApi.HEADER_SERVICE_TOKEN)); + assertEquals(SERVICE_SECRET, request.header(DDRemoteDamApi.HEADER_SERVICE_TOKEN)); JsonObject body = JsonParser.parseString(request.body()).getAsJsonObject(); assertEquals(SERVICE_SECRET, body.getAsJsonObject("variables").getAsJsonObject("input") .get("callbackSecret").getAsString()); @@ -415,7 +415,7 @@ void registersTheServiceSecretAsTheCallbackSecret() throws DDMessagingException } @Test - void acceptsCallbackRootOriginsAndPrintableSecretBoundaries() throws DDMessagingException { + void acceptsCallbackRootOriginsAndPrintableSecretBoundaries() throws DDRemoteDamException { UUID subscriptionId = UUID.randomUUID(); respond("registerExternalDamSubscription", JsonParser.parseString("60")); for (String callbackUrl : List.of("http://127.0.0.1:8080", "https://callback.example.com/")) { @@ -432,7 +432,7 @@ void acceptsCallbackRootOriginsAndPrintableSecretBoundaries() throws DDMessaging } @Test - void preservesSubscriberIdsAcrossRegistrationAndCancellation() throws DDMessagingException { + void preservesSubscriberIdsAcrossRegistrationAndCancellation() throws DDRemoteDamException { for (String subscriberId : List.of("service:account", "service account", "s".repeat(129), "s".repeat(256))) { UUID subscriptionId = UUID.randomUUID(); respond("registerExternalDamSubscription", JsonParser.parseString("60")); @@ -524,7 +524,7 @@ void rejectsInvalidCallbackSecretsBeforeSendingAnyRequest() { } @Test - void unregistersOnlyTheStableSubscriberAndSubscriptionId() throws DDMessagingException { + void unregistersOnlyTheStableSubscriberAndSubscriptionId() throws DDRemoteDamException { UUID subscriptionId = UUID.randomUUID(); for (boolean removed : List.of(true, false)) { respond("unregisterExternalDamSubscription", JsonParser.parseString(Boolean.toString(removed))); @@ -550,7 +550,7 @@ void acceptsBothSubscriptionLeaseBoundaries() throws Exception { "projects", CALLBACK_URL, CALLBACK_SECRET).toCompletableFuture().get(5, TimeUnit.SECONDS).intValue()); JsonObject input = posted().getAsJsonObject("variables").getAsJsonObject("input"); assertEquals("s".repeat(256), input.get("subscriberId").getAsString()); - assertEquals("socket", input.get("connectionId").getAsString()); + assertEquals("socket", input.get("damSessionId").getAsString()); } } @@ -582,28 +582,28 @@ void rejectsInvalidLeaseValuesAndMalformedSubscriptionEnvelopesWithoutRetrying() } @Test - void rejectsTheWholeListWhenAnyRequiredConnectionFieldIsInvalid() { - for (String field : List.of("deploymentId", "applicationRunId", "connectionId", "product", "version")) { - JsonObject missing = connection("connection-2"); + void rejectsTheWholeListWhenAnyRequiredSessionFieldIsInvalid() { + for (String field : List.of("appDeploymentId", "appInstanceId", "damSessionId", "product", "version")) { + JsonObject missing = session("session-2"); missing.remove(field); - assertInvalidConnection(missing); + assertInvalidSession(missing); for (String value : List.of("null", "true", "42", "[]", "{}", "\"\"", "\" \\t\\n\"")) { - JsonObject invalid = connection("connection-2"); + JsonObject invalid = session("session-2"); invalid.add(field, JsonParser.parseString(value)); - assertInvalidConnection(invalid); + assertInvalidSession(invalid); } } } @Test - void rejectsNonArrayConnectionResultsAndNonObjectEntries() { + void rejectsNonArraySessionResultsAndNonObjectEntries() { for (String value : List.of( - "null", "true", "42", "\"connections\"", "{}", "[null]", "[true]", "[42]", "[\"connection\"]", "[[]]" + "null", "true", "42", "\"sessions\"", "{}", "[null]", "[true]", "[42]", "[\"session\"]", "[[]]" )) { - respond(CONNECTIONS_FIELD, JsonParser.parseString(value)); + respond(SESSIONS_FIELD, JsonParser.parseString(value)); - assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); - assertAsyncRequestFails(() -> client.getConnectionsAsync(ACCOUNT_ID)); + assertRequestFails(() -> client.getSessions(ACCOUNT_ID)); + assertAsyncRequestFails(() -> client.getSessionsAsync(ACCOUNT_ID)); } } @@ -612,15 +612,15 @@ void rejectsMalformedJsonRootsAndMissingDataForBothOperations() { for (String body : List.of( "", "{", "not-json", "null", "[]", "true", "42", "\"root\"", "{}", "{\"data\":null}", "{\"data\":[]}", "{\"data\":true}", "{\"data\":{}}", - "{\"data\":{\"externalDamConnections\":null,\"sendExternalDamMessage\":null}}", + "{\"data\":{\"externalDamSessions\":null,\"sendExternalDamMessage\":null}}", "{\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\":[" )) { responseJson.set(body); - assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); - assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); - assertAsyncRequestFails(() -> client.getConnectionsAsync(ACCOUNT_ID)); - assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); + assertRequestFails(() -> client.getSessions(ACCOUNT_ID)); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); + assertAsyncRequestFails(() -> client.getSessionsAsync(ACCOUNT_ID)); + assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); } } @@ -638,10 +638,10 @@ JsonNull.INSTANCE, new JsonObject(), JsonParser.parseString("true"), JsonParser. envelope.add("errors", value); responseJson.set(envelope.toString()); - assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); - assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); - assertAsyncRequestFails(() -> client.getConnectionsAsync(ACCOUNT_ID)); - DDMessagingException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); + assertRequestFails(() -> client.getSessions(ACCOUNT_ID)); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); + assertAsyncRequestFails(() -> client.getSessionsAsync(ACCOUNT_ID)); + DDRemoteDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); assertEquals("External DAM GraphQL request failed", failure.getMessage()); } } @@ -652,10 +652,10 @@ void acceptsAnEmptyGraphqlErrorsArray() throws Exception { envelope.add("errors", new JsonArray()); responseJson.set(envelope.toString()); - assertEquals(List.of(), client.getConnections(ACCOUNT_ID)); - assertTrue(client.sendMessage(ACCOUNT_ID, "connection-1", message())); - assertEquals(List.of(), client.getConnectionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS)); - assertTrue(client.sendMessageAsync(ACCOUNT_ID, "connection-1", message()).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertEquals(List.of(), client.getSessions(ACCOUNT_ID)); + assertTrue(client.sendMessage(ACCOUNT_ID, "session-1", message())); + assertEquals(List.of(), client.getSessionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertTrue(client.sendMessageAsync(ACCOUNT_ID, "session-1", message()).toCompletableFuture().get(5, TimeUnit.SECONDS)); assertEquals(4, requestCount.get()); } @@ -664,8 +664,18 @@ void rejectsSendResultsThatAreNotJsonBooleans() { for (String value : List.of("null", "\"true\"", "\"false\"", "0", "1", "{}", "[]")) { respond(SEND_FIELD, JsonParser.parseString(value)); - assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); - assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); + assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); + } + } + + @Test + void acceptsSuccessfulHttpStatusesThroughoutTheTwoHundredRange() throws Exception { + responseJson.set(successfulEnvelope().toString()); + for (int status : List.of(200, 201, 202, 299)) { + responseStatus.set(status); + assertEquals(List.of(), client.getSessions(ACCOUNT_ID)); + assertEquals(List.of(), client.getSessionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS)); } } @@ -674,15 +684,15 @@ void rejectsHttpFailuresWithoutExposingTheResponseOrRetrying() { JsonObject envelope = successfulEnvelope(); envelope.addProperty("message", UPSTREAM_MARKER + SERVICE_SECRET + CALLBACK_SECRET); responseJson.set(envelope.toString()); - for (int status : List.of(204, 400, 401, 403, 404, 429, 500, 503)) { + for (int status : List.of(204, 300, 400, 401, 403, 404, 429, 500, 503)) { responseStatus.set(status); - assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); - assertRequestFails(() -> client.getConnectionAccount("connection-1")); - assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertRequestFails(() -> client.getSessions(ACCOUNT_ID)); + assertRequestFails(() -> client.getSessionAccount("session-1")); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); assertRequestFails(() -> client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); - DDMessagingException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); + DDRemoteDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); assertEquals(status == 204 ? "Invalid External DAM response" : "External DAM request failed: HTTP " + status, failure.getMessage()); } @@ -695,7 +705,7 @@ void neverFollowsRedirectsOrForwardsTheServiceSecret() throws IOException { HttpServer destination = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); destination.createContext("/", exchange -> { forwarded.incrementAndGet(); - forwardedSecret.set(exchange.getRequestHeaders().getFirst(DDMessagingApi.HEADER_SERVICE_TOKEN)); + forwardedSecret.set(exchange.getRequestHeaders().getFirst(DDRemoteDamApi.HEADER_SERVICE_TOKEN)); try { exchange.sendResponseHeaders(200, -1); } catch (IOException e) { @@ -711,12 +721,12 @@ void neverFollowsRedirectsOrForwardsTheServiceSecret() throws IOException { for (int status : List.of(301, 302, 303, 307, 308)) { responseStatus.set(status); - assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); - assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertRequestFails(() -> client.getSessions(ACCOUNT_ID)); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); assertRequestFails(() -> client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); - assertAsyncRequestFails(() -> client.getConnectionAccountAsync("connection-1")); - assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); + assertAsyncRequestFails(() -> client.getSessionAccountAsync("session-1")); + assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); assertAsyncRequestFails(() -> client.registerSubscriptionAsync("account", UUID.randomUUID(), ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); assertEquals(0, forwarded.get()); @@ -728,20 +738,20 @@ void neverFollowsRedirectsOrForwardsTheServiceSecret() throws IOException { } @Test - void neverRetriesOrReplaysFailedMutationsAfterRecovery() throws DDMessagingException { + void neverRetriesOrReplaysFailedMutationsAfterRecovery() throws DDRemoteDamException { responseJson.set("{\"errors\":[{\"message\":\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\"}]}"); - assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); responseJson.set(successfulEnvelope().toString()); responseStatus.set(503); - assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); responseStatus.set(200); truncateResponse.set(true); - assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); truncateResponse.set(false); - assertTrue(client.sendMessage(ACCOUNT_ID, "connection-1", message())); + assertTrue(client.sendMessage(ACCOUNT_ID, "session-1", message())); assertEquals(4, requestCount.get()); } @@ -750,12 +760,12 @@ void preservesAsyncNetworkFailureCausesAndAllowsSubsequentRequests() throws Exce responseJson.set(successfulEnvelope().toString()); truncateResponse.set(true); - DDMessagingException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", message())); + DDRemoteDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); assertEquals("External DAM service request failed", failure.getMessage()); assertInstanceOf(IOException.class, failure.getCause()); truncateResponse.set(false); - assertTrue(client.sendMessageAsync(ACCOUNT_ID, "connection-1", message()).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertTrue(client.sendMessageAsync(ACCOUNT_ID, "session-1", message()).toCompletableFuture().get(5, TimeUnit.SECONDS)); assertEquals(2, requestCount.get()); } @@ -765,9 +775,9 @@ void reportsAsyncConnectionFailuresThroughTheStageWithTheirCause() { server = null; ExecutionException completion = assertThrows(ExecutionException.class, - () -> client.getConnectionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS)); + () -> client.getSessionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS)); - DDMessagingException failure = assertInstanceOf(DDMessagingException.class, completion.getCause()); + DDRemoteDamException failure = assertInstanceOf(DDRemoteDamException.class, completion.getCause()); assertEquals("External DAM service request failed", failure.getMessage()); assertInstanceOf(IOException.class, failure.getCause()); assertSafeFailure(failure); @@ -784,7 +794,7 @@ void rejectsInvalidServiceOriginsBeforeSendingAnyRequest() { "http://user:" + SERVICE_SECRET + "@127.0.0.1", "http://127.0.0.1:0", "http://127.0.0.1:65536" )) { IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, - () -> new DDMessagingClient(url, SERVICE_SECRET), url); + () -> new DDRemoteDamClient(url, SERVICE_SECRET), url); assertFalse(failure.toString().contains(SERVICE_SECRET)); } assertEquals(0, requestCount.get()); @@ -794,26 +804,26 @@ void rejectsInvalidServiceOriginsBeforeSendingAnyRequest() { void acceptsHttpAndHttpsRootOriginsAndPrintableSecretBoundaries() { for (String url : List.of(serviceUrl(), serviceUrl() + "/", "https://127.0.0.1", "https://127.0.0.1:8443/")) { for (String secret : List.of("!".repeat(32), "~".repeat(512))) { - assertDoesNotThrow(() -> new DDMessagingClient(url, secret)); + assertDoesNotThrow(() -> new DDRemoteDamClient(url, secret)); } } assertEquals(0, requestCount.get()); } @Test - void rejectsInvalidSecretsAndBlankConnectionIdsBeforeSendingAnyRequest() { + void rejectsInvalidSecretsAndBlankSessionIdsBeforeSendingAnyRequest() { for (String secret : List.of("", "s".repeat(31), "s".repeat(513), " ".repeat(32), "s".repeat(31) + "\n", "s".repeat(31) + "\t", "s".repeat(31) + "\u007f", "s".repeat(31) + "é")) { - assertThrows(IllegalArgumentException.class, () -> new DDMessagingClient(serviceUrl(), secret)); + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamClient(serviceUrl(), secret)); } - for (String connectionId : List.of("", " ", "\t\n")) { - assertThrows(IllegalArgumentException.class, () -> client.sendMessage(ACCOUNT_ID, connectionId, message())); - assertThrows(IllegalArgumentException.class, () -> client.sendMessageAsync(ACCOUNT_ID, connectionId, message())); + for (String damSessionId : List.of("", " ", "\t\n")) { + assertThrows(IllegalArgumentException.class, () -> client.sendMessage(ACCOUNT_ID, damSessionId, message())); + assertThrows(IllegalArgumentException.class, () -> client.sendMessageAsync(ACCOUNT_ID, damSessionId, message())); } - assertThrows(NullPointerException.class, () -> client.getConnectionsAsync(null)); - assertThrows(NullPointerException.class, () -> client.getConnectionAccountAsync(null)); - assertThrows(NullPointerException.class, () -> client.sendMessageAsync(null, "connection-1", message())); - assertThrows(NullPointerException.class, () -> client.sendMessageAsync(ACCOUNT_ID, "connection-1", null)); + assertThrows(NullPointerException.class, () -> client.getSessionsAsync(null)); + assertThrows(NullPointerException.class, () -> client.getSessionAccountAsync(null)); + assertThrows(NullPointerException.class, () -> client.sendMessageAsync(null, "session-1", message())); + assertThrows(NullPointerException.class, () -> client.sendMessageAsync(ACCOUNT_ID, "session-1", null)); assertEquals(0, requestCount.get()); } @@ -904,11 +914,11 @@ private JsonObject posted() { CapturedRequest request = latestRequest.get(); assertNotNull(request); assertEquals("POST", request.method()); - assertEquals(DDMessagingApi.GRAPHQL_PATH, request.uri().toString()); + assertEquals(DDRemoteDamApi.GRAPHQL_PATH, request.uri().toString()); assertEquals("application/json", request.header("Content-Type")); assertEquals("application/json", request.header("Accept")); assertEquals("no-store", request.header("Cache-Control")); - assertEquals(SERVICE_SECRET, request.header(DDMessagingApi.HEADER_SERVICE_TOKEN)); + assertEquals(SERVICE_SECRET, request.header(DDRemoteDamApi.HEADER_SERVICE_TOKEN)); assertNull(request.header("Authorization")); assertNull(request.header("Cookie")); assertFalse(request.body().contains(SERVICE_SECRET)); @@ -924,35 +934,35 @@ private void respond(@NotNull String field, @NotNull JsonElement value) { responseJson.set(envelope.toString()); } - private void assertInvalidConnection(@NotNull JsonObject invalid) { - JsonArray connections = new JsonArray(); - connections.add(connection("connection-1")); - connections.add(invalid); - respond(CONNECTIONS_FIELD, connections); - assertRequestFails(() -> client.getConnections(ACCOUNT_ID)); - assertAsyncRequestFails(() -> client.getConnectionsAsync(ACCOUNT_ID)); + private void assertInvalidSession(@NotNull JsonObject invalid) { + JsonArray sessions = new JsonArray(); + sessions.add(session("session-1")); + sessions.add(invalid); + respond(SESSIONS_FIELD, sessions); + assertRequestFails(() -> client.getSessions(ACCOUNT_ID)); + assertAsyncRequestFails(() -> client.getSessionsAsync(ACCOUNT_ID)); } private void assertRequestFails(@NotNull Executable action) { int before = requestCount.get(); - DDMessagingException failure = assertThrows(DDMessagingException.class, action); + DDRemoteDamException failure = assertThrows(DDRemoteDamException.class, action); assertEquals(before + 1, requestCount.get()); assertSafeFailure(failure); } @NotNull - private DDMessagingException assertAsyncRequestFails(@NotNull Supplier> action) { + private DDRemoteDamException assertAsyncRequestFails(@NotNull Supplier> action) { int before = requestCount.get(); CompletionStage stage = action.get(); ExecutionException completion = assertThrows(ExecutionException.class, () -> stage.toCompletableFuture().get(5, TimeUnit.SECONDS)); - DDMessagingException failure = assertInstanceOf(DDMessagingException.class, completion.getCause()); + DDRemoteDamException failure = assertInstanceOf(DDRemoteDamException.class, completion.getCause()); assertEquals(before + 1, requestCount.get()); assertSafeFailure(failure); return failure; } - private static void assertSafeFailure(@NotNull DDMessagingException failure) { + private static void assertSafeFailure(@NotNull DDRemoteDamException failure) { StringWriter trace = new StringWriter(); failure.printStackTrace(new PrintWriter(trace)); assertFalse(trace.toString().contains(SERVICE_SECRET)); @@ -961,20 +971,20 @@ private static void assertSafeFailure(@NotNull DDMessagingException failure) { } @NotNull - private static JsonObject connection(@NotNull String connectionId) { - JsonObject connection = new JsonObject(); - connection.addProperty("deploymentId", "DI_desktop"); - connection.addProperty("applicationRunId", "run-1"); - connection.addProperty("connectionId", connectionId); - connection.addProperty("product", "DBeaver"); - connection.addProperty("version", "26.2.4"); - return connection; + private static JsonObject session(@NotNull String damSessionId) { + JsonObject session = new JsonObject(); + session.addProperty("appDeploymentId", "DI_remoteDam"); + session.addProperty("appInstanceId", "run-1"); + session.addProperty("damSessionId", damSessionId); + session.addProperty("product", "DBeaver"); + session.addProperty("version", "26.2.4"); + return session; } @NotNull private static JsonObject successfulEnvelope() { JsonObject data = new JsonObject(); - data.add(CONNECTIONS_FIELD, new JsonArray()); + data.add(SESSIONS_FIELD, new JsonArray()); data.addProperty(SEND_FIELD, true); JsonObject envelope = new JsonObject(); envelope.add("data", data); @@ -982,8 +992,8 @@ private static JsonObject successfulEnvelope() { } @NotNull - private static DDMessagingMessage message() { - return new DDMessagingMessage("projects", "project.updated", new JsonObject()); + private static DDRemoteDamMessage message() { + return new DDRemoteDamMessage("projects", "project.updated", new JsonObject()); } private record CapturedRequest( diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessageTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessageTest.java similarity index 79% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessageTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessageTest.java index 838167d..66ec5ae 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingMessageTest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessageTest.java @@ -14,7 +14,7 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.model; +package com.dbeaver.datadam.dam.remote.api.model; import com.google.gson.JsonObject; import org.junit.jupiter.api.Test; @@ -22,14 +22,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -class DDMessagingMessageTest { +class DDRemoteDamMessageTest { @Test void isolatesThePayloadFromChangesToTheOriginalObject() { JsonObject project = new JsonObject(); project.addProperty("name", "original"); JsonObject payload = new JsonObject(); payload.add("project", project); - DDMessagingMessage message = new DDMessagingMessage("projects", "updated", payload); + DDRemoteDamMessage message = new DDRemoteDamMessage("projects", "updated", payload); project.addProperty("name", "changed"); @@ -40,7 +40,7 @@ void isolatesThePayloadFromChangesToTheOriginalObject() { void isolatesListenersFromEachOthersPayloadChanges() { JsonObject payload = new JsonObject(); payload.addProperty("projectId", "project-1"); - DDMessagingMessage message = new DDMessagingMessage("projects", "updated", payload); + DDRemoteDamMessage message = new DDRemoteDamMessage("projects", "updated", payload); message.payload().addProperty("projectId", "changed"); @@ -49,14 +49,14 @@ void isolatesListenersFromEachOthersPayloadChanges() { @Test void rejectsBlankRoutingFields() { - assertThrows(IllegalArgumentException.class, () -> new DDMessagingMessage(" ", "updated", new JsonObject())); - assertThrows(IllegalArgumentException.class, () -> new DDMessagingMessage("projects", " ", new JsonObject())); + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamMessage(" ", "updated", new JsonObject())); + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamMessage("projects", " ", new JsonObject())); } @Test - void rejectsMissingConnectionIdentifiers() { - assertThrows(IllegalArgumentException.class, () -> new DDDamConnectionInfo(" ", "run-1", "connection-1")); - assertThrows(IllegalArgumentException.class, () -> new DDDamConnectionInfo("DI_desktop", " ", "connection-1")); - assertThrows(IllegalArgumentException.class, () -> new DDDamConnectionInfo("DI_desktop", "run-1", " ")); + void rejectsMissingSessionIdentifiers() { + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamSessionInfo(" ", "run-1", "session-1")); + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamSessionInfo("DI_remoteDam", " ", "session-1")); + assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamSessionInfo("DI_remoteDam", "run-1", " ")); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnectionTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionTest.java similarity index 61% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnectionTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionTest.java index f224b05..2346381 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/model/DDMessagingConnectionTest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionTest.java @@ -14,22 +14,22 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.model; +package com.dbeaver.datadam.dam.remote.api.model; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -class DDMessagingConnectionTest { +class DDRemoteDamSessionTest { @Test void preservesSeparateDeploymentRunAndSocketIdentities() { - DDMessagingConnection connection = new DDMessagingConnection("deployment", "run", "socket", "DBeaver", "26.2"); + DDRemoteDamSession session = new DDRemoteDamSession("deployment", "run", "socket", "DBeaver", "26.2"); - Assertions.assertEquals("deployment", connection.deploymentId()); - Assertions.assertEquals("run", connection.applicationRunId()); - Assertions.assertEquals("socket", connection.connectionId()); - Assertions.assertEquals("DBeaver", connection.product()); - Assertions.assertEquals("26.2", connection.version()); - Assertions.assertNotEquals(connection, new DDMessagingConnection("deployment", "run", "another-socket", "DBeaver", "26.2")); + Assertions.assertEquals("deployment", session.appDeploymentId()); + Assertions.assertEquals("run", session.appInstanceId()); + Assertions.assertEquals("socket", session.damSessionId()); + Assertions.assertEquals("DBeaver", session.product()); + Assertions.assertEquals("26.2", session.version()); + Assertions.assertNotEquals(session, new DDRemoteDamSession("deployment", "run", "another-socket", "DBeaver", "26.2")); } @Test @@ -40,7 +40,7 @@ void rejectsMissingOrBlankMetadataInEveryField() { values[field] = invalid; Assertions.assertThrows(IllegalArgumentException.class, () -> - new DDMessagingConnection(values[0], values[1], values[2], values[3], values[4])); + new DDRemoteDamSession(values[0], values[1], values[2], values[3], values[4])); } } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransportTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransportTest.java similarity index 77% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransportTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransportTest.java index 0b8c541..d365811 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/requestreply/DDMessagingRequestReplyTransportTest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransportTest.java @@ -14,17 +14,17 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.requestreply; - -import com.dbeaver.datadam.messaging.api.exception.DDMessagingException; -import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; -import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannel; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelState; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelStatus; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingConnectionListener; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingMessageListener; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingSubscription; +package com.dbeaver.datadam.dam.remote.api.requestreply; + +import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelState; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelStatus; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamMessageListener; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSessionListener; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSubscription; import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint; import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; @@ -54,24 +54,24 @@ import java.util.function.Consumer; @Timeout(10) -class DDMessagingRequestReplyTransportTest { +class DDRemoteDamRequestReplyTransportTest { private static final String TOPIC = "projects"; - private static final DDDamConnectionInfo CONNECTION = new DDDamConnectionInfo("desktop", "run", "socket"); + private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("remoteDam", "run", "socket"); @Test void exchangesRequestsInBothDirectionsThroughExistingMessageChannels() throws Exception { FakeChannel callerChannel = new FakeChannel(); FakeChannel handlerChannel = new FakeChannel(); - callerChannel.forward = message -> handlerChannel.deliver(CONNECTION, message); - handlerChannel.forward = message -> callerChannel.deliver(CONNECTION, message); + callerChannel.forward = message -> handlerChannel.deliver(SESSION, message); + handlerChannel.forward = message -> callerChannel.deliver(SESSION, message); JsonObject payload = new JsonObject(); payload.addProperty("value", "request"); - try (var handler = MessagingRequestReplyEndpoint.open(DDMessagingRequestReplyTransport.forChannel(handlerChannel, TOPIC)); + try (var handler = MessagingRequestReplyEndpoint.open(DDRemoteDamRequestReplyTransport.forChannel(handlerChannel, TOPIC)); var handlerRegistration = handler.handle(request -> { request.addProperty("handled", true); return request; }); - var caller = MessagingRequestReplyEndpoint.open(DDMessagingRequestReplyTransport.forChannel(callerChannel, TOPIC)); + var caller = MessagingRequestReplyEndpoint.open(DDRemoteDamRequestReplyTransport.forChannel(callerChannel, TOPIC)); var callerRegistration = caller.handle(request -> null)) { JsonObject response = caller.request(payload, Duration.ofSeconds(2)); @@ -92,8 +92,8 @@ void exchangesRequestsInBothDirectionsThroughExistingMessageChannels() throws Ex @Test void routesRequestsAndRepliesToUniqueEndpointTopics() throws Exception { FakeChannel channel = new FakeChannel(); - DDMessagingRequestReplyTransport first = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); - DDMessagingRequestReplyTransport second = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); + DDRemoteDamRequestReplyTransport first = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); + DDRemoteDamRequestReplyTransport second = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); UUID firstId = UUID.randomUUID(); UUID secondId = UUID.randomUUID(); Received firstReceived = new Received(); @@ -110,20 +110,20 @@ void routesRequestsAndRepliesToUniqueEndpointTopics() throws Exception { Assertions.assertEquals(TOPIC, channel.subscriptions.get(3).topic); MessagingRequestReplyMessage request = request(firstId); first.send(request).toCompletableFuture().join(); - DDMessagingMessage sent = channel.sent.getLast(); + DDRemoteDamMessage sent = channel.sent.getLast(); Assertions.assertEquals(TOPIC, sent.topic()); - channel.deliver(CONNECTION, sent); + channel.deliver(SESSION, sent); Assertions.assertEquals(List.of(request), firstReceived.messages); Assertions.assertEquals(List.of(request), secondReceived.messages); second.send(request.response(null)).toCompletableFuture().join(); Assertions.assertEquals(firstReplyTopic, channel.sent.getLast().topic()); - channel.deliver(CONNECTION, channel.sent.getLast()); + channel.deliver(SESSION, channel.sent.getLast()); Assertions.assertEquals(List.of(request, request.response(null)), firstReceived.messages); Assertions.assertEquals(List.of(request), secondReceived.messages); MessagingRequestReplyMessage otherRequest = request(secondId); first.send(otherRequest.failure(MessagingRequestReplyMessage.Error.NO_HANDLER)).toCompletableFuture().join(); Assertions.assertEquals(secondReplyTopic, channel.sent.getLast().topic()); - channel.deliver(CONNECTION, channel.sent.getLast()); + channel.deliver(SESSION, channel.sent.getLast()); Assertions.assertEquals(otherRequest.failure(MessagingRequestReplyMessage.Error.NO_HANDLER), secondReceived.messages.getLast()); } Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active)); @@ -135,20 +135,20 @@ void routesRequestsAndRepliesToUniqueEndpointTopics() throws Exception { void keepsDerivedTopicsWithinRegistrationMetadataLimit() throws Exception { FakeChannel channel = new FakeChannel(); String topic = "x".repeat(256); - DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, topic); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, topic); try (var subscription = adapter.subscribe(UUID.randomUUID(), new Received())) { Assertions.assertEquals(topic, channel.subscriptions.get(1).topic); Assertions.assertTrue(channel.subscriptions.getFirst().topic.length() <= 256); } - Assertions.assertThrows(IllegalArgumentException.class, () -> DDMessagingRequestReplyTransport.forChannel(channel, " ")); - Assertions.assertThrows(IllegalArgumentException.class, () -> DDMessagingRequestReplyTransport.forChannel(channel, topic + "x")); + Assertions.assertThrows(IllegalArgumentException.class, () -> DDRemoteDamRequestReplyTransport.forChannel(channel, " ")); + Assertions.assertThrows(IllegalArgumentException.class, () -> DDRemoteDamRequestReplyTransport.forChannel(channel, topic + "x")); } @Test void rollsBackReplySubscriptionAndStateObservationWhenRequestSubscriptionFails() { FakeChannel channel = new FakeChannel(); channel.failTopic = TOPIC; - DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); Assertions.assertSame(channel.subscriptionFailure, failure.getCause()); @@ -161,9 +161,9 @@ void rollsBackReplySubscriptionAndStateObservationWhenRequestSubscriptionFails() @Test void scopedSubscriberPreservesCheckedOpeningFailureAndRollsBack() { - DDMessagingException unavailable = new DDMessagingException("Unavailable"); + DDRemoteDamException unavailable = new DDRemoteDamException("Unavailable"); AtomicInteger closed = new AtomicInteger(); - DDMessagingRequestReplyTransport adapter = new DDMessagingRequestReplyTransport(TOPIC, + DDRemoteDamRequestReplyTransport adapter = new DDRemoteDamRequestReplyTransport(TOPIC, message -> CompletableFuture.completedFuture(null), (topic, listener) -> { if (topic.equals(TOPIC)) { throw unavailable; @@ -178,12 +178,12 @@ void scopedSubscriberPreservesCheckedOpeningFailureAndRollsBack() { @Test void rejectsDisconnectedAndClosedChannelsAtOpening() { - for (DDMessagingChannelState state : List.of( - DDMessagingChannelState.DISCONNECTED, DDMessagingChannelState.CONNECTING, DDMessagingChannelState.CLOSED + for (DDRemoteDamChannelState state : List.of( + DDRemoteDamChannelState.DISCONNECTED, DDRemoteDamChannelState.CONNECTING, DDRemoteDamChannelState.CLOSED )) { FakeChannel channel = new FakeChannel(); - channel.setStatus(new DDMessagingChannelStatus(state, null)); - DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); + channel.setStatus(new DDRemoteDamChannelStatus(state, null)); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); Assertions.assertEquals(MessagingRequestReplyException.Reason.DISCONNECTED, failure.reason()); @@ -197,11 +197,11 @@ void rollsBackAllResourcesAfterDisconnectDuringOpeningEvenIfTheChannelReconnects FakeChannel channel = new FakeChannel(); channel.onSubscribe = topic -> { if (TOPIC.equals(topic)) { - channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.DISCONNECTED, null)); - channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, CONNECTION)); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null)); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, SESSION)); } }; - DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); Assertions.assertEquals(MessagingRequestReplyException.Reason.DISCONNECTED, failure.reason()); @@ -213,37 +213,37 @@ void rollsBackAllResourcesAfterDisconnectDuringOpeningEvenIfTheChannelReconnects @Test void propagatesDisconnectAndAcceptsOnlyTheCurrentPhysicalSource() throws Exception { FakeChannel channel = new FakeChannel(); - DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); Received received = new Received(); try (var subscription = adapter.subscribe(endpoint, received)) { adapter.send(request(endpoint)).toCompletableFuture().join(); - DDMessagingMessage wire = channel.sent.getLast(); - channel.deliver(new DDDamConnectionInfo("other", "run", "socket"), wire); - channel.deliver(new DDDamConnectionInfo("desktop", "other", "socket"), wire); - channel.deliver(new DDDamConnectionInfo("desktop", "run", "other"), wire); + DDRemoteDamMessage wire = channel.sent.getLast(); + channel.deliver(new DDRemoteDamSessionInfo("other", "run", "socket"), wire); + channel.deliver(new DDRemoteDamSessionInfo("remoteDam", "other", "socket"), wire); + channel.deliver(new DDRemoteDamSessionInfo("remoteDam", "run", "other"), wire); Assertions.assertTrue(received.messages.isEmpty()); - channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.DISCONNECTED, null)); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null)); Assertions.assertEquals(1, received.disconnects); assertFailure(MessagingRequestReplyException.Reason.DISCONNECTED, adapter.send(request(endpoint))); - channel.deliver(CONNECTION, wire); + channel.deliver(SESSION, wire); Assertions.assertTrue(received.messages.isEmpty()); - DDDamConnectionInfo replacement = new DDDamConnectionInfo("desktop", "new-run", "new-socket"); - channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, replacement)); - channel.deliver(CONNECTION, wire); + DDRemoteDamSessionInfo replacement = new DDRemoteDamSessionInfo("remoteDam", "new-run", "new-socket"); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, replacement)); + channel.deliver(SESSION, wire); channel.deliver(replacement, wire); Assertions.assertEquals(1, received.messages.size()); - channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, CONNECTION)); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, SESSION)); Assertions.assertEquals(2, received.disconnects); - channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CLOSED, null)); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CLOSED, null)); Assertions.assertEquals(3, received.disconnects); } } @Test - void completesMessageDeliveryBeforeNotifyingAConcurrentConnectionReplacement() throws Exception { + void completesMessageDeliveryBeforeNotifyingAConcurrentSessionReplacement() throws Exception { FakeChannel channel = new FakeChannel(); - DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); CountDownLatch messageEntered = new CountDownLatch(1); CountDownLatch releaseMessage = new CountDownLatch(1); @@ -262,15 +262,15 @@ public void onDisconnect() { } })) { adapter.send(request(endpoint)).toCompletableFuture().join(); - DDMessagingMessage wire = channel.sent.getLast(); - FutureTask message = async(() -> channel.deliver(CONNECTION, wire)); + DDRemoteDamMessage wire = channel.sent.getLast(); + FutureTask message = async(() -> channel.deliver(SESSION, wire)); try { await(messageEntered); CountDownLatch replacing = new CountDownLatch(1); - DDDamConnectionInfo replacement = new DDDamConnectionInfo("desktop", "next-run", "next-socket"); + DDRemoteDamSessionInfo replacement = new DDRemoteDamSessionInfo("remoteDam", "next-run", "next-socket"); FutureTask state = async(() -> { replacing.countDown(); - channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, replacement)); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, replacement)); }); try { await(replacing); @@ -283,7 +283,7 @@ public void onDisconnect() { state.get(2, TimeUnit.SECONDS); Assertions.assertEquals(List.of("message", "disconnect"), delivered); - channel.deliver(CONNECTION, wire); + channel.deliver(SESSION, wire); Assertions.assertEquals(List.of("message", "disconnect"), delivered); } finally { releaseMessage.countDown(); @@ -293,9 +293,9 @@ public void onDisconnect() { } @Test - void notifiesConnectionReplacementBeforeDeliveringANewSourceMessage() throws Exception { + void notifiesSessionReplacementBeforeDeliveringANewSourceMessage() throws Exception { FakeChannel channel = new FakeChannel(); - DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); CountDownLatch disconnectEntered = new CountDownLatch(1); CountDownLatch releaseDisconnect = new CountDownLatch(1); @@ -314,10 +314,10 @@ public void onDisconnect() { } })) { adapter.send(request(endpoint)).toCompletableFuture().join(); - DDMessagingMessage wire = channel.sent.getLast(); - DDDamConnectionInfo replacement = new DDDamConnectionInfo("desktop", "next-run", "next-socket"); + DDRemoteDamMessage wire = channel.sent.getLast(); + DDRemoteDamSessionInfo replacement = new DDRemoteDamSessionInfo("remoteDam", "next-run", "next-socket"); FutureTask state = async(() -> - channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, replacement))); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, replacement))); try { await(disconnectEntered); CountDownLatch receiving = new CountDownLatch(1); @@ -345,13 +345,13 @@ public void onDisconnect() { @Test void defersReentrantStateNotificationsUntilTheCurrentMessageReturns() throws Exception { FakeChannel channel = new FakeChannel(); - DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); List delivered = new ArrayList<>(); try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { @Override public void onMessage(@NotNull MessagingRequestReplyMessage message) { - channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.DISCONNECTED, null)); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null)); delivered.add("message"); } @@ -362,7 +362,7 @@ public void onDisconnect() { })) { adapter.send(request(endpoint)).toCompletableFuture().join(); - channel.deliver(CONNECTION, channel.sent.getLast()); + channel.deliver(SESSION, channel.sent.getLast()); Assertions.assertEquals(List.of("message", "disconnect"), delivered); } @@ -371,7 +371,7 @@ public void onDisconnect() { @Test void establishesTheNewGenerationBeforeMessagesWhenStateObserversAreDelayed() throws Exception { FakeChannel channel = new FakeChannel(); - DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); List delivered = new ArrayList<>(); try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { @@ -386,20 +386,20 @@ public void onDisconnect() { } })) { adapter.send(request(endpoint)).toCompletableFuture().join(); - DDMessagingMessage wire = channel.sent.getLast(); - DDDamConnectionInfo replacement = new DDDamConnectionInfo("desktop", "next-run", "next-socket"); - DDMessagingChannelStatus ready = new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, replacement); + DDRemoteDamMessage wire = channel.sent.getLast(); + DDRemoteDamSessionInfo replacement = new DDRemoteDamSessionInfo("remoteDam", "next-run", "next-socket"); + DDRemoteDamChannelStatus ready = new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, replacement); channel.status = ready; channel.deliver(replacement, wire); Assertions.assertEquals(List.of("disconnect", "message"), delivered); - for (Consumer observer : List.copyOf(channel.observers)) { - observer.accept(new DDMessagingChannelStatus(DDMessagingChannelState.DISCONNECTED, null)); + for (Consumer observer : List.copyOf(channel.observers)) { + observer.accept(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null)); observer.accept(ready); } Assertions.assertEquals(List.of("disconnect", "message"), delivered); - channel.deliver(CONNECTION, wire); + channel.deliver(SESSION, wire); Assertions.assertEquals(List.of("disconnect", "message"), delivered); channel.deliver(replacement, wire); Assertions.assertEquals(List.of("disconnect", "message", "message"), delivered); @@ -410,8 +410,8 @@ public void onDisconnect() { void permitsConcurrentInlineRepliesWithoutHoldingCallbackLocks() throws Exception { FakeChannel leftChannel = new FakeChannel(); FakeChannel rightChannel = new FakeChannel(); - DDMessagingRequestReplyTransport left = DDMessagingRequestReplyTransport.forChannel(leftChannel, TOPIC); - DDMessagingRequestReplyTransport right = DDMessagingRequestReplyTransport.forChannel(rightChannel, TOPIC); + DDRemoteDamRequestReplyTransport left = DDRemoteDamRequestReplyTransport.forChannel(leftChannel, TOPIC); + DDRemoteDamRequestReplyTransport right = DDRemoteDamRequestReplyTransport.forChannel(rightChannel, TOPIC); UUID leftId = UUID.randomUUID(); UUID rightId = UUID.randomUUID(); CountDownLatch requests = new CountDownLatch(2); @@ -420,13 +420,13 @@ void permitsConcurrentInlineRepliesWithoutHoldingCallbackLocks() throws Exceptio var rightSubscription = right.subscribe(rightId, inlineReplies(right, requests, replies))) { left.send(request(leftId)).toCompletableFuture().join(); right.send(request(rightId)).toCompletableFuture().join(); - DDMessagingMessage toRight = leftChannel.sent.getLast(); - DDMessagingMessage toLeft = rightChannel.sent.getLast(); - leftChannel.forward = message -> rightChannel.deliver(CONNECTION, message); - rightChannel.forward = message -> leftChannel.deliver(CONNECTION, message); + DDRemoteDamMessage toRight = leftChannel.sent.getLast(); + DDRemoteDamMessage toLeft = rightChannel.sent.getLast(); + leftChannel.forward = message -> rightChannel.deliver(SESSION, message); + rightChannel.forward = message -> leftChannel.deliver(SESSION, message); - FutureTask leftDelivery = async(() -> leftChannel.deliver(CONNECTION, toLeft)); - FutureTask rightDelivery = async(() -> rightChannel.deliver(CONNECTION, toRight)); + FutureTask leftDelivery = async(() -> leftChannel.deliver(SESSION, toLeft)); + FutureTask rightDelivery = async(() -> rightChannel.deliver(SESSION, toRight)); leftDelivery.get(2, TimeUnit.SECONDS); rightDelivery.get(2, TimeUnit.SECONDS); @@ -438,7 +438,7 @@ void permitsConcurrentInlineRepliesWithoutHoldingCallbackLocks() throws Exceptio void boundsQueuedCallbacksAndPreservesDisconnectWhenTheQueueIsFull() throws Exception { for (boolean disconnect : List.of(false, true)) { FakeChannel channel = new FakeChannel(); - DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); CountDownLatch entered = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); @@ -458,15 +458,15 @@ public void onDisconnect() { } })) { adapter.send(request(endpoint)).toCompletableFuture().join(); - DDMessagingMessage wire = channel.sent.getLast(); - FutureTask delivery = async(() -> channel.deliver(CONNECTION, wire)); + DDRemoteDamMessage wire = channel.sent.getLast(); + FutureTask delivery = async(() -> channel.deliver(SESSION, wire)); try { await(entered); for (int i = 0; i < 130; i++) { - channel.deliver(CONNECTION, wire); + channel.deliver(SESSION, wire); } if (disconnect) { - channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.DISCONNECTED, null)); + channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null)); } } finally { release.countDown(); @@ -481,7 +481,7 @@ public void onDisconnect() { @Test void releasesReceivingResourcesWhenAListenerFails() throws Exception { FakeChannel channel = new FakeChannel(); - DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); IllegalStateException failure = new IllegalStateException("Listener failed"); try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() { @@ -497,7 +497,7 @@ public void onDisconnect() { adapter.send(request(endpoint)).toCompletableFuture().join(); Assertions.assertSame(failure, Assertions.assertThrows(IllegalStateException.class, - () -> channel.deliver(CONNECTION, channel.sent.getLast()))); + () -> channel.deliver(SESSION, channel.sent.getLast()))); assertFailure(MessagingRequestReplyException.Reason.CLOSED, adapter.send(request(endpoint))); Assertions.assertTrue(channel.subscriptions.stream().noneMatch(value -> value.active)); @@ -509,15 +509,15 @@ public void onDisconnect() { @Test void rejectsMalformedRecognizedFramesAndWrongTypeTopicAndOrigin() throws Exception { FakeChannel channel = new FakeChannel(); - DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); Received received = new Received(); try (var subscription = adapter.subscribe(endpoint, received)) { MessagingRequestReplyMessage request = request(endpoint); adapter.send(request).toCompletableFuture().join(); - DDMessagingMessage requestWire = channel.sent.getLast(); + DDRemoteDamMessage requestWire = channel.sent.getLast(); adapter.send(request.response(new JsonObject())).toCompletableFuture().join(); - final DDMessagingMessage responseWire = channel.sent.getLast(); + final DDRemoteDamMessage responseWire = channel.sent.getLast(); List malformed = new ArrayList<>(); JsonObject abbreviatedId = requestWire.payload(); abbreviatedId.addProperty("requestId", "1-1-1-1-1"); @@ -544,40 +544,40 @@ void rejectsMalformedRecognizedFramesAndWrongTypeTopicAndOrigin() throws Excepti requestError.addProperty("error", "NO_HANDLER"); malformed.add(requestError); for (JsonObject envelope : malformed) { - channel.deliver(CONNECTION, new DDMessagingMessage(TOPIC, requestWire.type(), envelope)); + channel.deliver(SESSION, new DDRemoteDamMessage(TOPIC, requestWire.type(), envelope)); } JsonObject responseError = responseWire.payload(); responseError.addProperty("error", "NO_HANDLER"); - channel.deliver(CONNECTION, new DDMessagingMessage(responseWire.topic(), responseWire.type(), responseError)); + channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), responseWire.type(), responseError)); JsonObject responsePrimitive = responseWire.payload(); responsePrimitive.addProperty("data", 42); - channel.deliver(CONNECTION, new DDMessagingMessage(responseWire.topic(), responseWire.type(), responsePrimitive)); + channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), responseWire.type(), responsePrimitive)); JsonObject responseMissingData = responseWire.payload(); responseMissingData.remove("data"); - channel.deliver(CONNECTION, new DDMessagingMessage(responseWire.topic(), responseWire.type(), responseMissingData)); + channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), responseWire.type(), responseMissingData)); JsonObject wrongOrigin = responseWire.payload(); wrongOrigin.addProperty("replyTo", UUID.randomUUID().toString()); - channel.deliver(CONNECTION, new DDMessagingMessage(responseWire.topic(), responseWire.type(), wrongOrigin)); + channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), responseWire.type(), wrongOrigin)); adapter.send(request.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)).toCompletableFuture().join(); - DDMessagingMessage errorWire = channel.sent.getLast(); + DDRemoteDamMessage errorWire = channel.sent.getLast(); JsonObject errorData = errorWire.payload(); errorData.add("data", new JsonObject()); - channel.deliver(CONNECTION, new DDMessagingMessage(errorWire.topic(), errorWire.type(), errorData)); + channel.deliver(SESSION, new DDRemoteDamMessage(errorWire.topic(), errorWire.type(), errorData)); JsonObject unknownError = errorWire.payload(); unknownError.addProperty("error", "OTHER"); - channel.deliver(CONNECTION, new DDMessagingMessage(errorWire.topic(), errorWire.type(), unknownError)); + channel.deliver(SESSION, new DDRemoteDamMessage(errorWire.topic(), errorWire.type(), unknownError)); JsonObject missingError = errorWire.payload(); missingError.remove("error"); - channel.deliver(CONNECTION, new DDMessagingMessage(errorWire.topic(), errorWire.type(), missingError)); - channel.deliver(CONNECTION, new DDMessagingMessage(TOPIC, "unrelated", requestWire.payload())); - channel.deliver(CONNECTION, new DDMessagingMessage(TOPIC, responseWire.type(), responseWire.payload())); - channel.deliver(CONNECTION, new DDMessagingMessage(responseWire.topic(), requestWire.type(), requestWire.payload())); - channel.subscriptions.getFirst().listener.onMessage(CONNECTION, requestWire); - channel.subscriptions.get(1).listener.onMessage(CONNECTION, responseWire); + channel.deliver(SESSION, new DDRemoteDamMessage(errorWire.topic(), errorWire.type(), missingError)); + channel.deliver(SESSION, new DDRemoteDamMessage(TOPIC, "unrelated", requestWire.payload())); + channel.deliver(SESSION, new DDRemoteDamMessage(TOPIC, responseWire.type(), responseWire.payload())); + channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), requestWire.type(), requestWire.payload())); + channel.subscriptions.getFirst().listener.onMessage(SESSION, requestWire); + channel.subscriptions.get(1).listener.onMessage(SESSION, responseWire); Assertions.assertTrue(received.messages.isEmpty()); - channel.deliver(CONNECTION, requestWire); - channel.deliver(CONNECTION, responseWire); - channel.deliver(CONNECTION, errorWire); + channel.deliver(SESSION, requestWire); + channel.deliver(SESSION, responseWire); + channel.deliver(SESSION, errorWire); Assertions.assertEquals(List.of(request, request.response(new JsonObject()), request.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)), received.messages); } @@ -586,8 +586,8 @@ void rejectsMalformedRecognizedFramesAndWrongTypeTopicAndOrigin() throws Excepti @Test void closeIsIdempotentAndLeavesBorrowedSubscriptionsIntact() throws Exception { FakeChannel channel = new FakeChannel(); - final DDMessagingSubscription borrowed = channel.subscribe("other", (connection, message) -> { }); - DDMessagingRequestReplyTransport adapter = DDMessagingRequestReplyTransport.forChannel(channel, TOPIC); + final DDRemoteDamSubscription borrowed = channel.subscribe("other", (session, message) -> { }); + DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); final MessagingSubscription subscription = adapter.subscribe(endpoint, new Received()); Assertions.assertThrows(IllegalStateException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); @@ -604,7 +604,7 @@ void closeIsIdempotentAndLeavesBorrowedSubscriptionsIntact() throws Exception { @Test void boundsOutstandingSendsAndReleasesBudgetOnCompletionAndFailure() throws Exception { List> pending = new ArrayList<>(); - DDMessagingRequestReplyTransport adapter = new DDMessagingRequestReplyTransport(TOPIC, message -> { + DDRemoteDamRequestReplyTransport adapter = new DDRemoteDamRequestReplyTransport(TOPIC, message -> { CompletableFuture result = new CompletableFuture<>(); pending.add(result); return result; @@ -645,7 +645,7 @@ void boundsOutstandingSendsAndReleasesBudgetOnCompletionAndFailure() throws Exce void releasesSendBudgetAfterSynchronousSenderFailure() throws Exception { IllegalStateException unavailable = new IllegalStateException("Unavailable"); AtomicInteger attempts = new AtomicInteger(); - DDMessagingRequestReplyTransport adapter = new DDMessagingRequestReplyTransport(TOPIC, message -> { + DDRemoteDamRequestReplyTransport adapter = new DDRemoteDamRequestReplyTransport(TOPIC, message -> { if (attempts.incrementAndGet() <= 129) { throw unavailable; } @@ -683,7 +683,7 @@ private static FutureTask async(@NotNull Runnable action) { @NotNull private static MessagingRequestReplyTransport.Listener inlineReplies( - @NotNull DDMessagingRequestReplyTransport adapter, + @NotNull DDRemoteDamRequestReplyTransport adapter, @NotNull CountDownLatch requests, @NotNull AtomicInteger replies ) { @@ -729,29 +729,29 @@ public void onDisconnect() { } } - private static final class FakeChannel implements DDMessagingChannel { + private static final class FakeChannel implements DDRemoteDamChannel { private final List subscriptions = new ArrayList<>(); - private final List> observers = new ArrayList<>(); - private final List sent = new ArrayList<>(); + private final List> observers = new ArrayList<>(); + private final List sent = new ArrayList<>(); private final RuntimeException subscriptionFailure = new IllegalStateException("Subscription unavailable"); - private volatile DDMessagingChannelStatus status = new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, CONNECTION); + private volatile DDRemoteDamChannelStatus status = new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, SESSION); @Nullable private String failTopic; @Nullable private Consumer onSubscribe; @Nullable - private Consumer forward; + private Consumer forward; private boolean closed; @Override @NotNull - public DDMessagingChannelStatus getStatus() { + public DDRemoteDamChannelStatus getStatus() { return status; } @Override @NotNull - public DDMessagingSubscription observeState(@NotNull Consumer observer) { + public DDRemoteDamSubscription observeState(@NotNull Consumer observer) { observers.add(observer); observer.accept(status); return () -> observers.remove(observer); @@ -759,7 +759,7 @@ public DDMessagingSubscription observeState(@NotNull Consumer send(@NotNull DDMessagingMessage message) { + public CompletionStage send(@NotNull DDRemoteDamMessage message) { sent.add(message); if (forward != null) { forward.accept(message); @@ -769,7 +769,7 @@ public CompletionStage send(@NotNull DDMessagingMessage message) { @Override @NotNull - public DDMessagingSubscription subscribe(@NotNull String topic, @NotNull DDMessagingMessageListener listener) { + public DDRemoteDamSubscription subscribe(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) { if (topic.equals(failTopic)) { throw subscriptionFailure; } @@ -783,16 +783,16 @@ public DDMessagingSubscription subscribe(@NotNull String topic, @NotNull DDMessa @Override @NotNull - public DDMessagingSubscription addConnectionListener(@NotNull DDMessagingConnectionListener listener) { + public DDRemoteDamSubscription addSessionListener(@NotNull DDRemoteDamSessionListener listener) { throw new UnsupportedOperationException(); } - private void setStatus(@NotNull DDMessagingChannelStatus next) { + private void setStatus(@NotNull DDRemoteDamChannelStatus next) { status = next; List.copyOf(observers).forEach(observer -> observer.accept(next)); } - private void deliver(@NotNull DDDamConnectionInfo source, @NotNull DDMessagingMessage message) { + private void deliver(@NotNull DDRemoteDamSessionInfo source, @NotNull DDRemoteDamMessage message) { for (TopicSubscription subscription : List.copyOf(subscriptions)) { if (subscription.active && subscription.topic.equals(message.topic())) { subscription.listener.onMessage(source, message); @@ -808,10 +808,10 @@ public void close() { private static final class TopicSubscription { private final String topic; - private final DDMessagingMessageListener listener; + private final DDRemoteDamMessageListener listener; private boolean active = true; - private TopicSubscription(@NotNull String topic, @NotNull DDMessagingMessageListener listener) { + private TopicSubscription(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) { this.topic = topic; this.listener = listener; } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingGsonRpcCodecTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodecTest.java similarity index 91% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingGsonRpcCodecTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodecTest.java index dcd2a42..b7d87d2 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingGsonRpcCodecTest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodecTest.java @@ -14,7 +14,7 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.rpc; +package com.dbeaver.datadam.dam.remote.api.rpc; import com.google.gson.FieldNamingPolicy; import com.google.gson.Gson; @@ -29,8 +29,8 @@ import java.lang.reflect.Type; import java.util.List; -class DDMessagingGsonRpcCodecTest { - private final DDMessagingGsonRpcCodec codec = new DDMessagingGsonRpcCodec(new Gson()); +class DDRemoteDamGsonRpcCodecTest { + private final DDRemoteDamGsonRpcCodec codec = new DDRemoteDamGsonRpcCodec(new Gson()); @Test void encodesOrdinaryAndPrimitiveValues() { @@ -61,7 +61,7 @@ void usesDeclaredGenericTypesForApplicationRecords() { @Test void preservesApplicationGsonConfiguration() { - var configured = new DDMessagingGsonRpcCodec(new GsonBuilder() + var configured = new DDRemoteDamGsonRpcCodec(new GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create()); Item value = new Item("configured", List.of(3)); @@ -73,7 +73,7 @@ void preservesApplicationGsonConfiguration() { @Test void requiresAnExplicitGsonAndDeclaredTypes() { - Assertions.assertThrows(NullPointerException.class, () -> new DDMessagingGsonRpcCodec(null)); + Assertions.assertThrows(NullPointerException.class, () -> new DDRemoteDamGsonRpcCodec(null)); Assertions.assertThrows(NullPointerException.class, () -> codec.encode("Hello", null)); Assertions.assertThrows(NullPointerException.class, () -> codec.decode(null, null)); } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTopicsIntegrationTest.java similarity index 61% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTopicsIntegrationTest.java index 936bad5..32323ff 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTopicsIntegrationTest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTopicsIntegrationTest.java @@ -14,16 +14,16 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.rpc; - -import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; -import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannel; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelState; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelStatus; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingConnectionListener; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingMessageListener; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingSubscription; +package com.dbeaver.datadam.dam.remote.api.rpc; + +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelState; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelStatus; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamMessageListener; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSessionListener; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSubscription; import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions; import com.dbeaver.messaging.rpc.MessagingRpcCodec; import com.dbeaver.messaging.rpc.MessagingRpcEndpoint; @@ -47,46 +47,46 @@ import java.util.function.Consumer; @Timeout(10) -class DDMessagingRpcTopicsIntegrationTest { - private static final DDDamConnectionInfo CONNECTION = new DDDamConnectionInfo("desktop", "run", "physical-connection"); +class DDRemoteDamRpcTopicsIntegrationTest { + private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("remoteDam", "run", "physical-session"); private static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 16, 8); @Test void routesExactDeveloperTopicsBidirectionallyWithIndependentBindingCodecs() { - Channel desktop = new Channel(); + Channel remoteDam = new Channel(); Channel firstBackend = new Channel(); Channel secondBackend = new Channel(); - desktop.forward = message -> { + remoteDam.forward = message -> { firstBackend.receive(message); secondBackend.receive(message); }; - firstBackend.forward = desktop::receive; - secondBackend.forward = desktop::receive; - var codec = new DDMessagingGsonRpcCodec(new Gson()); + firstBackend.forward = remoteDam::receive; + secondBackend.forward = remoteDam::receive; + var codec = new DDRemoteDamGsonRpcCodec(new Gson()); String firstTopic = "hello.files.v2"; String secondTopic = "developer..chosen-topic"; var serviceCodec = new PrefixCodec("response:", "argument:"); var firstProxyCodec = new PrefixCodec("first:", "one:"); var secondProxyCodec = new PrefixCodec("second:", "two:"); - try (var desktopRpc = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(desktop), codec, OPTIONS); - var firstRpc = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(firstBackend), OPTIONS); - var secondRpc = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(secondBackend), OPTIONS); + try (var remoteDamRpc = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(remoteDam), codec, OPTIONS); + var firstRpc = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(firstBackend), OPTIONS); + var secondRpc = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(secondBackend), OPTIONS); var firstGreeting = firstRpc.register(firstTopic, Greeting.class, value -> value, serviceCodec); var secondGreeting = secondRpc.register(secondTopic, Greeting.class, value -> "other:" + value, codec); - var reverseGreeting = desktopRpc.register(firstTopic, Greeting.class, value -> "desktop:" + value)) { - Greeting first = desktopRpc.proxy(firstTopic, Greeting.class, firstProxyCodec); - Greeting second = desktopRpc.proxy(firstTopic, Greeting.class, secondProxyCodec); + var reverseGreeting = remoteDamRpc.register(firstTopic, Greeting.class, value -> "remoteDam:" + value)) { + Greeting first = remoteDamRpc.proxy(firstTopic, Greeting.class, firstProxyCodec); + Greeting second = remoteDamRpc.proxy(firstTopic, Greeting.class, secondProxyCodec); Assertions.assertEquals("one:response:argument:first:Hello", first.hello("Hello")); Assertions.assertEquals("two:response:argument:second:Hello", second.hello("Hello")); - Assertions.assertEquals("other:Hello", desktopRpc.proxy(secondTopic, Greeting.class).hello("Hello")); - Assertions.assertEquals("desktop:reverse", firstRpc.proxy(firstTopic, Greeting.class, codec).hello("reverse")); - Assertions.assertEquals("response:argument:fallback", desktopRpc.proxy(firstTopic, Greeting.class).hello("fallback")); - Assertions.assertEquals(4, desktop.subscriptions.size()); + Assertions.assertEquals("other:Hello", remoteDamRpc.proxy(secondTopic, Greeting.class).hello("Hello")); + Assertions.assertEquals("remoteDam:reverse", firstRpc.proxy(firstTopic, Greeting.class, codec).hello("reverse")); + Assertions.assertEquals("response:argument:fallback", remoteDamRpc.proxy(firstTopic, Greeting.class).hello("fallback")); + Assertions.assertEquals(4, remoteDam.subscriptions.size()); Assertions.assertEquals(2, firstBackend.subscriptions.size()); Assertions.assertEquals(2, secondBackend.subscriptions.size()); - Assertions.assertTrue(desktop.subscriptions.stream().anyMatch(value -> value.topic().equals(firstTopic))); - Assertions.assertTrue(desktop.subscriptions.stream().anyMatch(value -> value.topic().equals(secondTopic))); - for (DDMessagingMessage message : desktop.sent) { + Assertions.assertTrue(remoteDam.subscriptions.stream().anyMatch(value -> value.topic().equals(firstTopic))); + Assertions.assertTrue(remoteDam.subscriptions.stream().anyMatch(value -> value.topic().equals(secondTopic))); + for (DDRemoteDamMessage message : remoteDam.sent) { if ("REQUEST".equals(message.payload().get("type").getAsString())) { Assertions.assertTrue(List.of(firstTopic, secondTopic).contains(message.topic())); JsonElement envelope = message.payload().get("data"); @@ -99,9 +99,9 @@ void routesExactDeveloperTopicsBidirectionallyWithIndependentBindingCodecs() { MessagingRpcException missing = Assertions.assertThrows(MessagingRpcException.class, () -> first.hello("closed")); Assertions.assertEquals(MessagingRpcMessage.Error.UNKNOWN_SERVICE, missing.remoteError()); - Assertions.assertEquals("other:still", desktopRpc.proxy(secondTopic, Greeting.class).hello("still")); + Assertions.assertEquals("other:still", remoteDamRpc.proxy(secondTopic, Greeting.class).hello("still")); } - for (Channel channel : List.of(desktop, firstBackend, secondBackend)) { + for (Channel channel : List.of(remoteDam, firstBackend, secondBackend)) { Assertions.assertFalse(channel.closed); Assertions.assertTrue(channel.subscriptions.isEmpty()); Assertions.assertTrue(channel.observers.isEmpty()); @@ -128,23 +128,23 @@ public Object decode(@Nullable JsonElement payload, @NotNull Type type) { } } - private static final class Channel implements DDMessagingChannel { + private static final class Channel implements DDRemoteDamChannel { private final List subscriptions = new CopyOnWriteArrayList<>(); - private final List> observers = new CopyOnWriteArrayList<>(); - private final List sent = new CopyOnWriteArrayList<>(); - private Consumer forward = message -> { }; + private final List> observers = new CopyOnWriteArrayList<>(); + private final List sent = new CopyOnWriteArrayList<>(); + private Consumer forward = message -> { }; private boolean closed; @Override @NotNull - public DDMessagingChannelStatus getStatus() { - return new DDMessagingChannelStatus( - closed ? DDMessagingChannelState.CLOSED : DDMessagingChannelState.CONNECTED, closed ? null : CONNECTION); + public DDRemoteDamChannelStatus getStatus() { + return new DDRemoteDamChannelStatus( + closed ? DDRemoteDamChannelState.CLOSED : DDRemoteDamChannelState.CONNECTED, closed ? null : SESSION); } @Override @NotNull - public DDMessagingSubscription observeState(@NotNull Consumer observer) { + public DDRemoteDamSubscription observeState(@NotNull Consumer observer) { observers.add(observer); observer.accept(getStatus()); return () -> observers.remove(observer); @@ -152,23 +152,23 @@ public DDMessagingSubscription observeState(@NotNull Consumer send(@NotNull DDMessagingMessage message) { + public CompletionStage send(@NotNull DDRemoteDamMessage message) { sent.add(message); forward.accept(message); return CompletableFuture.completedFuture(null); } - private void receive(@NotNull DDMessagingMessage message) { + private void receive(@NotNull DDRemoteDamMessage message) { for (Subscription subscription : subscriptions) { if (subscription.topic.equals(message.topic())) { - subscription.listener.onMessage(CONNECTION, message); + subscription.listener.onMessage(SESSION, message); } } } @Override @NotNull - public DDMessagingSubscription subscribe(@NotNull String topic, @NotNull DDMessagingMessageListener listener) { + public DDRemoteDamSubscription subscribe(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) { var subscription = new Subscription(topic, listener); subscriptions.add(subscription); return () -> subscriptions.remove(subscription); @@ -176,7 +176,7 @@ public DDMessagingSubscription subscribe(@NotNull String topic, @NotNull DDMessa @Override @NotNull - public DDMessagingSubscription addConnectionListener(@NotNull DDMessagingConnectionListener listener) { + public DDRemoteDamSubscription addSessionListener(@NotNull DDRemoteDamSessionListener listener) { return () -> { }; } @@ -189,6 +189,6 @@ public void close() { } } - private record Subscription(@NotNull String topic, @NotNull DDMessagingMessageListener listener) { + private record Subscription(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) { } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransportTest.java similarity index 91% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransportTest.java index 71e98d7..8eb610c 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/rpc/DDMessagingRpcTransportTest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransportTest.java @@ -14,18 +14,18 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.rpc; - -import com.dbeaver.datadam.messaging.api.DDMessagingProtocol; -import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; -import com.dbeaver.datadam.messaging.api.model.DDMessagingMessage; -import com.dbeaver.datadam.messaging.api.requestreply.DDMessagingRequestReplyTransport; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannel; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelState; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingChannelStatus; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingConnectionListener; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingMessageListener; -import com.dbeaver.datadam.messaging.api.transport.DDMessagingSubscription; +package com.dbeaver.datadam.dam.remote.api.rpc; + +import com.dbeaver.datadam.dam.remote.api.DDRemoteDamProtocol; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; +import com.dbeaver.datadam.dam.remote.api.requestreply.DDRemoteDamRequestReplyTransport; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelState; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelStatus; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamMessageListener; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSessionListener; +import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSubscription; import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint; import com.dbeaver.messaging.requestreply.MessagingRequestReplyException; import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage; @@ -77,9 +77,9 @@ import java.util.function.Consumer; @Timeout(10) -class DDMessagingRpcTransportTest { +class DDRemoteDamRpcTransportTest { private static final String TOPIC = "custom.files"; - private static final DDDamConnectionInfo CONNECTION = new DDDamConnectionInfo("desktop", "run", "socket"); + private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("remoteDam", "run", "socket"); private static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 16, 8); private static final MessagingRpcCodec CODEC = new GsonCodec(); @@ -89,7 +89,7 @@ void delegatesRequestExecutionUnlessAnExplicitExecutorIsSupplied() { List queued = new ArrayList<>(); AtomicInteger calls = new AtomicInteger(); delegate.requestExecutor = queued::add; - DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); adapter.executeRequest(calls::incrementAndGet); Assertions.assertEquals(1, queued.size()); Assertions.assertEquals(0, calls.get()); @@ -103,7 +103,7 @@ void delegatesRequestExecutionUnlessAnExplicitExecutorIsSupplied() { Assertions.assertSame(unavailable, Assertions.assertThrows(RejectedExecutionException.class, () -> adapter.executeRequest(calls::incrementAndGet))); - DDMessagingRpcTransport inline = new DDMessagingRpcTransport(delegate, Runnable::run); + DDRemoteDamRpcTransport inline = new DDRemoteDamRpcTransport(delegate, Runnable::run); inline.executeRequest(calls::incrementAndGet); Assertions.assertEquals(2, calls.get()); } @@ -116,8 +116,8 @@ void channelRequestsRunInlineByDefaultAndCanUseABorrowedExecutor() throws Except channel.forward = channel::deliver; AtomicReference handlerThread = new AtomicReference<>(); try (var endpoint = MessagingRpcEndpoint.open(topic -> supplied - ? DDMessagingRpcTransport.forChannel(channel, topic, executor) - : DDMessagingRpcTransport.forChannel(channel, topic), CODEC, OPTIONS); + ? DDRemoteDamRpcTransport.forChannel(channel, topic, executor) + : DDRemoteDamRpcTransport.forChannel(channel, topic), CODEC, OPTIONS); var service = endpoint.register(TOPIC, Text.class, value -> { handlerThread.set(Thread.currentThread()); return "reply:" + value; @@ -150,7 +150,7 @@ void mapsSuppliedExecutorRejectionToRemoteOverloadAndAllowsFreshCalls() { }; var options = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 1, 1); try (var endpoint = MessagingRpcEndpoint.open( - topic -> DDMessagingRpcTransport.forChannel(channel, topic, executor), CODEC, options); + topic -> DDRemoteDamRpcTransport.forChannel(channel, topic, executor), CODEC, options); var service = endpoint.register(TOPIC, Text.class, value -> { calls.incrementAndGet(); return value; @@ -178,9 +178,9 @@ void supportsSynchronousCallbacksWithBorrowedRequestExecutorsAndIndependentChann left.forward = message -> deliveries.execute(() -> right.deliver(message)); right.forward = message -> deliveries.execute(() -> left.deliver(message)); try (var caller = MessagingRpcEndpoint.open( - topic -> DDMessagingRpcTransport.forChannel(left, topic, requests), CODEC, OPTIONS); + topic -> DDRemoteDamRpcTransport.forChannel(left, topic, requests), CODEC, OPTIONS); var handler = MessagingRpcEndpoint.open( - topic -> DDMessagingRpcTransport.forChannel(right, topic, requests), CODEC, OPTIONS); + topic -> DDRemoteDamRpcTransport.forChannel(right, topic, requests), CODEC, OPTIONS); var callback = caller.register(TOPIC, Text.class, value -> "callback:" + value); var service = handler.register(TOPIC, Text.class, value -> handler.proxy(TOPIC, Text.class).apply(value))) { Assertions.assertEquals("callback:value", caller.proxy(TOPIC, Text.class).apply("value")); @@ -200,10 +200,10 @@ void servesIndependentBidirectionalTopicsWithoutClosingTheBorrowedChannel() thro right.forward = left::deliver; AtomicInteger nullArguments = new AtomicInteger(); AtomicInteger notifications = new AtomicInteger(); - final DDMessagingSubscription borrowed = left.subscribe("other", (connection, message) -> { }); + final DDRemoteDamSubscription borrowed = left.subscribe("other", (session, message) -> { }); try (var caller = MessagingRpcEndpoint.open( - topic -> new DDMessagingRpcTransport(DDMessagingRequestReplyTransport.forChannel(left, topic)), CODEC, OPTIONS); - var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), CODEC, OPTIONS); + topic -> new DDRemoteDamRpcTransport(DDRemoteDamRequestReplyTransport.forChannel(left, topic)), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), CODEC, OPTIONS); var leftNames = caller.register("names", Names.class, values -> List.of("left", values.getFirst())); var rightNames = handler.register("names", Names.class, values -> List.of("right", values.getFirst())); var otherNames = handler.register("other-names", Names.class, values -> List.of("other")); @@ -253,8 +253,8 @@ void isolatesGsonArgumentsAndResultsAcrossRealMessageChannels() throws Exception right.forward = left::deliver; AtomicReference received = new AtomicReference<>(); Item argument = new Item("argument", new ArrayList<>(List.of("initial"))); - try (var caller = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(left), CODEC, OPTIONS); - var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), CODEC, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), CODEC, OPTIONS); var service = handler.register("records", Records.class, value -> { received.set(value); value.tags().add("handler"); @@ -276,9 +276,9 @@ void isolatesGsonArgumentsAndResultsAcrossRealMessageChannels() throws Exception void roundTripsZeroArgumentsAndOverloadsUsingEveryParameterAcrossJsonFraming() { FakeChannel left = new FakeChannel(); FakeChannel right = new FakeChannel(); - left.forward = message -> right.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); - right.forward = message -> left.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); - var codec = new DDMessagingGsonRpcCodec(new Gson()); + left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + var codec = new DDRemoteDamGsonRpcCodec(new Gson()); Overloaded implementation = new Overloaded() { @Override @NotNull @@ -310,8 +310,8 @@ public String apply(@Nullable String first, @Nullable String second, @Nullable S return "three:" + first + ":" + second + ":" + third; } }; - try (var caller = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(left), codec, OPTIONS); - var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), codec, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), codec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), codec, OPTIONS); var service = handler.register(TOPIC, Overloaded.class, implementation)) { Overloaded proxy = caller.proxy(TOPIC, Overloaded.class); Assertions.assertEquals("zero", proxy.apply()); @@ -346,11 +346,11 @@ public String apply(@Nullable String first, @Nullable String second, @Nullable S void roundTripsVarargsAsOneArrayArgumentAcrossJsonFraming() { FakeChannel left = new FakeChannel(); FakeChannel right = new FakeChannel(); - left.forward = message -> right.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); - right.forward = message -> left.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); - var codec = new DDMessagingGsonRpcCodec(new Gson()); - try (var caller = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(left), codec, OPTIONS); - var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), codec, OPTIONS); + left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + var codec = new DDRemoteDamGsonRpcCodec(new Gson()); + try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), codec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), codec, OPTIONS); var service = handler.register(TOPIC, Varargs.class, (prefix, values) -> prefix + ":" + (values == null ? "null" : Arrays.toString(values)))) { Varargs proxy = caller.proxy(TOPIC, Varargs.class); @@ -373,15 +373,15 @@ void roundTripsVarargsAsOneArrayArgumentAcrossJsonFraming() { void roundTripsEachArgumentsDeclaredRecordAndGenericTypesAcrossJsonFraming() throws Exception { FakeChannel left = new FakeChannel(); FakeChannel right = new FakeChannel(); - left.forward = message -> right.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); - right.forward = message -> left.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); - CountingCodec callerCodec = new CountingCodec(new DDMessagingGsonRpcCodec(new Gson())); - CountingCodec handlerCodec = new CountingCodec(new DDMessagingGsonRpcCodec(new Gson())); + left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + CountingCodec callerCodec = new CountingCodec(new DDRemoteDamGsonRpcCodec(new Gson())); + CountingCodec handlerCodec = new CountingCodec(new DDRemoteDamGsonRpcCodec(new Gson())); Item selected = new Item("selected", List.of("chosen")); List items = List.of(new Item("nested", List.of("tag"))); List numbers = List.of(7, 8); - try (var caller = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(left), callerCodec, OPTIONS); - var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), handlerCodec, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), callerCodec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), handlerCodec, OPTIONS); var service = handler.register(TOPIC, RecordArguments.class, (label, item, values, counts) -> { Assertions.assertEquals(items, values); Assertions.assertEquals(numbers, counts); @@ -413,12 +413,12 @@ void roundTripsEachArgumentsDeclaredRecordAndGenericTypesAcrossJsonFraming() thr void rejectsWrongArgumentCountsAndNullPrimitiveArgumentsAcrossJsonFraming() throws Exception { FakeChannel left = new FakeChannel(); FakeChannel right = new FakeChannel(); - left.forward = message -> right.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); - right.forward = message -> left.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); + left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); AtomicInteger calls = new AtomicInteger(); - var codec = new DDMessagingGsonRpcCodec(new Gson()); - try (var caller = MessagingRequestReplyEndpoint.open(DDMessagingRpcTransport.forChannel(left, TOPIC), OPTIONS); - var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), codec, OPTIONS); + var codec = new DDRemoteDamGsonRpcCodec(new Gson()); + try (var caller = MessagingRequestReplyEndpoint.open(DDRemoteDamRpcTransport.forChannel(left, TOPIC), OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), codec, OPTIONS); var service = handler.register(TOPIC, Numbers.class, (first, second, third) -> { calls.incrementAndGet(); return first + second + third; @@ -441,7 +441,7 @@ void rejectsWrongArgumentCountsAndNullPrimitiveArgumentsAcrossJsonFraming() thro @Test void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); Received received = new Received(); MessagingRequestReplyMessage> request = request(endpoint, new JsonPrimitive("argument")); @@ -502,7 +502,7 @@ void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception { @Test void roundTripsStructuredDiagnosticsAndExplicitNullMessagesWithoutChangingOuterFrames() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); Received received = new Received(); MessagingRequestReplyMessage> request = request(endpoint, null); @@ -544,7 +544,7 @@ void roundTripsStructuredDiagnosticsAndExplicitNullMessagesWithoutChangingOuterF @Test void maximumDiagnosticsFitTheFullFramedMessageBudgetUnderEscapingAndUnicode() throws Exception { FakeChannel channel = new FakeChannel(); - DDMessagingRpcTransport adapter = DDMessagingRpcTransport.forChannel(channel, "x".repeat(256)); + DDRemoteDamRpcTransport adapter = DDRemoteDamRpcTransport.forChannel(channel, "x".repeat(256)); UUID endpoint = UUID.randomUUID(); Received received = new Received(); MessagingRequestReplyMessage> request = request(endpoint, null); @@ -560,7 +560,7 @@ void maximumDiagnosticsFitTheFullFramedMessageBudgetUnderEscapingAndUnicode() th MessagingRequestReplyMessage> failure = request.response( MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION, details)); adapter.send(failure).toCompletableFuture().join(); - DDMessagingMessage wire = channel.sent.getLast(); + DDRemoteDamMessage wire = channel.sent.getLast(); Assertions.assertEquals("datadam.requestreply.v1", wire.type()); JsonObject outer = wire.payload(); Assertions.assertEquals(Set.of("requestId", "replyTo", "type", "data"), outer.keySet()); @@ -568,9 +568,9 @@ void maximumDiagnosticsFitTheFullFramedMessageBudgetUnderEscapingAndUnicode() th Assertions.assertEquals(endpoint.toString(), outer.get("replyTo").getAsString()); Assertions.assertEquals("RESPONSE", outer.get("type").getAsString()); Assertions.assertEquals("dbeaver.rpc.v2", outer.getAsJsonObject("data").get("protocol").getAsString()); - String encoded = DDMessagingProtocol.encode(wire); + String encoded = DDRemoteDamProtocol.encode(wire); Assertions.assertTrue(encoded.getBytes(StandardCharsets.UTF_8).length <= 65_536); - channel.deliver(DDMessagingProtocol.decode(encoded)); + channel.deliver(DDRemoteDamProtocol.decode(encoded)); Assertions.assertEquals(failure, received.messages.getLast()); } Assertions.assertEquals(5, channel.sent.size()); @@ -581,7 +581,7 @@ void maximumDiagnosticsFitTheFullFramedMessageBudgetUnderEscapingAndUnicode() th @Test void rejectsMalformedExceptionDetailsInErrorEnvelopes() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); Received received = new Received(); MessagingRequestReplyMessage request = @@ -602,7 +602,7 @@ void rejectsMalformedExceptionDetailsInErrorEnvelopes() throws Exception { @Test void roundTripsZeroSingleAndMultipleArgumentsAsJsonArrays() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); Received received = new Received(); List> cases = List.of(List.of(), Collections.singletonList(null), List.of(new JsonPrimitive("single")), @@ -640,7 +640,7 @@ void roundTripsZeroSingleAndMultipleArgumentsAsJsonArrays() throws Exception { @Test void acceptsArbitraryJsonValuesAndNormalizesEncodedNull() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); Received received = new Received(); List values = List.of(JsonParser.parseString("{\"items\":[1,true,null]}"), @@ -674,7 +674,7 @@ void acceptsArbitraryJsonValuesAndNormalizesEncodedNull() throws Exception { @Test void isolatesSnapshotsEvenWhenTheBorrowedDelegateDoesNotCopy() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); Received received = new Received(); try (var subscription = adapter.subscribe(endpoint, received)) { @@ -705,7 +705,7 @@ void isolatesSnapshotsEvenWhenTheBorrowedDelegateDoesNotCopy() throws Exception @Test void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); Received received = new Received(); UUID endpoint = UUID.randomUUID(); MessagingRequestReplyMessage request = @@ -764,7 +764,7 @@ void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception @Test void dropsMalformedResponseAndNestedErrorShapes() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); Received received = new Received(); UUID endpoint = UUID.randomUUID(); MessagingRequestReplyMessage request = @@ -818,16 +818,16 @@ void dropsMalformedResponseAndNestedErrorShapes() throws Exception { @Test void requiresObjectEnvelopesThroughExistingDataDamFraming() throws Exception { FakeChannel channel = new FakeChannel(); - DDMessagingRpcTransport adapter = DDMessagingRpcTransport.forChannel(channel, TOPIC); + DDRemoteDamRpcTransport adapter = DDRemoteDamRpcTransport.forChannel(channel, TOPIC); UUID endpoint = UUID.randomUUID(); Received received = new Received(); try (var subscription = adapter.subscribe(endpoint, received)) { adapter.send(request(endpoint, null)).toCompletableFuture().join(); - DDMessagingMessage valid = channel.sent.getLast(); + DDRemoteDamMessage valid = channel.sent.getLast(); for (JsonElement value : List.of(new JsonPrimitive("text"), new JsonArray(), JsonNull.INSTANCE)) { JsonObject invalid = valid.payload(); invalid.add("data", value); - channel.deliver(new DDMessagingMessage(valid.topic(), valid.type(), invalid)); + channel.deliver(new DDRemoteDamMessage(valid.topic(), valid.type(), invalid)); } Assertions.assertTrue(received.messages.isEmpty()); channel.deliver(valid); @@ -838,7 +838,7 @@ void requiresObjectEnvelopesThroughExistingDataDamFraming() throws Exception { @Test void neutralRpcDistinguishesNestedErrorsOuterErrorsAndInvalidResponses() throws Exception { MemoryTransport delegate = new MemoryTransport(); - try (var endpoint = MessagingRpcEndpoint.open(topic -> new DDMessagingRpcTransport(delegate), CODEC, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(topic -> new DDRemoteDamRpcTransport(delegate), CODEC, OPTIONS)) { Text proxy = endpoint.proxy("text", Text.class); for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) { delegate.forward = request -> delegate.deliver(request.response(errorEnvelope(error))); @@ -869,7 +869,7 @@ void neutralRpcDistinguishesNestedErrorsOuterErrorsAndInvalidResponses() throws void malformedAndUnknownVersionRepliesRemainUnansweredUntilTimeout() throws Exception { MemoryTransport delegate = new MemoryTransport(); MessagingRequestReplyOptions options = new MessagingRequestReplyOptions(Duration.ofMillis(100), 4, 2); - try (var endpoint = MessagingRpcEndpoint.open(topic -> new DDMessagingRpcTransport(delegate), CODEC, options)) { + try (var endpoint = MessagingRpcEndpoint.open(topic -> new DDRemoteDamRpcTransport(delegate), CODEC, options)) { for (Consumer mutation : List.>of( envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v1"), envelope -> envelope.addProperty("type", "UNKNOWN"), @@ -892,9 +892,9 @@ void dispatchesRealRpcFailuresInsideSuccessfulRequestReplyResponses() throws Exc FakeChannel right = new FakeChannel(); left.forward = right::deliver; right.forward = left::deliver; - try (var caller = MessagingRequestReplyEndpoint.open(DDMessagingRpcTransport.forChannel(left, TOPIC), OPTIONS); - var missingCaller = MessagingRequestReplyEndpoint.open(DDMessagingRpcTransport.forChannel(left, "missing"), OPTIONS); - var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), CODEC, OPTIONS); + try (var caller = MessagingRequestReplyEndpoint.open(DDRemoteDamRpcTransport.forChannel(left, TOPIC), OPTIONS); + var missingCaller = MessagingRequestReplyEndpoint.open(DDRemoteDamRpcTransport.forChannel(left, "missing"), OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), CODEC, OPTIONS); var service = handler.register(TOPIC, Text.class, value -> { throw new IllegalStateException("Handler failed"); })) { @@ -931,8 +931,8 @@ void dispatchesRealRpcFailuresInsideSuccessfulRequestReplyResponses() throws Exc void proxyFailuresExposeApplicationDiagnosticsWithNestedAndSuppressedTracesAcrossReferenceFraming() { FakeChannel left = new FakeChannel(); FakeChannel right = new FakeChannel(); - left.forward = message -> right.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); - right.forward = message -> left.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); + left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); Throwable root = new UnsupportedOperationException("root cause"); Throwable nested = new IllegalArgumentException("nested cause", root); Throwable suppressed = new IllegalArgumentException("suppressed failure"); @@ -946,8 +946,8 @@ void proxyFailuresExposeApplicationDiagnosticsWithNestedAndSuppressedTracesAcros CountingCodec callerCodec = new CountingCodec(); CountingCodec handlerCodec = new CountingCodec(); AtomicInteger calls = new AtomicInteger(); - try (var caller = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(left), callerCodec, OPTIONS); - var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), handlerCodec, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), callerCodec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), handlerCodec, OPTIONS); var service = handler.register(TOPIC, Text.class, value -> { calls.incrementAndGet(); throw application; @@ -992,8 +992,8 @@ void remoteArgumentAndResultCodecFailuresCarryDiagnosticsWithoutEncodingErrorsOr for (boolean encoding : List.of(false, true)) { FakeChannel left = new FakeChannel(); FakeChannel right = new FakeChannel(); - left.forward = message -> right.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); - right.forward = message -> left.deliver(DDMessagingProtocol.decode(DDMessagingProtocol.encode(message))); + left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); + right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message))); CountingCodec callerCodec = new CountingCodec(); AtomicInteger encodes = new AtomicInteger(); AtomicInteger decodes = new AtomicInteger(); @@ -1017,8 +1017,8 @@ public Object decode(@Nullable JsonElement payload, @NotNull Type type) throws E return CODEC.decode(payload, type); } }; - try (var caller = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(left), callerCodec, OPTIONS); - var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), handlerCodec, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), callerCodec, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), handlerCodec, OPTIONS); var service = handler.register(TOPIC, Text.class, value -> { calls.incrementAndGet(); return value; @@ -1046,7 +1046,7 @@ public Object decode(@Nullable JsonElement payload, @NotNull Type type) throws E @Test void rejectsMismatchedOutgoingFramesBeforeCallingTheDelegate() { MemoryTransport delegate = new MemoryTransport(); - DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); UUID endpoint = UUID.randomUUID(); MessagingRequestReplyMessage> request = request(endpoint, null); List>> malformed = List.of( @@ -1066,7 +1066,7 @@ void rejectsMismatchedOutgoingFramesBeforeCallingTheDelegate() { @Test void preservesAsynchronousAndSynchronousSendFailuresWithoutRetriesOrWrapping() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); MessagingRequestReplyMessage> request = request(UUID.randomUUID(), null); CompletableFuture pending = new CompletableFuture<>(); delegate.sendResult = pending; @@ -1099,7 +1099,7 @@ void preservesAsynchronousAndSynchronousSendFailuresWithoutRetriesOrWrapping() t @Test void boundsFramingBeforeCopyingAndHoldsPermitsUntilActualSendCompletion() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); AtomicInteger copies = new AtomicInteger(); JsonElement value = new JsonElement() { @Override @@ -1171,7 +1171,7 @@ public JsonElement deepCopy() { @Test void releasesPermitsAfterSnapshotFramingFailures() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); IllegalStateException unavailable = new IllegalStateException("Snapshot unavailable"); JsonElement value = new JsonElement() { @Override @@ -1194,7 +1194,7 @@ public JsonElement deepCopy() { @Test void forwardsDisconnectsAndDelegatesSubscriptionOwnership() throws Exception { MemoryTransport delegate = new MemoryTransport(); - DDMessagingRpcTransport adapter = new DDMessagingRpcTransport(delegate); + DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate); Received received = new Received(); MessagingSubscription subscription = adapter.subscribe(UUID.randomUUID(), received); Assertions.assertSame(delegate.subscription, subscription); @@ -1206,7 +1206,7 @@ void forwardsDisconnectsAndDelegatesSubscriptionOwnership() throws Exception { MemoryTransport failing = new MemoryTransport(); failing.subscribeFailure = unavailable; MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, - () -> new DDMessagingRpcTransport(failing).subscribe(UUID.randomUUID(), new Received())); + () -> new DDRemoteDamRpcTransport(failing).subscribe(UUID.randomUUID(), new Received())); Assertions.assertSame(unavailable, failure); } @@ -1214,7 +1214,7 @@ void forwardsDisconnectsAndDelegatesSubscriptionOwnership() throws Exception { void rollsBackDelegatedChannelSubscriptionsWithoutClosingTheBorrowedChannel() { FakeChannel channel = new FakeChannel(); channel.failTopic = TOPIC; - DDMessagingRpcTransport adapter = DDMessagingRpcTransport.forChannel(channel, TOPIC); + DDRemoteDamRpcTransport adapter = DDRemoteDamRpcTransport.forChannel(channel, TOPIC); MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received())); Assertions.assertSame(channel.subscriptionFailure, failure.getCause()); @@ -1229,7 +1229,7 @@ void rollsBackDelegatedChannelSubscriptionsWithoutClosingTheBorrowedChannel() { void failedLazyTopicSubscriptionRollsBackAndCanBeRetried() { FakeChannel channel = new FakeChannel(); channel.failTopic = TOPIC; - try (var endpoint = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(channel), CODEC, OPTIONS)) { + try (var endpoint = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(channel), CODEC, OPTIONS)) { MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> endpoint.proxy(TOPIC, Text.class)); Assertions.assertEquals(MessagingRpcException.Reason.SEND_FAILED, failure.reason()); Assertions.assertSame(channel.subscriptionFailure, failure.getCause().getCause()); @@ -1263,8 +1263,8 @@ public Object decode(@Nullable String payload, @NotNull Type type) { return payload; } }; - try (var caller = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(left), CODEC, OPTIONS); - var handler = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(right), CODEC, OPTIONS); + try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), CODEC, OPTIONS); + var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), CODEC, OPTIONS); var service = handler.register(TOPIC, Text.class, value -> value)) { MessagingRpcException sendFailure = Assertions.assertThrows(MessagingRpcException.class, () -> caller.proxy(TOPIC, Text.class, incompatible).apply("incompatible")); @@ -1288,8 +1288,8 @@ public Object decode(@Nullable String payload, @NotNull Type type) { @Test void channelDisconnectTerminatesPendingRpcAndClosingLeavesTheChannelIntact() throws Exception { FakeChannel channel = new FakeChannel(); - channel.forward = message -> channel.setStatus(new DDMessagingChannelStatus(DDMessagingChannelState.DISCONNECTED, null)); - try (var endpoint = MessagingRpcEndpoint.open(DDMessagingRpcTransport.forChannel(channel), CODEC, OPTIONS)) { + channel.forward = message -> channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null)); + try (var endpoint = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(channel), CODEC, OPTIONS)) { MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> endpoint.proxy("text", Text.class).apply("request")); Assertions.assertEquals(MessagingRpcException.Reason.DISCONNECTED, failure.reason()); @@ -1563,27 +1563,27 @@ private void deliver(@NotNull MessagingRequestReplyMessage message) } } - private static final class FakeChannel implements DDMessagingChannel { + private static final class FakeChannel implements DDRemoteDamChannel { private final List subscriptions = new CopyOnWriteArrayList<>(); - private final List> observers = new CopyOnWriteArrayList<>(); - private final List sent = new CopyOnWriteArrayList<>(); + private final List> observers = new CopyOnWriteArrayList<>(); + private final List sent = new CopyOnWriteArrayList<>(); private final RuntimeException subscriptionFailure = new IllegalStateException("Subscription unavailable"); - private DDMessagingChannelStatus status = new DDMessagingChannelStatus(DDMessagingChannelState.CONNECTED, CONNECTION); + private DDRemoteDamChannelStatus status = new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, SESSION); @Nullable private String failTopic; @Nullable - private Consumer forward; + private Consumer forward; private boolean closed; @Override @NotNull - public DDMessagingChannelStatus getStatus() { + public DDRemoteDamChannelStatus getStatus() { return status; } @Override @NotNull - public DDMessagingSubscription observeState(@NotNull Consumer observer) { + public DDRemoteDamSubscription observeState(@NotNull Consumer observer) { observers.add(observer); observer.accept(status); return () -> observers.remove(observer); @@ -1591,7 +1591,7 @@ public DDMessagingSubscription observeState(@NotNull Consumer send(@NotNull DDMessagingMessage message) { + public CompletionStage send(@NotNull DDRemoteDamMessage message) { sent.add(message); if (forward != null) { forward.accept(message); @@ -1601,7 +1601,7 @@ public CompletionStage send(@NotNull DDMessagingMessage message) { @Override @NotNull - public DDMessagingSubscription subscribe(@NotNull String topic, @NotNull DDMessagingMessageListener listener) { + public DDRemoteDamSubscription subscribe(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) { if (topic.equals(failTopic)) { throw subscriptionFailure; } @@ -1612,19 +1612,19 @@ public DDMessagingSubscription subscribe(@NotNull String topic, @NotNull DDMessa @Override @NotNull - public DDMessagingSubscription addConnectionListener(@NotNull DDMessagingConnectionListener listener) { + public DDRemoteDamSubscription addSessionListener(@NotNull DDRemoteDamSessionListener listener) { throw new UnsupportedOperationException(); } - private void deliver(@NotNull DDMessagingMessage message) { + private void deliver(@NotNull DDRemoteDamMessage message) { for (TopicSubscription subscription : subscriptions) { if (subscription.active && subscription.topic.equals(message.topic())) { - subscription.listener.onMessage(CONNECTION, message); + subscription.listener.onMessage(SESSION, message); } } } - private void setStatus(@NotNull DDMessagingChannelStatus next) { + private void setStatus(@NotNull DDRemoteDamChannelStatus next) { status = next; observers.forEach(observer -> observer.accept(next)); } @@ -1637,10 +1637,10 @@ public void close() { private static final class TopicSubscription { private final String topic; - private final DDMessagingMessageListener listener; + private final DDRemoteDamMessageListener listener; private volatile boolean active = true; - private TopicSubscription(@NotNull String topic, @NotNull DDMessagingMessageListener listener) { + private TopicSubscription(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) { this.topic = topic; this.listener = listener; } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatusTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatusTest.java similarity index 55% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatusTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatusTest.java index 4b17e51..fc58fe0 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/datadam/messaging/api/transport/DDMessagingChannelStatusTest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatusTest.java @@ -14,30 +14,30 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.messaging.api.transport; +package com.dbeaver.datadam.dam.remote.api.transport; -import com.dbeaver.datadam.messaging.api.model.DDDamConnectionInfo; +import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -class DDMessagingChannelStatusTest { - private static final DDDamConnectionInfo CONNECTION = new DDDamConnectionInfo("deployment", "run", "socket"); +class DDRemoteDamChannelStatusTest { + private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("deployment", "run", "socket"); @Test - void onlyConnectedStatusIsReadyAndCarriesConnectionInformation() { - for (DDMessagingChannelState state : DDMessagingChannelState.values()) { - boolean connected = state == DDMessagingChannelState.CONNECTED; - DDMessagingChannelStatus status = new DDMessagingChannelStatus(state, connected ? CONNECTION : null); + void onlyConnectedStatusIsReadyAndCarriesSessionInformation() { + for (DDRemoteDamChannelState state : DDRemoteDamChannelState.values()) { + boolean connected = state == DDRemoteDamChannelState.CONNECTED; + DDRemoteDamChannelStatus status = new DDRemoteDamChannelStatus(state, connected ? SESSION : null); Assertions.assertEquals(connected, status.isReady()); - Assertions.assertEquals(connected ? CONNECTION : null, status.connection()); + Assertions.assertEquals(connected ? SESSION : null, status.session()); Assertions.assertThrows(IllegalArgumentException.class, - () -> new DDMessagingChannelStatus(state, connected ? null : CONNECTION)); + () -> new DDRemoteDamChannelStatus(state, connected ? null : SESSION)); } } @Test void rejectsMissingState() { - Assertions.assertThrows(NullPointerException.class, () -> new DDMessagingChannelStatus(null, null)); + Assertions.assertThrows(NullPointerException.class, () -> new DDRemoteDamChannelStatus(null, null)); } } diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyDeadlineTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyDeadlineTest.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyDeadlineTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyDeadlineTest.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpointTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpointTest.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpointTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpointTest.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessageTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessageTest.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessageTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessageTest.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptionsTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptionsTest.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptionsTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptionsTest.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java similarity index 86% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java index 358ea0d..5c7b4ad 100644 --- a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcContractTest.java @@ -30,6 +30,10 @@ import java.util.concurrent.CompletionStage; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -48,13 +52,12 @@ void rejectsInvalidContractsBeforeSendingAndLeavesTheTopicAvailable() throws Exc try (var endpoint = MessagingRpcEndpoint.open(transport, OPTIONS)) { for (Class type : List.of( String.class, HiddenService.class, SealedService.class, GenericService.class, RawGenericService.class, - EmptyService.class, StaticOnlyService.class, ObjectOnlyService.class, DefaultService.class, + EmptyService.class, StaticOnlyService.class, ObjectOnlyService.class, DefaultOnlyService.class, GenericMethodService.class, FutureService.class, StageService.class, CompletableService.class )) { assertThrows(IllegalArgumentException.class, () -> endpoint.proxy("service", type, codec), type.getName()); assertThrows(IllegalArgumentException.class, () -> endpoint.register("service", type, null, codec), type.getName()); } - assertThrows(IllegalArgumentException.class, () -> endpoint.register("service", DefaultService.class, value -> value, codec)); try (var registration = endpoint.register("service", StaticService.class, value -> value, codec)) { assertTrue(transport.sent.isEmpty()); assertTrue(codec.encoded.isEmpty()); @@ -63,6 +66,89 @@ void rejectsInvalidContractsBeforeSendingAndLeavesTheTopicAvailable() throws Exc } } + @Test + void logsAndSkipsEachUnsupportedMethodWithoutDiscardingUsableOperations() throws Exception { + List records = new ArrayList<>(); + Logger logger = Logger.getLogger(MessagingRpcContract.class.getName()); + Handler handler = new Handler() { + @Override + public void publish(@NotNull LogRecord record) { + records.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + logger.addHandler(handler); + try { + var contract = MessagingRpcContract.create(MixedService.class); + assertEquals(DefaultService.class.getMethod("echo", String.class), contract.operation("echo(java.lang.String)")); + assertEquals(5, records.size()); + for (String name : List.of("local", "generic", "future", "stage", "asynchronous")) { + var method = Arrays.stream(MixedService.class.getMethods()).filter(value -> value.getName().equals(name)) + .findFirst().orElseThrow(); + assertNull(contract.operation(MessagingRpcContract.operationId(method))); + assertTrue(records.stream().anyMatch(record -> record.getLevel() == Level.WARNING + && record.getMessage().contains(method.toGenericString()) + && record.getThrown() instanceof IllegalArgumentException)); + } + } finally { + logger.removeHandler(handler); + handler.close(); + } + } + + @Test + void skipsUnsupportedCovariantReturnBridgesWithoutDiscardingUsableOperations() throws Exception { + assertTrue(Arrays.stream(MixedCovariantService.class.getDeclaredMethods()).anyMatch(method -> method.isBridge())); + var contract = MessagingRpcContract.create(MixedCovariantService.class); + assertEquals(MixedCovariantService.class.getMethod("supported", String.class), + contract.operation("supported(java.lang.String)")); + assertNull(contract.operation("echo(java.lang.String)")); + } + + @Test + void dispatchesSupportedMethodsAndRejectsSkippedMethodsWithoutSendingOrDecodingThem() throws Exception { + var clientTransport = new MemoryTransport(); + var serverTransport = new MemoryTransport(); + var codec = new TestCodec(); + connect(clientTransport, serverTransport); + try (var client = MessagingRpcEndpoint.open(clientTransport, OPTIONS); + var raw = MessagingRequestReplyEndpoint.open(clientTransport.forTopic("raw"), OPTIONS); + var server = MessagingRpcEndpoint.open(serverTransport, OPTIONS); + var registration = server.register("mixed", DefaultService.class, value -> "reply:" + value, codec); + var rawRegistration = server.register("raw", DefaultService.class, value -> value, codec)) { + DefaultService proxy = client.proxy("mixed", DefaultService.class, codec); + assertEquals("reply:value", proxy.echo("value")); + int sent = clientTransport.sent.size(); + int encoded = codec.encoded.size(); + final int decoded = codec.decoded.size(); + assertThrows(IllegalArgumentException.class, () -> proxy.local("local")); + assertEquals(sent, clientTransport.sent.size()); + assertEquals(encoded, codec.encoded.size()); + assertEquals(decoded, codec.decoded.size()); + assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION), + raw.request(MessagingRpcMessage.request("local(java.lang.String)", List.of("local")))); + assertEquals(encoded, codec.encoded.size()); + assertEquals(decoded, codec.decoded.size()); + assertEquals("reply:again", proxy.echo("again")); + } + } + + @Test + void rejectsContractsWithoutUsableMethodsWithTheSameGuardAsEmptyContracts() { + for (Class type : List.of(EmptyService.class, DefaultOnlyService.class, GenericMethodService.class, + FutureService.class, StageService.class, CompletableService.class, RawGenericService.class)) { + var failure = assertThrows(IllegalArgumentException.class, () -> MessagingRpcContract.create(type)); + assertEquals("RPC contract must declare at least one usable operation", failure.getMessage()); + } + } + @Test void invokesZeroAndTwoArgumentOperationsWithTheDeclaredArgumentLists() throws Exception { var clientTransport = new MemoryTransport(); @@ -542,6 +628,36 @@ default String local(@NotNull String value) { } } + public interface DefaultOnlyService { + @NotNull + default String local(@NotNull String value) { + return value; + } + } + + public interface MixedService extends DefaultService { + @NotNull + T generic(@NotNull T value); + + @NotNull + Future future(); + + @NotNull + CompletionStage stage(); + + @NotNull + CompletableFuture asynchronous(); + } + + public interface MixedCovariantService extends FutureService { + @NotNull + @Override + CompletableFuture echo(@NotNull String value); + + @NotNull + String supported(@NotNull String value); + } + public interface VarargsService { @NotNull String echo(@Nullable String... values); diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointFacadeTest.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcEndpointTest.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetailsTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetailsTest.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetailsTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetailsTest.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionTest.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionTest.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcMessageTest.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcTestSupport.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcTestSupport.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcTestSupport.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/MessagingRpcTestSupport.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/HiddenInheritedService.java diff --git a/apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java similarity index 100% rename from apis/com.dbeaver.datadam.messaging.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java rename to apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/messaging/rpc/fixtures/PublicInheritedService.java diff --git a/apis/com.dbeaver.datadam.messaging.api/META-INF/MANIFEST.MF b/apis/com.dbeaver.datadam.messaging.api/META-INF/MANIFEST.MF deleted file mode 100644 index fe34762..0000000 --- a/apis/com.dbeaver.datadam.messaging.api/META-INF/MANIFEST.MF +++ /dev/null @@ -1,21 +0,0 @@ -Manifest-Version: 1.0 -Bundle-ManifestVersion: 2 -Bundle-Vendor: DBeaver Corp -Bundle-Name: DataDam Messaging API -Bundle-SymbolicName: com.dbeaver.datadam.messaging.api -Bundle-Version: 0.0.17.qualifier -Bundle-RequiredExecutionEnvironment: JavaSE-21 -Export-Package: com.dbeaver.datadam.messaging.api, - com.dbeaver.datadam.messaging.api.client, - com.dbeaver.datadam.messaging.api.exception, - com.dbeaver.datadam.messaging.api.model, - com.dbeaver.datadam.messaging.api.requestreply, - com.dbeaver.datadam.messaging.api.rpc, - com.dbeaver.datadam.messaging.api.transport, - com.dbeaver.messaging.requestreply, - com.dbeaver.messaging.rpc -Bundle-ActivationPolicy: lazy -Require-Bundle: org.jkiss.utils, - com.dbeaver.rest.client;visibility:=reexport -Import-Package: com.google.gson -Automatic-Module-Name: com.dbeaver.datadam.messaging.api diff --git a/apis/com.dbeaver.datadam.messaging.api/README.md b/apis/com.dbeaver.datadam.messaging.api/README.md deleted file mode 100644 index 852c140..0000000 --- a/apis/com.dbeaver.datadam.messaging.api/README.md +++ /dev/null @@ -1,404 +0,0 @@ -# Messaging API - -The `com.dbeaver.datadam.messaging.api` module contains the Desktop WebSocket contracts, a backend-only client for -the External DAM internal GraphQL API, and the transport-independent messaging packages. -Neither the models nor the WebSocket protocol depend on the HTTP client, Spring, or Eclipse runtime services. - -The WebSocket handshake sends Desktop metadata in `DDMessagingHandshakeRequest` and returns the assigned connection ID -in `DDMessagingHandshakeResponse`. The corresponding wire control types are `hello` and `ready`. - -Java package names are `com.dbeaver.datadam.messaging.api.*` and `com.dbeaver.messaging.*`. -See [Messaging](MESSAGING.md) for the request/reply and typed RPC contracts. - -## Internal GraphQL contract - -The backend client posts JSON requests to `/internal/graphql` on the configured External DAM service root URL. -The service implementation must expose the following contract; adding the client alone does not deploy the endpoint. - -```graphql -scalar JSON - -type Query { - externalDamConnections(accountId: ID!): [ExternalDamConnection!]! - externalDamConnectionAccount(connectionId: ID!): ID -} - -type Mutation { - sendExternalDamMessage(accountId: ID!, connectionId: ID!, message: ExternalDamMessageInput!): Boolean! - registerExternalDamSubscription(input: ExternalDamSubscriptionInput!): Int! - unregisterExternalDamSubscription(subscriberId: String!, subscriptionId: ID!): Boolean! -} - -type ExternalDamConnection { - deploymentId: String! - applicationRunId: String! - connectionId: ID! - product: String! - version: String! -} - -input ExternalDamMessageInput { - topic: String! - type: String! - payload: JSON! -} - -input ExternalDamSubscriptionInput { - subscriberId: String! - subscriptionId: ID! - accountId: ID! - connectionId: ID - topic: String! - callbackUrl: String! - callbackSecret: String! -} -``` - -- `accountId` is a UUID and is mandatory for account-wide connection lists, sends, and subscription registration. - Sending must use the account and connection ID together. -- `externalDamConnectionAccount` resolves a unique physical connection ID to the account from its verified identity. - It returns null for a missing or not-ready connection. The backend client reports that result as a checked - `DDMessagingException`. This backend-only lookup does not authorize end-user access to that account. -- Connection lists return an immutable client-side snapshot of currently ready sockets, not persisted DAM installations or history. -- Concurrent sockets can share a Deployment ID and application run ID. Consumers must not deduplicate sockets using either ID. -- Product and version are Desktop-supplied presentation metadata, not authenticated identity or authorization claims. -- Message payloads must be JSON objects. They are passed through GraphQL variables, not interpolated into query text or encoded as JSON strings. -- A send result of `true` means the server completed its WebSocket write. It does not confirm that Desktop received or processed the event. -- A result of `false` means that no ready connection exists for that account and connection ID. This also covers a connection belonging to another account. -- A connection closing after lookup, queue rejection, an invalid message, or a write failure produces a GraphQL error rather than a successful send. -- HTTP errors, GraphQL errors (including errors with partial data), and malformed responses become `DDMessagingException`. -- The client does not retry mutations or queue messages offline. After a timeout or network failure, delivery may be unknown; callers must not blindly replay non-idempotent events. -- Connections live in one External DAM process. Until owner-aware routing exists, calls must reach the process owning the socket; a missing result from another replica does not prove global offline status. - -## Authentication and configuration - -`DDMessagingClient` takes a service root URL and the External DAM service secret. It never logs in or acquires a Desktop token. -The URL must use HTTP or HTTPS, have no credentials, query, fragment, or non-root path, and use a valid port. -HTTP is supported for trusted private service networks; use HTTPS across untrusted networks. - -The External DAM service secret must contain 32–512 printable, non-space ASCII characters. It authenticates -both backend-to-broker requests and broker-to-backend callbacks through the `X-DD-Service-Token` header. -Subscription registration also carries this same secret in its `callbackSecret` variable for protocol compatibility; -protect that backend-only request and never log its body. The secret must never appear in URLs or GraphQL query text. -Redirects are disabled to avoid forwarding credentials to another endpoint. -Connection establishment uses the shared REST client's 5-second default connect timeout; responses have a 10-second deadline. -HTTP error bodies, GraphQL error details, and malformed response contents are not copied into client exceptions. - -The service secret authenticates a trusted backend, not an end user. A backend exposing these operations to users must -derive `accountId` from its authorized account context rather than trusting an arbitrary account ID supplied by the caller. -Likewise, a connection ID alone is not end-user authorization; only expose selections from an authorized account's list. -The server must validate the service secret before executing internal GraphQL operations and fail closed when its secret -is missing or invalid. A browser session or Desktop JWT must not grant access to this endpoint. -The secret belongs to backend runtime configuration only and must never be distributed to Desktop or browser clients. -It is separate from the Account backchannel secret used to verify Desktop JWTs. - -```java -var client = new DDMessagingClient(serviceUrl, serviceSecret); -var connections = client.getConnections(accountId); -boolean written = client.sendMessage(accountId, connectionId, message); -``` - -Use one configured client per service endpoint instead of constructing one per request. No deployment-specific URL or secret is packaged here. -The client also exposes `getConnectionsAsync`, `getConnectionAccountAsync`, `sendMessageAsync`, -`registerSubscriptionAsync`, and `unregisterSubscriptionAsync`. These use the JDK HTTP client's nonblocking API and -return completion stages without a wrapper executor; synchronous methods remain available to ordinary callers. -The deadline covers the full HTTP exchange, including the response body. Cancelling a stage attempts to stop the -HTTP exchange, but does not promise cancellation of an operation already running on the receiving service. - -## Backend callback subscriptions - -`registerSubscription(subscriberId, subscriptionId, accountId, connectionId, topic, callbackUrl, callbackSecret)` -returns the lease in seconds. The receiving process generates the UUID `subscriptionId`; repeated registration -with the same scope and callback renews it. -A null `connectionId` subscribes to the account's matching messages, including future Desktop connections. -A non-null connection ID filters one physical connection and does not follow a replacement socket. -The receiver supplies its callback service root URL and the External DAM service secret as `callbackSecret` during -registration and renewal. The `callbackSecret` parameter and wire field remain part of the registration protocol. -The broker keeps them only with its in-memory registrations; it has no configured subscriber list or separate -service-registration step. The subscriber ID identifies the receiver and does not determine business topics. -Callback URLs follow the same HTTP/HTTPS root-URL validation as the broker URL, and callback secrets follow the same -32–512 printable, non-space ASCII validation as the service secret. Only trusted authenticated backends may register -callbacks; restrict the broker's outbound network access and use HTTPS outside a trusted private network. - -`DDMessagingCallbackClient` delivers messages to the receiver's authenticated -`POST /internal/external-dam/graphql` using the same External DAM service secret. Successful acknowledgement extends the -lease. The receiver calls its listener directly on the HTTP request thread and acknowledges after it returns. -Concurrent callbacks may invoke the same listener concurrently and have no ordering guarantee; implementations must -be thread-safe. An RPC reply, not the HTTP acknowledgement, carries the business result. Local cancellation -uses `unregisterSubscription`, including cleanup when the receiver no longer recognizes an incoming registration. -External DAM stops starting callbacks on cancellation or expiry; an already started callback may finish. -Messages are not retried or replayed after callback failure. The broker bounds in-flight callbacks per subscription; -neither side creates a delivery executor or per-subscription mailbox. `deliverAsync` sends callbacks using nonblocking -HTTP. Its default deadline is 30 seconds, including listener execution, and an explicit duration remains supported. -Timeout does not promise cancellation of the remote handler. - -Spring services use the shared `com.dbeaver.datadam.messaging.base` module from the DataDam server repository. -It supplies the callback endpoint, local dispatch, `DDMessagingSubscription.close()` cancellation, and automatic renewal -during idle periods. Configure `datadam.external-dam-client.service-url`, `datadam.external-dam-client.service-secret`, and -`datadam.external-dam-client.callback-url` on Account and Storage. Set `service-secret` to the External DAM service -secret; the library uses it for outgoing requests, incoming callback authentication, and the registration's -`callbackSecret` field automatically when opening topic subscriptions, including RPC request/reply routes. -There is no separate `datadam.external-dam-client.callback-secret` configuration. -Select a scoped channel once with `subscriptions.forConnection(accountId, connectionId)` or -`subscriptions.forAccount(accountId)`, then call `channel.subscribe(topic, listener)` without repeating identifiers. -The low-level registration protocol retains account and connection scope to enforce account ownership. -Application handlers do not renew registrations themselves. Every callback destination must reach the exact -receiving process that owns the local registration. - -Desktop subscriptions remain local. The broker sends to the selected authenticated physical Desktop connection; -the Desktop channel dispatches the message to its local topic listeners without announcing handlers to the broker. - -## Channel state - -`DDMessagingChannel.getStatus()` returns an immutable `DDMessagingChannelStatus`: `DISCONNECTED`, `CONNECTING`, `CONNECTED`, -or `CLOSED`. Only `CONNECTED` contains connection information, and `status.isReady()` checks that state. -`observeState(observer)` delivers the current state and subsequent changes in order without a snapshot/subscription -race. Closing the returned subscription cancels only that observer. Initial notification can run synchronously; -subsequent callbacks run on the notifying thread. Existing `addConnectionListener` remains transitions-only. - -## Request/reply - -The `com.dbeaver.messaging.requestreply` package supplies `MessagingRequestReplyEndpoint

`. It knows nothing about DataDam -accounts, deployment IDs, GraphQL, or JSON. `DDMessagingRequestReplyTransport` adapts an existing scoped DataDam channel to -`MessagingRequestReplyEndpoint` without changing the channel's asynchronous message API. - -On Desktop, or inside External DAM over an existing physical channel: - -```java -var transport = DDMessagingRequestReplyTransport.forChannel(channel, "documents"); -try (var requests = MessagingRequestReplyEndpoint.open(transport)) { - var response = requests.request(payload); -} -``` - -Open a physical-channel endpoint after the channel becomes ready. Opening over a disconnected or closed physical -channel fails without retaining subscriptions. An already opened endpoint can stay installed across reconnects; -requests outstanding at disconnection fail and are not replayed. - -An Account, Storage, or other backend selects a physical connection once: - -```java -var transport = subscriptions.forConnection(accountId, connectionId).requestReply("documents"); -try (var requests = MessagingRequestReplyEndpoint.open(transport)) { - var response = requests.request(payload); -} -``` - -The peer registers `requests.handle(payload -> process(payload))` on its own endpoint. The request topic identifies -one logical receiving endpoint for the selected peer; use distinct topics for independently handled services. Each -endpoint establishes its private reply subscription before accepting calls, so replies return to the originating -backend process. Protocol envelopes and payloads remain JSON objects. A null response represents successful completion -without a result. At this lower request/reply layer, handler failures are returned as protocol error codes without -application exception details. The typed RPC layer below supplies diagnostic error responses separately. - -The bridge uses message type `datadam.requestreply.v1`. Its envelope contains `requestId`, `replyTo`, `type`, and either -`data` or `error`. `replyTo` is an endpoint UUID, not a backend address. Private reply topics use -`datadam.reply.v1..`, which keeps them within registration metadata limits even -for a maximum-length request topic. Closing the endpoint cancels both topic subscriptions. - -`request` blocks a worker or virtual thread. The request/reply result acknowledges remote handler completion; -ordinary channel sends and callback GraphQL acknowledgements have transport-only meaning. The backend -adapter uses nonblocking HTTP with at most 128 sends in flight, without a send pool or waiting queue. It keeps the existing -in-memory callback subscriptions and lease renewal, and exposes request/reply only for `forConnection`, with no implicit connection -selection from account-wide subscriptions. - -Closing the request/reply endpoint cancels its receiving subscriptions and owned work, without closing the shared -channel or subscription facade. Closing the subscription facade rejects new backend sends and invalidates later send -completions; an already started HTTP send can finish. Physical channels report disconnects immediately; backend -callbacks have no physical connection-state feed, so pending calls use their deadlines when the peer disappears. -Timeout or interruption can leave the remote outcome unknown. No request or response is automatically retried. - -The direct-channel adapter orders message callbacks and disconnection callbacks through one bounded receiving mailbox. -Source validation and mailbox insertion share the same lifecycle lock; listener callbacks run outside that lock. -If a new physical source is visible before its state observer runs, the adapter inserts the generation boundary before -that source's first message. Delayed observers read the current channel status instead of reverting to an old source. -The mailbox holds at most 128 queued callbacks and adds no executor. Excess frames are dropped; a disconnection takes -priority over a full queued batch so pending calls are still invalidated. Listener failure closes the receiving routes. -Backend callback adapters have no serialized receiving mailbox and invoke listeners directly on concurrent HTTP threads. -The neutral request/reply endpoint owns no executor. A low-level physical-channel user must keep blocking handlers -off a serialized receiver when they can make synchronous calls whose replies need that same receiver. - -## Typed RPC - -`com.dbeaver.messaging.rpc` adds typed Java proxies and registered implementations without depending on DataDam or -WebSocket. `DDMessagingRpcTransport` adapts the existing request/reply transport to the neutral `MessagingRpcMessage` envelope. -It owns no execution service or application serializer; the developer selects serialization through `MessagingRpcCodec`. - -For example, a plugin can supply its own Gson instance and adapters for its proxies and implementations: - -```java -Gson gson = new Gson(); -MessagingRpcCodec codec = new DDMessagingGsonRpcCodec(gson); -``` - -The Desktop side installs an implementation and can independently obtain proxies for backend services: - -```java -try (var rpc = MessagingRpcEndpoint.open(topic -> DDMessagingRpcTransport.forChannel(channel, topic, requestExecutor)); - var registration = rpc.register("desktop.projects", DesktopProjects.class, implementation, codec)) { - ServerDocuments documents = rpc.proxy("server.documents", ServerDocuments.class, codec); - serveApplication(documents); -} -``` - -`requestExecutor` is a borrowed application/platform execution service, used only for admitted incoming requests. -Without that argument, the adapter uses its receiving thread. Responses and disconnects are never dispatched through -the request executor. Prefer the ready Desktop connection service below; it uses Eclipse system Jobs so blocking -handlers do not stall WebSocket reply delivery, without creating RPC-owned thread pools. - -The backend selects its physical account/connection scope once and uses the same endpoint API: - -```java -var scope = subscriptions.forConnection(accountId, connectionId); -try (var rpc = MessagingRpcEndpoint.open(topic -> { - try { - return new DDMessagingRpcTransport(scope.requestReply(topic)); - } catch (DDMessagingException e) { - throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e); - } -}); - var registration = rpc.register("server.documents", ServerDocuments.class, implementation, codec)) { - DesktopProjects projects = rpc.proxy("desktop.projects", DesktopProjects.class, codec); - serveApplication(projects); -} -``` - -The topic supplied to `register` and `proxy` is the actual DataDam request topic. Topic-scoped request/reply routes -open lazily and are reused locally. The mechanism adds no Account/Storage service routing or automatic prefix; naming -conventions such as `storage.files` are entirely application-owned. Registering a local service never registers one -on the other peer. The existing private reply topic routes each answer back to the exact initiating process. -The application must arrange the intended receiver's subscription before calling it; duplicate backend receivers on -the same topic are not service discovery. Replica routing and exact callback-process ownership constraints still apply. - -`DDMessagingGsonRpcCodec` retains the supplied `Gson` instance, including application adapters and naming policies, and preserves -the declared generic `Type` passed by RPC for each argument and result. Each argument is encoded and decoded independently; -a varargs parameter is one array argument. Encoded JSON null is represented as a nullable application payload. The -neutral messaging packages remain independent of Gson, and each proxy or registration can supply another compatible codec. -The resulting `MessagingRpcEndpoint` is not parameterized by JSON or by any other payload representation. - -### Ready endpoints in Account, Storage, and Desktop - -Account and Storage already import `DDMessagingClientConfiguration` from `com.dbeaver.datadam.messaging.base`. -That configuration supplies the callback receiver, subscriptions, and a shared -`DDMessagingRpc` connection owner. Inject that owner once, select a Desktop connection, and obtain its endpoint: - -```java -var connections = rpcConnections.getConnections(accountId); -var selected = chooseConnection(connections); -MessagingRpcEndpoint rpc = rpcConnections.forConnection(selected.connectionId()); -``` - -`rpcConnections` is the `DDMessagingRpc` Spring bean. `getConnections(accountId)` returns an immutable current -snapshot without opening RPC endpoints; `chooseConnection` represents application logic or a user selection from -those descriptions. Selection and release require only the unique physical connection ID, with no account wrapper. -The owner resolves the account through the authenticated private relay API when opening an endpoint, so a known -connection ID also works without listing connections first. No local discovery cache is required. The transport -retains the resolved account internally and preserves account-ownership checks for subscriptions and sends. -The configured subscriber ID identifies the callback destination and does not select RPC topic names. -Repeated lookups return the same open endpoint; callers do not construct adapters or pass account/connection IDs -to each registration or remote call. - -The Desktop and backend connection owners provide an uncustomized `DDMessagingGsonRpcCodec` as a default, so ordinary -contracts can use `rpc.proxy(topic, Contract.class)` or `rpc.register(topic, Contract.class, implementation)`. -The overloads accepting a codec override it only for that proxy or implementation registration, without modifying -another caller's serialization or requiring a shared application codec bean. -Callers can also override the response timeout when creating a proxy with `rpc.proxy(topic, Contract.class, timeout)` -or `rpc.proxy(topic, Contract.class, codec, timeout)`, where `timeout` is a `Duration`. Other proxies and their shared -connection keep their existing settings; overloads without a timeout use the endpoint default of 30 seconds unless -the owner supplied different options. - -Desktop obtains the corresponding endpoint from its existing connection service: - -```java -var connections = DDMessagingDesktopConnectionService.getInstance(); -MessagingRpcEndpoint rpc = connections.getRpc(); -``` - -The Desktop connection must first become ready. Endpoint access never starts login or waits for a new connection. -The endpoint can serve independently chosen topics over the same WebSocket, with no reserved backend-service lanes. - -Pass the obtained endpoint to ordinary Java objects. The receiving object registers an implementation at runtime: - -```java -public final class GreetingReceiver { - private final MessagingRpcEndpoint rpc; - private final MessagingRpcCodec codec; - - public GreetingReceiver(MessagingRpcEndpoint rpc, MessagingRpcCodec codec) { - this.rpc = rpc; - this.codec = codec; - } - - public MessagingSubscription enable(Greeting implementation) { - return rpc.register("greeting", Greeting.class, implementation, codec); - } -} -``` - -The other peer keeps its calling code separate: - -```java -public final class GreetingCaller { - private final MessagingRpcEndpoint rpc; - private final MessagingRpcCodec codec; - - public GreetingCaller(MessagingRpcEndpoint rpc, MessagingRpcCodec codec) { - this.rpc = rpc; - this.codec = codec; - } - - public String hello(String value) { - Greeting greeting = rpc.proxy("greeting", Greeting.class, codec); - return greeting.hello(value); - } -} -``` - -The common business contract is shared between those two peers, not installed on the relay: - -```java -/** Answers greeting requests. */ -public interface Greeting { - String hello(String value); -} -``` - -For example, one peer enables `value -> value + " World"`, while the other calls `hello("Hello")`. Reverse-direction -services are independently registered and called through the same endpoint. No business-interface beans are required. - -Keep the returned registration until the implementation should stop accepting new calls, then close that registration. -Proxies require no separate close. Shared endpoint ownership belongs to the connection service, not an individual -caller. Desktop retains endpoints and registrations across transient reconnects, but invalidates them before an -authentication identity change and on shutdown. Backend owners close missing physical connections after a successful -registry reconciliation; failed registry reads preserve existing endpoints. Applications can explicitly release a -backend scope with `rpcConnections.closeConnection(selected.connectionId())`. -Backend owners share asynchronous opening results while callers wait with a timeout. Idle reconciliation and -subscription renewal run through Spring scheduling, not owner-created executors. Desktop authorization expiry and -transport lifecycle jobs remain active even without business requests. - -Replacing a closed endpoint does not transfer its old registrations. After an identity change or explicit closure, -obtain the new endpoint and register the appropriate implementations again. Backend connection owners and caches are -process-local; the existing exact-owner callback routing requirement still applies. - -The inner JSON envelope uses `protocol: "dbeaver.rpc.v2"`, `type`, and `data`, plus `operation` for requests -or a fixed `error` code for failures. Request `data` is always a positional JSON array: `[]` for no arguments, -`[value]` for one argument, `[null]` for one null argument, or `[value1, value2]` for multiple arguments. A varargs -array occupies one position, so a single string varargs parameter can produce `[["a", "b"]]`. -Error envelopes contain `exception`: either null or an object with string `type`, -nullable string `message`, string `stackTrace`, and boolean `truncated`. The receiver formats causes and suppressed -exceptions locally; no `Throwable` object is serialized and the application codec is not involved. Each encoded argument -and response `data` can be any JSON value, including null; the outer DataDam message remains an object and retains its -existing request/reply correlation and source validation. Malformed inner envelopes -are ignored without exposing raw contents. Transport validation and RPC validation do not replace authentication. - -The adapter bounds framing, snapshot copying, and outstanding sends to 128 operations without a waiting queue. -Cancellation of a returned send future does not free its slot until the underlying send actually finishes. - -Interfaces declare ordinary methods with zero, one, or multiple arguments, including varargs, returning values or `void`; -applications do not implement transport listeners. A `void` call waits for handler completion. Local serialization errors -and transport failures become unchecked `MessagingRpcException`. Remote handler and codec failures preserve their codes -and expose `remoteException()` with bounded -type, message, and stack-trace text; ordinary exception logging includes the remote trace through a local diagnostic -cause. Limits are 256 UTF-16 code units for the type, 1,024 for the message, and 8,192 for the trace, with `truncated` -indicating incomplete diagnostics. Errors without exception details have null `remoteException()`. -See [Messaging](MESSAGING.md) for supported contract shapes, ownership, concurrent codec/handler requirements, and timeout semantics. diff --git a/apis/pom.xml b/apis/pom.xml index 481c343..4b60748 100644 --- a/apis/pom.xml +++ b/apis/pom.xml @@ -20,7 +20,7 @@ com.dbeaver.datadam.dm.api - com.dbeaver.datadam.messaging.api + com.dbeaver.datadam.dam.remote.api com.dbeaver.datadam.share.api com.dbeaver.datadam.lm.api com.dbeaver.datadam.sso.api From d44a005fdd290f2aee0976e8a38a43ec1a62df91 Mon Sep 17 00:00:00 2001 From: Viktor Kirst Date: Mon, 26 Oct 2026 20:59:21 +0400 Subject: [PATCH 30/30] dbeaver/pro#11108 Rename remaining External DAM contracts to Remote DAM --- .../README.md | 32 +++---- .../dam/remote/api/DDRemoteDamApi.java | 2 +- .../api/client/DDRemoteDamCallbackClient.java | 12 +-- .../remote/api/client/DDRemoteDamClient.java | 52 ++++++------ .../api/client/DDRemoteDamGraphQlClient.java | 14 ++-- .../client/DDRemoteDamCallbackClientTest.java | 32 +++---- .../api/client/DDRemoteDamClientTest.java | 84 +++++++++---------- 7 files changed, 114 insertions(+), 114 deletions(-) diff --git a/apis/com.dbeaver.datadam.dam.remote.api/README.md b/apis/com.dbeaver.datadam.dam.remote.api/README.md index 649a7d5..c7f1ab0 100644 --- a/apis/com.dbeaver.datadam.dam.remote.api/README.md +++ b/apis/com.dbeaver.datadam.dam.remote.api/README.md @@ -28,17 +28,17 @@ The service implementation must expose this contract; adding a client alone does scalar JSON type Query { - externalDamSessions(accountId: ID!): [ExternalDamSession!]! - externalDamSessionAccount(damSessionId: ID!): ID + remoteDamSessions(accountId: ID!): [RemoteDamSession!]! + remoteDamSessionAccount(damSessionId: ID!): ID } type Mutation { - sendExternalDamMessage(accountId: ID!, damSessionId: ID!, message: ExternalDamMessageInput!): Boolean! - registerExternalDamSubscription(input: ExternalDamSubscriptionInput!): Int! - unregisterExternalDamSubscription(subscriberId: String!, subscriptionId: ID!): Boolean! + sendRemoteDamMessage(accountId: ID!, damSessionId: ID!, message: RemoteDamMessageInput!): Boolean! + registerRemoteDamSubscription(input: RemoteDamSubscriptionInput!): Int! + unregisterRemoteDamSubscription(subscriberId: String!, subscriptionId: ID!): Boolean! } -type ExternalDamSession { +type RemoteDamSession { appDeploymentId: String! appInstanceId: String! damSessionId: ID! @@ -46,13 +46,13 @@ type ExternalDamSession { version: String! } -input ExternalDamMessageInput { +input RemoteDamMessageInput { topic: String! type: String! payload: JSON! } -input ExternalDamSubscriptionInput { +input RemoteDamSubscriptionInput { subscriberId: String! subscriptionId: ID! accountId: ID! @@ -65,7 +65,7 @@ input ExternalDamSubscriptionInput { - `accountId` is a UUID, mandatory for account-wide session lists, sends, and subscription registration. Sends use the account and DAM session ID together. -- `externalDamSessionAccount` resolves a unique session ID to the account from its verified identity. It returns null +- `remoteDamSessionAccount` resolves a unique session ID to the account from its verified identity. It returns null for a missing or not-ready session; the client reports this as checked `DDRemoteDamException`. This backend-only lookup does not authorize end-user access to that account. - Session lists are immutable client-side snapshots of currently ready transport scopes, not persisted installations @@ -83,7 +83,7 @@ input ExternalDamSubscriptionInput { ## Authentication and configuration -`DDRemoteDamClient` takes a service root URL and the External DAM service secret. It never logs in or acquires a peer token. +`DDRemoteDamClient` takes a service root URL and the Remote DAM service secret. It never logs in or acquires a peer token. The URL must use HTTP or HTTPS, have no credentials, query, fragment, or non-root path, and use a valid port. HTTP is supported for trusted private service networks; use HTTPS across untrusted networks. @@ -124,9 +124,9 @@ trusted backends may register callbacks. Restrict outbound relay network access The relay keeps registrations in memory, with no configured subscriber list or separate service-registration step. The subscriber ID identifies the receiver and does not determine business topics. -`DDRemoteDamCallbackClient` posts to `/internal/external-dam/graphql`. Callback variables are `subscriptionId`, `accountId`, -`session`, and `message`. The session input is `ExternalDamSessionInfoInput` with `appDeploymentId`, `appInstanceId`, and -`damSessionId`. The mutation remains `deliverExternalDamMessage(subscriptionId: ..., accountId: ..., session: ..., message: ...)`. +`DDRemoteDamCallbackClient` posts to `/internal/remote-dam/graphql`. Callback variables are `subscriptionId`, `accountId`, +`session`, and `message`. The session input is `RemoteDamSessionInfoInput` with `appDeploymentId`, `appInstanceId`, and +`damSessionId`. The mutation is `deliverRemoteDamMessage(subscriptionId: ..., accountId: ..., session: ..., message: ...)`. The receiver invokes its listener on the HTTP request thread and acknowledges after it returns. Successful acknowledgement extends the lease. Concurrent callbacks may invoke a listener concurrently without ordering guarantees; listeners must be @@ -136,10 +136,10 @@ finish. Failed callbacks are not replayed or retried. In-flight callbacks are bo or mailbox. `deliverAsync` is nonblocking, with a default 30-second deadline including listener execution; explicit durations are supported. Timeout does not promise remote handler cancellation. -Spring services use the shared `com.dbeaver.datadam.messaging.base` server module and its +Spring services use the shared `com.dbeaver.datadam.dam.remote.base` server module and its `DDRemoteDamClientConfiguration`. It supplies callback reception, local dispatch, subscription cancellation, and idle -renewal. Existing settings are `datadam.external-dam-client.service-url`, `datadam.external-dam-client.service-secret`, -and `datadam.external-dam-client.callback-url`. There is no separate callback-secret setting. The configured service secret +renewal. Configure `datadam.remote-dam-client.service-url`, `datadam.remote-dam-client.service-secret`, +and `datadam.remote-dam-client.callback-url`. There is no separate callback-secret setting. The configured service secret is used for requests, callback authentication, and the registration's `callbackSecret` field. Select `subscriptions.forSession(accountId, damSessionId)` or `subscriptions.forAccount(accountId)`, then subscribe by diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApi.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApi.java index d9db679..5d55395 100644 --- a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApi.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApi.java @@ -22,7 +22,7 @@ public final class DDRemoteDamApi { public static final String GRAPHQL_PATH = "/internal/graphql"; - public static final String CALLBACK_GRAPHQL_PATH = "/internal/external-dam/graphql"; + public static final String CALLBACK_GRAPHQL_PATH = "/internal/remote-dam/graphql"; public static final String HEADER_SERVICE_TOKEN = "X-DD-Service-Token"; public static final int MIN_SERVICE_SECRET_LENGTH = 32; public static final int MAX_SERVICE_SECRET_LENGTH = 512; diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClient.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClient.java index c2eb3c7..557dfad 100644 --- a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClient.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClient.java @@ -31,12 +31,12 @@ import java.util.concurrent.CompletionStage; public final class DDRemoteDamCallbackClient extends DDRemoteDamGraphQlClient { - private static final String DELIVER_FIELD = "deliverExternalDamMessage"; - private static final String DELIVER_OPERATION = "DeliverExternalDamMessage"; + private static final String DELIVER_FIELD = "deliverRemoteDamMessage"; + private static final String DELIVER_OPERATION = "DeliverRemoteDamMessage"; private static final String DELIVER_MUTATION = """ - mutation DeliverExternalDamMessage($subscriptionId: ID!, $accountId: ID!, - $session: ExternalDamSessionInfoInput!, $message: ExternalDamMessageInput!) { - deliverExternalDamMessage(subscriptionId: $subscriptionId, accountId: $accountId, session: $session, message: $message) + mutation DeliverRemoteDamMessage($subscriptionId: ID!, $accountId: ID!, + $session: RemoteDamSessionInfoInput!, $message: RemoteDamMessageInput!) { + deliverRemoteDamMessage(subscriptionId: $subscriptionId, accountId: $accountId, session: $session, message: $message) } """; @@ -87,7 +87,7 @@ public CompletionStage deliverAsync( @Nullable private static Void readDeliveryResult(@NotNull JsonElement result) throws DDRemoteDamException { if (!readBoolean(result)) { - throw new DDRemoteDamException("External DAM callback was not accepted"); + throw new DDRemoteDamException("Remote DAM callback was not accepted"); } return null; } diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClient.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClient.java index fa20b8f..499cfc1 100644 --- a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClient.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClient.java @@ -35,37 +35,37 @@ public final class DDRemoteDamClient extends DDRemoteDamGraphQlClient { private static final int READ_TIMEOUT_MILLIS = 10_000; - private static final String SESSIONS_FIELD = "externalDamSessions"; - private static final String SESSION_ACCOUNT_FIELD = "externalDamSessionAccount"; - private static final String SEND_FIELD = "sendExternalDamMessage"; - private static final String SESSIONS_OPERATION = "ExternalDamSessions"; - private static final String SESSION_ACCOUNT_OPERATION = "ExternalDamSessionAccount"; - private static final String SEND_OPERATION = "SendExternalDamMessage"; + private static final String SESSIONS_FIELD = "remoteDamSessions"; + private static final String SESSION_ACCOUNT_FIELD = "remoteDamSessionAccount"; + private static final String SEND_FIELD = "sendRemoteDamMessage"; + private static final String SESSIONS_OPERATION = "RemoteDamSessions"; + private static final String SESSION_ACCOUNT_OPERATION = "RemoteDamSessionAccount"; + private static final String SEND_OPERATION = "SendRemoteDamMessage"; private static final String SESSIONS_QUERY = """ - query ExternalDamSessions($accountId: ID!) { - externalDamSessions(accountId: $accountId) { + query RemoteDamSessions($accountId: ID!) { + remoteDamSessions(accountId: $accountId) { appDeploymentId appInstanceId damSessionId product version } } """; private static final String SESSION_ACCOUNT_QUERY = """ - query ExternalDamSessionAccount($damSessionId: ID!) { - externalDamSessionAccount(damSessionId: $damSessionId) + query RemoteDamSessionAccount($damSessionId: ID!) { + remoteDamSessionAccount(damSessionId: $damSessionId) } """; private static final String SEND_MUTATION = """ - mutation SendExternalDamMessage($accountId: ID!, $damSessionId: ID!, $message: ExternalDamMessageInput!) { - sendExternalDamMessage(accountId: $accountId, damSessionId: $damSessionId, message: $message) + mutation SendRemoteDamMessage($accountId: ID!, $damSessionId: ID!, $message: RemoteDamMessageInput!) { + sendRemoteDamMessage(accountId: $accountId, damSessionId: $damSessionId, message: $message) } """; private static final String REGISTER_MUTATION = """ - mutation RegisterExternalDamSubscription($input: ExternalDamSubscriptionInput!) { - registerExternalDamSubscription(input: $input) + mutation RegisterRemoteDamSubscription($input: RemoteDamSubscriptionInput!) { + registerRemoteDamSubscription(input: $input) } """; private static final String UNREGISTER_MUTATION = """ - mutation UnregisterExternalDamSubscription($subscriberId: String!, $subscriptionId: ID!) { - unregisterExternalDamSubscription(subscriberId: $subscriberId, subscriptionId: $subscriptionId) + mutation UnregisterRemoteDamSubscription($subscriberId: String!, $subscriptionId: ID!) { + unregisterRemoteDamSubscription(subscriberId: $subscriberId, subscriptionId: $subscriptionId) } """; @@ -121,9 +121,9 @@ public int registerSubscription( @NotNull String callbackUrl, @NotNull String callbackSecret ) throws DDRemoteDamException { - return readLease(request("RegisterExternalDamSubscription", REGISTER_MUTATION, + return readLease(request("RegisterRemoteDamSubscription", REGISTER_MUTATION, subscriptionVariables(subscriberId, subscriptionId, accountId, damSessionId, topic, callbackUrl, callbackSecret), - "registerExternalDamSubscription")); + "registerRemoteDamSubscription")); } @NotNull @@ -136,20 +136,20 @@ public CompletionStage registerSubscriptionAsync( @NotNull String callbackUrl, @NotNull String callbackSecret ) { - return requestAsync("RegisterExternalDamSubscription", REGISTER_MUTATION, + return requestAsync("RegisterRemoteDamSubscription", REGISTER_MUTATION, subscriptionVariables(subscriberId, subscriptionId, accountId, damSessionId, topic, callbackUrl, callbackSecret), - "registerExternalDamSubscription", DDRemoteDamClient::readLease); + "registerRemoteDamSubscription", DDRemoteDamClient::readLease); } public boolean unregisterSubscription(@NotNull String subscriberId, @NotNull UUID subscriptionId) throws DDRemoteDamException { - return readBoolean(request("UnregisterExternalDamSubscription", UNREGISTER_MUTATION, - unsubscriptionVariables(subscriberId, subscriptionId), "unregisterExternalDamSubscription")); + return readBoolean(request("UnregisterRemoteDamSubscription", UNREGISTER_MUTATION, + unsubscriptionVariables(subscriberId, subscriptionId), "unregisterRemoteDamSubscription")); } @NotNull public CompletionStage unregisterSubscriptionAsync(@NotNull String subscriberId, @NotNull UUID subscriptionId) { - return requestAsync("UnregisterExternalDamSubscription", UNREGISTER_MUTATION, - unsubscriptionVariables(subscriberId, subscriptionId), "unregisterExternalDamSubscription", + return requestAsync("UnregisterRemoteDamSubscription", UNREGISTER_MUTATION, + unsubscriptionVariables(subscriberId, subscriptionId), "unregisterRemoteDamSubscription", DDRemoteDamGraphQlClient::readBoolean); } @@ -241,10 +241,10 @@ private static int readLease(@NotNull JsonElement result) throws DDRemoteDamExce requireText(damSessionId); } if (!DDRemoteDamApi.isValidServiceUrl(callbackUrl)) { - throw new IllegalArgumentException("Invalid External DAM callback URL"); + throw new IllegalArgumentException("Invalid Remote DAM callback URL"); } if (!DDRemoteDamApi.isValidServiceSecret(callbackSecret)) { - throw new IllegalArgumentException("Invalid External DAM callback secret"); + throw new IllegalArgumentException("Invalid Remote DAM callback secret"); } Map input = new HashMap<>(); input.put("subscriberId", subscriberId); diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamGraphQlClient.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamGraphQlClient.java index 7e1e3df..e35a424 100644 --- a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamGraphQlClient.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamGraphQlClient.java @@ -55,7 +55,7 @@ abstract class DDRemoteDamGraphQlClient extends AbstractRestClient { super(validateServiceUrl(serviceUrl), Math.min(DEFAULT_CONNECT_TIMEOUT, timeoutMillis), timeoutMillis, List.of(), HttpClient.Redirect.NEVER); if (!DDRemoteDamApi.isValidServiceSecret(serviceSecret)) { - throw new IllegalArgumentException("A valid External DAM service secret is required"); + throw new IllegalArgumentException("A valid Remote DAM service secret is required"); } this.serviceSecret = serviceSecret; this.endpoint = endpoint; @@ -178,7 +178,7 @@ private static JsonElement readResult(@NotNull String response, @NotNull String } JsonElement errors = envelope.get("errors"); if (errors != null && (!errors.isJsonArray() || !errors.getAsJsonArray().isEmpty())) { - throw new DDRemoteDamException("External DAM GraphQL request failed"); + throw new DDRemoteDamException("Remote DAM GraphQL request failed"); } JsonElement result = readObject(envelope.get("data")).get(field); if (result == null || result.isJsonNull()) { @@ -190,7 +190,7 @@ private static JsonElement readResult(@NotNull String response, @NotNull String @Override @NotNull protected DDRemoteDamException mapErrorResponse(int code, @NotNull String message, @NotNull URI uri) { - return new DDRemoteDamException("External DAM request failed: HTTP " + code); + return new DDRemoteDamException("Remote DAM request failed: HTTP " + code); } static boolean readBoolean(@NotNull JsonElement value) throws DDRemoteDamException { @@ -210,7 +210,7 @@ static JsonObject readObject(@Nullable JsonElement value) throws DDRemoteDamExce @NotNull static DDRemoteDamException invalidResponse() { - return new DDRemoteDamException("Invalid External DAM response"); + return new DDRemoteDamException("Invalid Remote DAM response"); } @NotNull @@ -219,18 +219,18 @@ private static DDRemoteDamException requestFailure(@NotNull Throwable error) { error = error.getCause(); } if (error instanceof TimeoutException) { - HttpTimeoutException timeout = new HttpTimeoutException("External DAM service request timed out"); + HttpTimeoutException timeout = new HttpTimeoutException("Remote DAM service request timed out"); timeout.initCause(error); error = timeout; } return error instanceof DDRemoteDamException failure - ? failure : new DDRemoteDamException("External DAM service request failed", error); + ? failure : new DDRemoteDamException("Remote DAM service request failed", error); } @NotNull private static String validateServiceUrl(@NotNull String serviceUrl) { if (!DDRemoteDamApi.isValidServiceUrl(serviceUrl)) { - throw new IllegalArgumentException("Invalid External DAM service URL"); + throw new IllegalArgumentException("Invalid Remote DAM service URL"); } return serviceUrl; } diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClientTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClientTest.java index fbde511..1b8ab2e 100644 --- a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClientTest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClientTest.java @@ -60,7 +60,7 @@ @Timeout(10) class DDRemoteDamCallbackClientTest { - private static final String SECRET = "external-dam-callback-secret-2026-test"; + private static final String SECRET = "remote-dam-callback-secret-2026-test"; private static final UUID SUBSCRIPTION_ID = UUID.randomUUID(); private static final UUID ACCOUNT_ID = UUID.randomUUID(); private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("deployment", "run", "socket"); @@ -69,7 +69,7 @@ class DDRemoteDamCallbackClientTest { {"nested":{"value":42},"nothing":null,"integer":9007199254740993, "decimal":1.2345678901234567890123456789,"text":"Привет"} """).getAsJsonObject()); - private final AtomicReference response = new AtomicReference<>("{\"data\":{\"deliverExternalDamMessage\":true}}"); + private final AtomicReference response = new AtomicReference<>("{\"data\":{\"deliverRemoteDamMessage\":true}}"); private final AtomicInteger status = new AtomicInteger(200); private final AtomicInteger requestCount = new AtomicInteger(); private final AtomicInteger responseDelay = new AtomicInteger(); @@ -102,16 +102,16 @@ void deliversTheExactCallbackWireWithIsolatedCredentialsAndAnOpaquePayload() thr CapturedRequest captured = request.get(); assertEquals(synchronous, captured); assertEquals("POST", captured.method()); - assertEquals("/internal/external-dam/graphql", captured.path()); + assertEquals("/internal/remote-dam/graphql", captured.path()); assertEquals(SECRET, captured.secret()); assertEquals("application/json", captured.contentType()); assertEquals("no-store", captured.cacheControl()); JsonObject body = captured.body(); - assertEquals("DeliverExternalDamMessage", body.get("operationName").getAsString()); + assertEquals("DeliverRemoteDamMessage", body.get("operationName").getAsString()); String query = body.get("query").getAsString().replaceAll("\\s+", " "); - assertTrue(query.contains("$session: ExternalDamSessionInfoInput!")); - assertTrue(query.contains("$message: ExternalDamMessageInput!")); - assertTrue(query.contains("deliverExternalDamMessage(subscriptionId: $subscriptionId, accountId: $accountId, " + assertTrue(query.contains("$session: RemoteDamSessionInfoInput!")); + assertTrue(query.contains("$message: RemoteDamMessageInput!")); + assertTrue(query.contains("deliverRemoteDamMessage(subscriptionId: $subscriptionId, accountId: $accountId, " + "session: $session, message: $message)")); JsonObject variables = body.getAsJsonObject("variables"); assertEquals(Set.of("subscriptionId", "accountId", "session", "message"), variables.keySet()); @@ -172,18 +172,18 @@ void allowsDefaultAsyncDeliveryToWaitLongerThanFiveSecondsForTheHandler() throws @Test void rejectsFalseAcknowledgementsAsProtocolFailures() { - response.set("{\"data\":{\"deliverExternalDamMessage\":false}}"); + response.set("{\"data\":{\"deliverRemoteDamMessage\":false}}"); assertFailureOnce(); DDRemoteDamException failure = assertAsyncFailureOnce(); - assertEquals("External DAM callback was not accepted", failure.getMessage()); + assertEquals("Remote DAM callback was not accepted", failure.getMessage()); } @Test void rejectsMalformedEnvelopesPartialErrorsAndNonBooleanResultsWithoutRetries() { for (String body : List.of("", "{", "null", "[]", "{}", "{\"data\":null}", "{\"data\":{}}", - "{\"data\":{\"deliverExternalDamMessage\":null}}", "{\"data\":{\"deliverExternalDamMessage\":\"true\"}}", - "{\"data\":{\"deliverExternalDamMessage\":1}}", "{\"errors\":null}", "{\"" + SECRET + "\":[", - "{\"data\":{\"deliverExternalDamMessage\":true},\"errors\":[{\"message\":\"" + SECRET + "\"}]}")) { + "{\"data\":{\"deliverRemoteDamMessage\":null}}", "{\"data\":{\"deliverRemoteDamMessage\":\"true\"}}", + "{\"data\":{\"deliverRemoteDamMessage\":1}}", "{\"errors\":null}", "{\"" + SECRET + "\":[", + "{\"data\":{\"deliverRemoteDamMessage\":true},\"errors\":[{\"message\":\"" + SECRET + "\"}]}")) { response.set(body); assertFailureOnce(); assertAsyncFailureOnce(); @@ -196,7 +196,7 @@ void rejectsHttpFailuresAndNeverFollowsRedirects() { status.set(code); assertFailureOnce(); DDRemoteDamException failure = assertAsyncFailureOnce(); - assertEquals("External DAM request failed: HTTP " + code, failure.getMessage()); + assertEquals("Remote DAM request failed: HTTP " + code, failure.getMessage()); } } @@ -214,7 +214,7 @@ void preservesTheExplicitAsyncDeliveryTimeoutAndItsNetworkCause() { DDRemoteDamException failure = assertAsyncFailureOnce(); - assertEquals("External DAM service request failed", failure.getMessage()); + assertEquals("Remote DAM service request failed", failure.getMessage()); assertInstanceOf(HttpTimeoutException.class, failure.getCause()); } @@ -291,7 +291,7 @@ private void assertStalledBodyIsCancelled(boolean cancelDelivery) throws Excepti requestCount.incrementAndGet(); exchange.getResponseHeaders().set("Content-Type", "application/json"); exchange.sendResponseHeaders(200, 0); - exchange.getResponseBody().write("{\"data\":{\"deliverExternalDamMessage\":".getBytes(StandardCharsets.UTF_8)); + exchange.getResponseBody().write("{\"data\":{\"deliverRemoteDamMessage\":".getBytes(StandardCharsets.UTF_8)); exchange.getResponseBody().flush(); headersSent.countDown(); if (!resumeBody.await(5, TimeUnit.SECONDS)) { @@ -325,7 +325,7 @@ private void assertStalledBodyIsCancelled(boolean cancelDelivery) throws Excepti ExecutionException completion = assertThrows(ExecutionException.class, () -> delivery.get(3, TimeUnit.SECONDS)); DDRemoteDamException failure = assertInstanceOf(DDRemoteDamException.class, completion.getCause()); - assertEquals("External DAM service request failed", failure.getMessage()); + assertEquals("Remote DAM service request failed", failure.getMessage()); assertInstanceOf(HttpTimeoutException.class, failure.getCause()); StringWriter trace = new StringWriter(); failure.printStackTrace(new PrintWriter(trace)); diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClientTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClientTest.java index b4b1c6f..18c433b 100644 --- a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClientTest.java +++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClientTest.java @@ -68,13 +68,13 @@ class DDRemoteDamClientTest { private static final UUID ACCOUNT_ID = UUID.fromString("58c487c0-de34-4b45-83bb-705cb733cbcd"); private static final UUID OTHER_ACCOUNT_ID = UUID.fromString("678d5732-708f-441e-bc59-719b92af1d64"); - private static final String SERVICE_SECRET = "external-dam-service-secret-2026-test"; + private static final String SERVICE_SECRET = "remote-dam-service-secret-2026-test"; private static final String CALLBACK_URL = "https://callback.example.com"; - private static final String CALLBACK_SECRET = "external-dam-callback-secret-2026-test"; + private static final String CALLBACK_SECRET = "remote-dam-callback-secret-2026-test"; private static final String UPSTREAM_MARKER = "private-upstream-response-marker"; - private static final String SESSIONS_FIELD = "externalDamSessions"; - private static final String SESSION_ACCOUNT_FIELD = "externalDamSessionAccount"; - private static final String SEND_FIELD = "sendExternalDamMessage"; + private static final String SESSIONS_FIELD = "remoteDamSessions"; + private static final String SESSION_ACCOUNT_FIELD = "remoteDamSessionAccount"; + private static final String SEND_FIELD = "sendRemoteDamMessage"; private final AtomicReference responseJson = new AtomicReference<>("{}"); private final AtomicInteger responseStatus = new AtomicInteger(200); private final AtomicReference redirect = new AtomicReference<>(); @@ -113,10 +113,10 @@ void postsSessionQueriesToTheInternalEndpointWithEitherRootUrlForm() throws DDRe JsonObject body = posted(); assertEquals(Set.of("operationName", "query", "variables"), body.keySet()); - assertEquals("ExternalDamSessions", body.get("operationName").getAsString()); + assertEquals("RemoteDamSessions", body.get("operationName").getAsString()); String query = body.get("query").getAsString().replaceAll("\\s+", " "); - assertTrue(query.contains("query ExternalDamSessions($accountId: ID!)")); - assertTrue(query.contains("externalDamSessions(accountId: $accountId)")); + assertTrue(query.contains("query RemoteDamSessions($accountId: ID!)")); + assertTrue(query.contains("remoteDamSessions(accountId: $accountId)")); for (String field : List.of("appDeploymentId", "appInstanceId", "damSessionId", "product", "version")) { assertTrue(query.contains(field), field); } @@ -137,26 +137,26 @@ void returnsAllAsyncOperationsBeforeDelayedResponsesAndParsesTheirResults() thro assertEquals(List.of(new DDRemoteDamSession("DI_remoteDam", "run-1", "session-1", "DBeaver", "26.2.4")), result); assertThrows(UnsupportedOperationException.class, () -> result.add(result.get(0))); - assertEquals("ExternalDamSessions", posted().get("operationName").getAsString()); + assertEquals("RemoteDamSessions", posted().get("operationName").getAsString()); assertEquals(ACCOUNT_ID.toString(), posted().getAsJsonObject("variables").get("accountId").getAsString()); respond(SESSION_ACCOUNT_FIELD, JsonParser.parseString("\"" + ACCOUNT_ID + "\"")); assertEquals(ACCOUNT_ID, awaitDelayedResponse(() -> client.getSessionAccountAsync("session-1"))); - assertEquals("ExternalDamSessionAccount", posted().get("operationName").getAsString()); + assertEquals("RemoteDamSessionAccount", posted().get("operationName").getAsString()); assertEquals("session-1", posted().getAsJsonObject("variables").get("damSessionId").getAsString()); respond(SEND_FIELD, JsonParser.parseString("false")); assertFalse(awaitDelayedResponse(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message()))); - assertEquals("SendExternalDamMessage", posted().get("operationName").getAsString()); + assertEquals("SendRemoteDamMessage", posted().get("operationName").getAsString()); assertEquals(ACCOUNT_ID.toString(), posted().getAsJsonObject("variables").get("accountId").getAsString()); assertEquals("session-1", posted().getAsJsonObject("variables").get("damSessionId").getAsString()); UUID subscriptionId = UUID.randomUUID(); - respond("registerExternalDamSubscription", JsonParser.parseString("60")); + respond("registerRemoteDamSubscription", JsonParser.parseString("60")); assertEquals(60, awaitDelayedResponse(() -> client.registerSubscriptionAsync("service account", subscriptionId, ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)).intValue()); JsonObject input = posted().getAsJsonObject("variables").getAsJsonObject("input"); - assertEquals("RegisterExternalDamSubscription", posted().get("operationName").getAsString()); + assertEquals("RegisterRemoteDamSubscription", posted().get("operationName").getAsString()); assertEquals("service account", input.get("subscriberId").getAsString()); assertEquals(subscriptionId.toString(), input.get("subscriptionId").getAsString()); assertEquals(ACCOUNT_ID.toString(), input.get("accountId").getAsString()); @@ -165,9 +165,9 @@ void returnsAllAsyncOperationsBeforeDelayedResponsesAndParsesTheirResults() thro assertEquals(CALLBACK_SECRET, input.get("callbackSecret").getAsString()); assertTrue(!input.has("damSessionId") || input.get("damSessionId").isJsonNull()); - respond("unregisterExternalDamSubscription", JsonParser.parseString("false")); + respond("unregisterRemoteDamSubscription", JsonParser.parseString("false")); assertFalse(awaitDelayedResponse(() -> client.unregisterSubscriptionAsync("service account", subscriptionId))); - assertEquals("UnregisterExternalDamSubscription", posted().get("operationName").getAsString()); + assertEquals("UnregisterRemoteDamSubscription", posted().get("operationName").getAsString()); assertEquals(Set.of("subscriberId", "subscriptionId"), posted().getAsJsonObject("variables").keySet()); assertEquals("service account", posted().getAsJsonObject("variables").get("subscriberId").getAsString()); assertEquals(subscriptionId.toString(), posted().getAsJsonObject("variables").get("subscriptionId").getAsString()); @@ -205,10 +205,10 @@ void resolvesTheSessionOwnerWithOneAuthenticatedReadAndNoDiscovery() throws DDRe assertEquals(accountId, client.getSessionAccount(damSessionId)); JsonObject body = posted(); - assertEquals("ExternalDamSessionAccount", body.get("operationName").getAsString()); + assertEquals("RemoteDamSessionAccount", body.get("operationName").getAsString()); String query = body.get("query").getAsString().replaceAll("\\s+", " "); - assertTrue(query.contains("query ExternalDamSessionAccount($damSessionId: ID!)")); - assertTrue(query.contains("externalDamSessionAccount(damSessionId: $damSessionId)")); + assertTrue(query.contains("query RemoteDamSessionAccount($damSessionId: ID!)")); + assertTrue(query.contains("remoteDamSessionAccount(damSessionId: $damSessionId)")); assertEquals(Set.of("damSessionId"), body.getAsJsonObject("variables").keySet()); assertEquals(damSessionId, body.getAsJsonObject("variables").get("damSessionId").getAsString()); assertFalse(query.contains("injectedSession")); @@ -320,7 +320,7 @@ void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDR JsonObject body = posted(); assertEquals(Set.of("operationName", "query", "variables"), body.keySet()); - assertEquals("SendExternalDamMessage", body.get("operationName").getAsString()); + assertEquals("SendRemoteDamMessage", body.get("operationName").getAsString()); JsonObject variables = body.getAsJsonObject("variables"); assertEquals(Set.of("accountId", "damSessionId", "message"), variables.keySet()); assertEquals(ACCOUNT_ID.toString(), variables.get("accountId").getAsString()); @@ -332,11 +332,11 @@ void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDR assertEquals(payload, input.get("payload")); assertTrue(input.get("payload").isJsonObject()); String query = body.get("query").getAsString().replaceAll("\\s+", " "); - assertTrue(query.contains("mutation SendExternalDamMessage(")); + assertTrue(query.contains("mutation SendRemoteDamMessage(")); assertTrue(query.contains("$accountId: ID!")); assertTrue(query.contains("$damSessionId: ID!")); - assertTrue(query.contains("$message: ExternalDamMessageInput!")); - assertTrue(query.contains("sendExternalDamMessage(accountId: $accountId, damSessionId: $damSessionId, message: $message)")); + assertTrue(query.contains("$message: RemoteDamMessageInput!")); + assertTrue(query.contains("sendRemoteDamMessage(accountId: $accountId, damSessionId: $damSessionId, message: $message)")); for (String value : List.of(ACCOUNT_ID.toString(), damSessionId, topic, type, "injectedSession", "injectedTopic", "injectedType", "Привет", "C:\\temp\\file")) { assertFalse(query.contains(value), value); @@ -353,7 +353,7 @@ void returnsTheSendBooleanWithoutAdditionalRequests() throws DDRemoteDamExceptio assertEquals(written, client.sendMessage(ACCOUNT_ID, "session-1", message())); assertEquals(before + 1, requestCount.get()); - assertEquals("SendExternalDamMessage", posted().get("operationName").getAsString()); + assertEquals("SendRemoteDamMessage", posted().get("operationName").getAsString()); } } @@ -363,17 +363,17 @@ void registersDynamicCallbacksAsVariablesAndPreservesStableIdsAndOptionalSession for (String damSessionId : List.of("", "socket")) { String callbackUrl = damSessionId.isEmpty() ? CALLBACK_URL : "http://127.0.0.1:8080/"; String callbackSecret = damSessionId.isEmpty() ? CALLBACK_SECRET : CALLBACK_SECRET + "\"\\"; - respond("registerExternalDamSubscription", JsonParser.parseString("60")); + respond("registerRemoteDamSubscription", JsonParser.parseString("60")); assertEquals(60, client.registerSubscription("account", subscriptionId, ACCOUNT_ID, damSessionId.isEmpty() ? null : damSessionId, "projects", callbackUrl, callbackSecret)); JsonObject body = posted(); assertEquals(Set.of("operationName", "query", "variables"), body.keySet()); - assertEquals("RegisterExternalDamSubscription", body.get("operationName").getAsString()); + assertEquals("RegisterRemoteDamSubscription", body.get("operationName").getAsString()); String query = body.get("query").getAsString().replaceAll("\\s+", " "); - assertTrue(query.contains("$input: ExternalDamSubscriptionInput!")); - assertTrue(query.contains("registerExternalDamSubscription(input: $input)")); + assertTrue(query.contains("$input: RemoteDamSubscriptionInput!")); + assertTrue(query.contains("registerRemoteDamSubscription(input: $input)")); assertEquals(Set.of("input"), body.getAsJsonObject("variables").keySet()); JsonObject input = body.getAsJsonObject("variables").getAsJsonObject("input"); assertEquals("account", input.get("subscriberId").getAsString()); @@ -399,7 +399,7 @@ void registersDynamicCallbacksAsVariablesAndPreservesStableIdsAndOptionalSession @Test void registersTheServiceSecretAsTheCallbackSecret() throws DDRemoteDamException { - respond("registerExternalDamSubscription", JsonParser.parseString("60")); + respond("registerRemoteDamSubscription", JsonParser.parseString("60")); assertEquals(60, client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, "projects", CALLBACK_URL, SERVICE_SECRET)); @@ -417,7 +417,7 @@ void registersTheServiceSecretAsTheCallbackSecret() throws DDRemoteDamException @Test void acceptsCallbackRootOriginsAndPrintableSecretBoundaries() throws DDRemoteDamException { UUID subscriptionId = UUID.randomUUID(); - respond("registerExternalDamSubscription", JsonParser.parseString("60")); + respond("registerRemoteDamSubscription", JsonParser.parseString("60")); for (String callbackUrl : List.of("http://127.0.0.1:8080", "https://callback.example.com/")) { for (String callbackSecret : List.of("!".repeat(32), "~".repeat(512))) { assertEquals(60, client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, @@ -435,13 +435,13 @@ void acceptsCallbackRootOriginsAndPrintableSecretBoundaries() throws DDRemoteDam void preservesSubscriberIdsAcrossRegistrationAndCancellation() throws DDRemoteDamException { for (String subscriberId : List.of("service:account", "service account", "s".repeat(129), "s".repeat(256))) { UUID subscriptionId = UUID.randomUUID(); - respond("registerExternalDamSubscription", JsonParser.parseString("60")); + respond("registerRemoteDamSubscription", JsonParser.parseString("60")); assertEquals(60, client.registerSubscription(subscriberId, subscriptionId, ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); assertEquals(subscriberId, posted().getAsJsonObject("variables").getAsJsonObject("input") .get("subscriberId").getAsString()); - respond("unregisterExternalDamSubscription", JsonParser.parseString("true")); + respond("unregisterRemoteDamSubscription", JsonParser.parseString("true")); assertTrue(client.unregisterSubscription(subscriberId, subscriptionId)); assertEquals(subscriberId, posted().getAsJsonObject("variables").get("subscriberId").getAsString()); } @@ -482,7 +482,7 @@ void rejectsMalformedCallbackOriginsBeforeSendingAnyRequestWithoutExposingSecret for (String callbackUrl : new String[] { null, "", " ", "/relative", "callback.example.com", "//callback.example.com", "http://", "http:///", "ftp://callback.example.com", "ws://callback.example.com", "wss://callback.example.com", - CALLBACK_URL + "/internal/external-dam/graphql", CALLBACK_URL + "/prefix/", CALLBACK_URL + "//", + CALLBACK_URL + "/internal/remote-dam/graphql", CALLBACK_URL + "/prefix/", CALLBACK_URL + "//", CALLBACK_URL + "/%2f", CALLBACK_URL + "?secret=" + CALLBACK_SECRET, CALLBACK_URL + "#fragment", "http://user:" + CALLBACK_SECRET + "@127.0.0.1", "http://127.0.0.1:0", "http://127.0.0.1:65536", "http://127.0.0.1:-1", "http://127.0.0.1:port", "http://[::1", "https://" + "a".repeat(4096) @@ -527,12 +527,12 @@ void rejectsInvalidCallbackSecretsBeforeSendingAnyRequest() { void unregistersOnlyTheStableSubscriberAndSubscriptionId() throws DDRemoteDamException { UUID subscriptionId = UUID.randomUUID(); for (boolean removed : List.of(true, false)) { - respond("unregisterExternalDamSubscription", JsonParser.parseString(Boolean.toString(removed))); + respond("unregisterRemoteDamSubscription", JsonParser.parseString(Boolean.toString(removed))); assertEquals(removed, client.unregisterSubscription("storage", subscriptionId)); JsonObject body = posted(); - assertEquals("UnregisterExternalDamSubscription", body.get("operationName").getAsString()); + assertEquals("UnregisterRemoteDamSubscription", body.get("operationName").getAsString()); JsonObject variables = body.getAsJsonObject("variables"); assertEquals(Set.of("subscriberId", "subscriptionId"), variables.keySet()); assertEquals("storage", variables.get("subscriberId").getAsString()); @@ -543,7 +543,7 @@ void unregistersOnlyTheStableSubscriberAndSubscriptionId() throws DDRemoteDamExc @Test void acceptsBothSubscriptionLeaseBoundaries() throws Exception { for (int lease : List.of(15, 300)) { - respond("registerExternalDamSubscription", JsonParser.parseString(Integer.toString(lease))); + respond("registerRemoteDamSubscription", JsonParser.parseString(Integer.toString(lease))); assertEquals(lease, client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); assertEquals(lease, client.registerSubscriptionAsync("s".repeat(256), UUID.randomUUID(), ACCOUNT_ID, "socket", @@ -558,14 +558,14 @@ void acceptsBothSubscriptionLeaseBoundaries() throws Exception { void rejectsInvalidLeaseValuesAndMalformedSubscriptionEnvelopesWithoutRetrying() { UUID subscriptionId = UUID.randomUUID(); for (String value : List.of("null", "true", "\"60\"", "14", "301", "60.5", "1e100", "{}", "[]")) { - respond("registerExternalDamSubscription", JsonParser.parseString(value)); + respond("registerRemoteDamSubscription", JsonParser.parseString(value)); assertRequestFails(() -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); assertAsyncRequestFails(() -> client.registerSubscriptionAsync("account", subscriptionId, ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); } for (String value : List.of("null", "\"true\"", "60", "{}", "[]")) { - respond("unregisterExternalDamSubscription", JsonParser.parseString(value)); + respond("unregisterRemoteDamSubscription", JsonParser.parseString(value)); assertRequestFails(() -> client.unregisterSubscription("account", subscriptionId)); assertAsyncRequestFails(() -> client.unregisterSubscriptionAsync("account", subscriptionId)); } @@ -612,7 +612,7 @@ void rejectsMalformedJsonRootsAndMissingDataForBothOperations() { for (String body : List.of( "", "{", "not-json", "null", "[]", "true", "42", "\"root\"", "{}", "{\"data\":null}", "{\"data\":[]}", "{\"data\":true}", "{\"data\":{}}", - "{\"data\":{\"externalDamSessions\":null,\"sendExternalDamMessage\":null}}", + "{\"data\":{\"remoteDamSessions\":null,\"sendRemoteDamMessage\":null}}", "{\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\":[" )) { responseJson.set(body); @@ -642,7 +642,7 @@ JsonNull.INSTANCE, new JsonObject(), JsonParser.parseString("true"), JsonParser. assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message())); assertAsyncRequestFails(() -> client.getSessionsAsync(ACCOUNT_ID)); DDRemoteDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); - assertEquals("External DAM GraphQL request failed", failure.getMessage()); + assertEquals("Remote DAM GraphQL request failed", failure.getMessage()); } } @@ -693,7 +693,7 @@ void rejectsHttpFailuresWithoutExposingTheResponseOrRetrying() { assertRequestFails(() -> client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)); DDRemoteDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); - assertEquals(status == 204 ? "Invalid External DAM response" : "External DAM request failed: HTTP " + status, + assertEquals(status == 204 ? "Invalid Remote DAM response" : "Remote DAM request failed: HTTP " + status, failure.getMessage()); } } @@ -762,7 +762,7 @@ void preservesAsyncNetworkFailureCausesAndAllowsSubsequentRequests() throws Exce DDRemoteDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())); - assertEquals("External DAM service request failed", failure.getMessage()); + assertEquals("Remote DAM service request failed", failure.getMessage()); assertInstanceOf(IOException.class, failure.getCause()); truncateResponse.set(false); assertTrue(client.sendMessageAsync(ACCOUNT_ID, "session-1", message()).toCompletableFuture().get(5, TimeUnit.SECONDS)); @@ -778,7 +778,7 @@ void reportsAsyncConnectionFailuresThroughTheStageWithTheirCause() { () -> client.getSessionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS)); DDRemoteDamException failure = assertInstanceOf(DDRemoteDamException.class, completion.getCause()); - assertEquals("External DAM service request failed", failure.getMessage()); + assertEquals("Remote DAM service request failed", failure.getMessage()); assertInstanceOf(IOException.class, failure.getCause()); assertSafeFailure(failure); assertEquals(0, requestCount.get());