From 81754152ae3cc1bdbad3100c2f796c9eead243e3 Mon Sep 17 00:00:00 2001 From: Kai Hudalla Date: Thu, 4 Sep 2025 17:02:09 +0200 Subject: [PATCH] Fix return types of async APIs The operations of the L1, L2 and L3 APIs have all been returning CompletableFuture and had always been succeeded with a UStatus. Client code then had to check if the UCode was OK or not. This has been changed so that the operations now return CompletableFuture and complete normally if the operation was successful. If the operation failed, the future completes exceptionally with a UStatusException containing the UStatus with the error code. This is much more aligned with the way async operations are usually handled in Java and makes client code much cleaner. --- checkstyle.xml | 4 +- pom.xml | 24 + .../v3/InMemoryUSubscriptionClient.java | 394 ------ .../v3/RpcClientBasedUSubscriptionClient.java | 193 +++ .../usubscription/v3/USubscriptionClient.java | 258 +--- .../AbstractCommunicationLayerClient.java | 36 + .../uprotocol/communication/CallOptions.java | 22 +- .../communication/InMemoryRpcClient.java | 125 +- .../communication/InMemoryRpcServer.java | 221 ++-- .../communication/InMemorySubscriber.java | 325 +++++ .../uprotocol/communication/Notifier.java | 121 +- .../uprotocol/communication/Publisher.java | 81 +- .../uprotocol/communication/RpcClient.java | 24 +- .../uprotocol/communication/RpcMapper.java | 9 +- .../uprotocol/communication/RpcResult.java | 9 +- .../uprotocol/communication/RpcServer.java | 44 +- .../communication/SimpleNotifier.java | 84 +- .../communication/SimplePublisher.java | 56 +- .../uprotocol/communication/Subscriber.java | 90 ++ .../SubscriptionChangeHandler.java | 2 +- .../uprotocol/communication/UClient.java | 91 +- .../uprotocol/communication/UPayload.java | 97 +- .../uprotocol/transport/LocalUriProvider.java | 46 + .../transport/StaticUriProvider.java | 79 ++ .../uprotocol/transport/UListener.java | 19 +- .../uprotocol/transport/UTransport.java | 168 +-- .../uprotocol/uri/factory/UriFactory.java | 40 +- .../uprotocol/uri/validator/UriValidator.java | 29 + .../v3/InMemoryUSubscriptionClientTest.java | 1171 ----------------- ...RpcClientBasedUSubscriptionClientTest.java | 151 +++ .../communication/CallOptionsTest.java | 30 +- .../CommunicationLayerClientTestBase.java | 68 + .../communication/InMemoryRpcClientTest.java | 335 +++-- .../communication/InMemoryRpcServerTest.java | 717 +++++----- .../communication/InMemorySubscriberTest.java | 671 ++++++++++ .../communication/RpcMapperTest.java | 2 +- .../communication/SimpleNotifierTest.java | 150 ++- .../communication/SimplePublisherTest.java | 84 +- .../communication/TestUTransport.java | 230 ---- .../uprotocol/communication/UClientTest.java | 172 ++- .../uprotocol/communication/UPayloadTest.java | 78 +- .../uprotocol/transport/UTransportTest.java | 212 +-- src/test/resources/junit-platform.properties | 6 + src/test/resources/logback-test.xml | 12 + 44 files changed, 3309 insertions(+), 3471 deletions(-) delete mode 100644 src/main/java/org/eclipse/uprotocol/client/usubscription/v3/InMemoryUSubscriptionClient.java create mode 100644 src/main/java/org/eclipse/uprotocol/client/usubscription/v3/RpcClientBasedUSubscriptionClient.java create mode 100644 src/main/java/org/eclipse/uprotocol/communication/AbstractCommunicationLayerClient.java create mode 100644 src/main/java/org/eclipse/uprotocol/communication/InMemorySubscriber.java create mode 100644 src/main/java/org/eclipse/uprotocol/communication/Subscriber.java rename src/main/java/org/eclipse/uprotocol/{client/usubscription/v3 => communication}/SubscriptionChangeHandler.java (95%) create mode 100644 src/main/java/org/eclipse/uprotocol/transport/LocalUriProvider.java create mode 100644 src/main/java/org/eclipse/uprotocol/transport/StaticUriProvider.java create mode 100644 src/test/java/org/eclipse/uprotocol/client/usubscription/v3/RpcClientBasedUSubscriptionClientTest.java create mode 100644 src/test/java/org/eclipse/uprotocol/communication/CommunicationLayerClientTestBase.java create mode 100644 src/test/java/org/eclipse/uprotocol/communication/InMemorySubscriberTest.java delete mode 100644 src/test/java/org/eclipse/uprotocol/communication/TestUTransport.java create mode 100644 src/test/resources/junit-platform.properties create mode 100644 src/test/resources/logback-test.xml diff --git a/checkstyle.xml b/checkstyle.xml index 0cf45859..19c90b92 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -33,7 +33,9 @@ - + + + diff --git a/pom.xml b/pom.xml index 8ecb6c66..f8730b66 100644 --- a/pom.xml +++ b/pom.xml @@ -89,11 +89,23 @@ pom import + + org.slf4j + slf4j-bom + 2.0.17 + pom + import + + + org.slf4j + slf4j-api + + com.google.protobuf protobuf-java @@ -149,6 +161,18 @@ [1.4.4,) test + + ch.qos.logback + logback-classic + [1.5,) + test + + + + org.slf4j + jul-to-slf4j + test + diff --git a/src/main/java/org/eclipse/uprotocol/client/usubscription/v3/InMemoryUSubscriptionClient.java b/src/main/java/org/eclipse/uprotocol/client/usubscription/v3/InMemoryUSubscriptionClient.java deleted file mode 100644 index 0bfce6e9..00000000 --- a/src/main/java/org/eclipse/uprotocol/client/usubscription/v3/InMemoryUSubscriptionClient.java +++ /dev/null @@ -1,394 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Apache License Version 2.0 which is available at - * https://www.apache.org/licenses/LICENSE-2.0 - * - * SPDX-License-Identifier: Apache-2.0 - */ -package org.eclipse.uprotocol.client.usubscription.v3; - -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.ConcurrentHashMap; -import java.util.logging.Logger; - -import org.eclipse.uprotocol.communication.CallOptions; -import org.eclipse.uprotocol.communication.InMemoryRpcClient; -import org.eclipse.uprotocol.communication.Notifier; -import org.eclipse.uprotocol.communication.RpcClient; -import org.eclipse.uprotocol.communication.RpcMapper; -import org.eclipse.uprotocol.communication.SimpleNotifier; -import org.eclipse.uprotocol.communication.UPayload; -import org.eclipse.uprotocol.communication.UStatusException; -import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscribersRequest; -import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscribersResponse; -import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscriptionsRequest; -import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscriptionsResponse; -import org.eclipse.uprotocol.core.usubscription.v3.NotificationsRequest; -import org.eclipse.uprotocol.core.usubscription.v3.NotificationsResponse; -import org.eclipse.uprotocol.core.usubscription.v3.SubscribeAttributes; -import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionRequest; -import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionResponse; -import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionStatus; -import org.eclipse.uprotocol.core.usubscription.v3.USubscriptionProto; -import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeRequest; -import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeResponse; -import org.eclipse.uprotocol.core.usubscription.v3.Update; -import org.eclipse.uprotocol.transport.UListener; -import org.eclipse.uprotocol.transport.UTransport; -import org.eclipse.uprotocol.uri.factory.UriFactory; -import org.eclipse.uprotocol.v1.UCode; -import org.eclipse.uprotocol.v1.UMessage; -import org.eclipse.uprotocol.v1.UMessageType; -import org.eclipse.uprotocol.v1.UStatus; -import org.eclipse.uprotocol.v1.UUri; - -import com.google.protobuf.Descriptors.ServiceDescriptor; - -/** - * Implementation of USubscriptionClient that caches state information within the object - * and used for single tenant applications (ex. in-vehicle). The implementation uses {@link InMemoryRpcClient} - * that also stores RPC corelation information within the objects - */ -public class InMemoryUSubscriptionClient implements USubscriptionClient { - private final UTransport transport; - private final RpcClient rpcClient; - private final Notifier notifier; - - private static final ServiceDescriptor USUBSCRIPTION = USubscriptionProto.getDescriptor().getServices().get(0); - - // TODO: The following items eventually need to be pulled from generated code - private static final UUri SUBSCRIBE_METHOD = UriFactory.fromProto(USUBSCRIPTION, 1); - private static final UUri UNSUBSCRIBE_METHOD = UriFactory.fromProto(USUBSCRIPTION, 2); - private static final UUri FETCH_SUBSCRIBERS_METHOD = UriFactory.fromProto(USUBSCRIPTION, 8); - private static final UUri FETCH_SUBSCRIPTIONS_METHOD = UriFactory.fromProto(USUBSCRIPTION, 3); - private static final UUri REGISTER_NOTIFICATIONS_METHOD = UriFactory.fromProto(USUBSCRIPTION, 6); - private static final UUri UNREGISTER_NOTIFICATIONS_METHOD = UriFactory.fromProto(USUBSCRIPTION, 7); - - private static final UUri NOTIFICATION_TOPIC = UriFactory.fromProto(USUBSCRIPTION, 0x8000); - - - // Map to store subscription change notification handlers - private final ConcurrentHashMap mHandlers = new ConcurrentHashMap<>(); - - // transport Notification listener that will process subscription change notifications - private final UListener mNotificationListener = this::handleNotifications; - - - /** - * Creates a new USubscription client passing {@link UTransport} and {@link CallOptions} - * used to provide additional options for the RPC requests to uSubscription service. - * - * @param transport the transport to use for sending the notifications - */ - public InMemoryUSubscriptionClient (UTransport transport) { - this(transport, new InMemoryRpcClient(transport), new SimpleNotifier(transport)); - } - - - /** - * Creates a new USubscription client passing {@link UTransport}, {@link CallOptions}, - * and an implementation of {@link RpcClient} and {@link Notifier}. - * - * @param transport the transport to use for sending the notifications - * @param rpcClient the rpc client to use for sending the RPC requests - * @param notifier the notifier to use for registering the notification listener - */ - public InMemoryUSubscriptionClient (UTransport transport, RpcClient rpcClient, Notifier notifier) { - Objects.requireNonNull(transport, UTransport.TRANSPORT_NULL_ERROR); - Objects.requireNonNull(rpcClient, "RpcClient missing"); - Objects.requireNonNull(notifier, "Notifier missing"); - this.transport = transport; - this.rpcClient = rpcClient; - this.notifier = notifier; - - // Register the notification listener to receive subscription change notifications - notifier.registerNotificationListener(NOTIFICATION_TOPIC, mNotificationListener).toCompletableFuture().join(); - } - - - /** - * Subscribes to a given topic. - * - * The API will return a {@link CompletionStage} with the response {@link SubscriptionResponse} or exception - * with the failure if the subscription was not successful. The optional passed {@link SubscriptionChangeHandler} - * is used to receive notifications of changes to the subscription status like a transition from - * {@link SubscriptionStatus.State#SUBSCRIBE_PENDING} to {@link SubscriptionStatus.State#SUBSCRIBED} that - * occurs when we subscribe to remote topics that the device we are on has not yet a subscriber that has - * subscribed to said topic. - * - * @param topic The topic to subscribe to. - * @param listener The listener to be called when messages are received. - * @param options The {@link CallOptions} to be used for the subscription. - * @param handler {@link SubscriptionChangeHandler} to handle changes to subscription states. - * @return Returns the CompletionStage with {@link SubscriptionResponse} or exception with the failure - * reason as {@link UStatus}. {@link UCode#ALREADY_EXISTS} will be returned if you call this API multiple - * times passing a different handler. - */ - @Override - public CompletionStage subscribe( - UUri topic, - UListener listener, - CallOptions options, - SubscriptionChangeHandler handler) { - Objects.requireNonNull(topic, "Subscribe topic missing"); - Objects.requireNonNull(listener, "Request listener missing"); - Objects.requireNonNull(options, "CallOptions missing"); - - final SubscriptionRequest request = SubscriptionRequest.newBuilder() - .setTopic(topic) - .build(); - - // Send the subscription request and handle the response - return RpcMapper.mapResponse(rpcClient.invokeMethod( - SUBSCRIBE_METHOD, UPayload.pack(request), options), SubscriptionResponse.class) - - // Then register the listener to be called when messages are received - .thenCompose(response -> { - if ( response.getStatus().getState() == SubscriptionStatus.State.SUBSCRIBED || - response.getStatus().getState() == SubscriptionStatus.State.SUBSCRIBE_PENDING) { - // When registering the listener fails, we have end up in a situation where we - // have successfully (logically) subscribed to the topic via the USubscriptio service - // but we have not been able to register the listener with the local transport. - // This means that events might start getting forwarded to the local authority which - // are not being consumed. Apart from this inefficiency, this does not pose a real - // problem and since we return a failed future, the client might be inclined to try - // again and (eventually) succeed in registering the listener as well. - return transport.registerListener(topic, listener).thenApply(status -> response); - } - return CompletableFuture.completedFuture(response); - }) - - // Then Add the handler (if the client provided one) so the client can be notified of - // changes to the subscription state. - .whenComplete( (response, exception) -> { - if (exception == null && handler != null) { - mHandlers.compute(topic, (k, existingHandler) -> { - if (existingHandler != null && existingHandler != handler) { - throw new UStatusException(UCode.ALREADY_EXISTS, "Handler already registered"); - } - return handler; - }); - } - }); - } - - - /** - * Unsubscribes from a given topic. - * - * The subscriber no longer wishes to be subscribed to said topic so we issue a unsubscribe - * request to the USubscription service. The API will return a {@link CompletionStage} with the - * {@link UStatus} of the result. If we are unable to unsubscribe to the topic with USubscription - * service, the listener and handler (if any) will remain registered. - * - * @param topic The topic to unsubscribe to. - * @param listener The listener to be called when messages are received. - * @param options The {@link CallOptions} to be used for the unsubscribe request. - * @return Returns {@link UStatus} with the result from the unsubscribe request. - */ - @Override - public CompletionStage unsubscribe(UUri topic, UListener listener, CallOptions options) { - Objects.requireNonNull(topic, "Unsubscribe topic missing"); - Objects.requireNonNull(listener, "listener missing"); - Objects.requireNonNull(options, "CallOptions missing"); - - final UnsubscribeRequest unsubscribeRequest = UnsubscribeRequest.newBuilder() - .setTopic(topic) - .build(); - - return RpcMapper.mapResponseToResult( - // Send the unsubscribe request - rpcClient.invokeMethod(UNSUBSCRIBE_METHOD, UPayload.pack(unsubscribeRequest), options), - UnsubscribeResponse.class) - // Then unregister the listener - .thenCompose( response -> { - if (response.isSuccess()) { - // Remove the handler only if unsubscribe was successful - mHandlers.remove(topic); - - return transport.unregisterListener(topic, listener); - } - return CompletableFuture.completedFuture(response.failureValue()); - }); - } - - - /** - * Unregister the listener and removes any registered {@link SubscriptionChangeHandler} for the topic. - * - * This method is used to remove handlers/listeners without notifying the uSubscription service - * so that we can be persistently subscribed even when the uE is not running. - * - * @param topic The topic to subscribe to. - * @param listener The listener to be called when messages are received. - * @return Returns {@link UStatus} with the status of the listener unregister request. - */ - @Override - public CompletionStage unregisterListener(UUri topic, UListener listener) { - Objects.requireNonNull(topic, "Unsubscribe topic missing"); - Objects.requireNonNull(listener, "Request listener missing"); - return transport.unregisterListener(topic, listener) - .whenComplete((status, exception) -> mHandlers.remove(topic)); - } - - /** - * Close the subscription client and clean up resources. - */ - public void close() { - mHandlers.clear(); - notifier.unregisterNotificationListener(NOTIFICATION_TOPIC, mNotificationListener) - .toCompletableFuture().join(); - } - - - /** - * Register for Subscription Change Notifications. - * - * This API allows producers to register to receive subscription change notifications for - * topics that they produce only. - * - * @param topic The topic to register for notifications. - * @param handler The {@link SubscriptionChangeHandler} to handle the subscription changes. - * @param options The {@link CallOptions} to be used for the register request. - * @return {@link CompletionStage} completed successfully if uSubscription service accepts the - * request to register the caller to be notified of subscription changes, or - * the CompletionStage completes exceptionally with {@link UStatus} that indicates - * the failure reason. - */ - @Override - public CompletionStage registerForNotifications(UUri topic, - SubscriptionChangeHandler handler, CallOptions options) { - Objects.requireNonNull(topic, "Topic missing"); - Objects.requireNonNull(handler, "Handler missing"); - Objects.requireNonNull(options, "CallOptions missing"); - - NotificationsRequest request = NotificationsRequest.newBuilder() - .setTopic(topic) - .build(); - - return RpcMapper.mapResponse(rpcClient.invokeMethod(REGISTER_NOTIFICATIONS_METHOD, - UPayload.pack(request), options), NotificationsResponse.class) - // Then Add the handler (if the client provided one) so the client can be notified of - // changes to the subscription state. - .whenComplete( (response, exception) -> { - if (exception == null) { - mHandlers.compute(topic, (k, existingHandler) -> { - if (existingHandler != null && existingHandler != handler) { - throw new UStatusException(UCode.ALREADY_EXISTS, "Handler already registered"); - } - return handler; - }); - } - }); - } - - - /** - * Unregister for subscription change notifications. - * - * @param topic The topic to unregister for notifications. - * @param options The {@link CallOptions} to be used for the unregister request. - * @return {@link CompletionStage} completed successfully with {@link NotificationsResponse} with - * the status of the API call to uSubscription service, or completed unsuccessfully with - * {@link UStatus} with the reason for the failure. {@link UCode#PERMISSION_DENIED} is - * returned if the topic ue_id does not equal the callers ue_id. - */ - @Override - public CompletionStage unregisterForNotifications(UUri topic, CallOptions options) { - Objects.requireNonNull(topic, "Topic missing"); - Objects.requireNonNull(options, "CallOptions missing"); - - NotificationsRequest request = NotificationsRequest.newBuilder() - .setTopic(topic) - .build(); - - return RpcMapper.mapResponse(rpcClient.invokeMethod(UNREGISTER_NOTIFICATIONS_METHOD, - UPayload.pack(request), options), NotificationsResponse.class) - .whenComplete((response, exception) -> mHandlers.remove(topic)); - } - - - /** - * Fetch the list of subscribers for a given produced topic. - * - * @param topic The topic to fetch the subscribers for. - * @param options The {@link CallOptions} to be used for the fetch request. - * @return {@link CompletionStage} completed successfully with {@link FetchSubscribersResponse} with - * the list of subscribers, or completed unsuccessfully with {@link UStatus} with the reason - * for the failure. - */ - @Override - public CompletionStage fetchSubscribers(UUri topic, CallOptions options) { - Objects.requireNonNull(topic, "Topic missing"); - Objects.requireNonNull(options, "CallOptions missing"); - - FetchSubscribersRequest request = FetchSubscribersRequest.newBuilder().setTopic(topic).build(); - return RpcMapper.mapResponse(rpcClient.invokeMethod(FETCH_SUBSCRIBERS_METHOD, - UPayload.pack(request), options), FetchSubscribersResponse.class); - } - - - /** - * Fetch list of Subscriptions for a given topic. - * - * API provides more information than {@code fetchSubscribers()} in that it also returns - * {@link SubscribeAttributes} per subscriber that might be useful to the producer to know. - * - * @param request The request containing the topic to fetch subscriptions for. - * @param options The {@link CallOptions} to be used for the request. - * @return {@link CompletionStage} completed successfully with {@link FetchSubscriptionsResponse} that - * contains the subscription information per subscriber to the topic or completed unsuccessfully with - * {@link UStatus} with the reason for the failure. {@link UCode#PERMISSION_DENIED} is returned if the - * topic ue_id does not equal the callers ue_id. - */ - @Override - public CompletionStage fetchSubscriptions(FetchSubscriptionsRequest request, - CallOptions options) { - Objects.requireNonNull(request, "Request missing"); - Objects.requireNonNull(options, "CallOptions missing"); - - return RpcMapper.mapResponse(rpcClient.invokeMethod(FETCH_SUBSCRIPTIONS_METHOD, - UPayload.pack(request), options), FetchSubscriptionsResponse.class); - } - - - /** - * Handles incoming notifications from the USubscription service. - * - * @param message The notification message from the USubscription service - */ - private void handleNotifications(UMessage message) { - // Ignore messages that are not notifications - if (message.getAttributes().getType() != UMessageType.UMESSAGE_TYPE_NOTIFICATION) { - return; - } - - // Unpack the notification message from uSubscription called Update - Optional subscriptionUpdate = UPayload.unpack( - message.getPayload(), message.getAttributes().getPayloadFormat(), Update.class); - - // Check if we got the right subscription change notification - if (subscriptionUpdate.isPresent()) { - // Check if we have a handler registered for the subscription change notification for the specific - // topic that triggered the subscription change notification. It is very possible that the client - // did not register one to begin with (i.e they don't care to receive it) - mHandlers.computeIfPresent(subscriptionUpdate.get().getTopic(), (topic, handler) -> { - try { - handler.handleSubscriptionChange(subscriptionUpdate.get().getTopic(), - subscriptionUpdate.get().getStatus()); - } catch (Exception e) { - Logger.getGlobal().info(e.getMessage()); - } - return handler; - }); - } - } -} diff --git a/src/main/java/org/eclipse/uprotocol/client/usubscription/v3/RpcClientBasedUSubscriptionClient.java b/src/main/java/org/eclipse/uprotocol/client/usubscription/v3/RpcClientBasedUSubscriptionClient.java new file mode 100644 index 00000000..0f239c70 --- /dev/null +++ b/src/main/java/org/eclipse/uprotocol/client/usubscription/v3/RpcClientBasedUSubscriptionClient.java @@ -0,0 +1,193 @@ +/** + * SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.eclipse.uprotocol.client.usubscription.v3; + +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletionStage; + +import org.eclipse.uprotocol.Uoptions; +import org.eclipse.uprotocol.communication.CallOptions; +import org.eclipse.uprotocol.communication.RpcClient; +import org.eclipse.uprotocol.communication.UPayload; +import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscribersRequest; +import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscribersResponse; +import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscriptionsRequest; +import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscriptionsResponse; +import org.eclipse.uprotocol.core.usubscription.v3.NotificationsRequest; +import org.eclipse.uprotocol.core.usubscription.v3.NotificationsResponse; +import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionRequest; +import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionResponse; +import org.eclipse.uprotocol.core.usubscription.v3.USubscriptionProto; +import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeRequest; +import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeResponse; +import org.eclipse.uprotocol.v1.UUri; + +import com.google.protobuf.Message; +import com.google.protobuf.Descriptors.ServiceDescriptor; + +/** + * A USubscription client implementation for invoking operations of a USubscription service. + *

+ * The client requires an {@link RpcClient} for performing the remote procedure calls. + */ +public class RpcClientBasedUSubscriptionClient implements USubscriptionClient { + private static final ServiceDescriptor USUBSCRIPTION_DEFAULT_DESCRIPTOR = USubscriptionProto.getDescriptor() + .getServices().get(0); + + // TODO: The following items eventually need to be pulled from generated code + private static final int SUBSCRIBE_METHOD_ID = 0x0001; + private static final int UNSUBSCRIBE_METHOD_ID = 0x0002; + private static final int FETCH_SUBSCRIPTIONS_METHOD_ID = 0x0003; + private static final int REGISTER_NOTIFICATIONS_METHOD_ID = 0x0006; + private static final int UNREGISTER_NOTIFICATIONS_METHOD_ID = 0x0007; + private static final int FETCH_SUBSCRIBERS_METHOD_ID = 0x0008; + private static final int NOTIFICATION_TOPIC_ID = 0x8000; + + private final RpcClient rpcClient; + private final UUri serviceUri; + private final CallOptions callOptions; + + /** + * Creates a new client for interacting with the default instance of + * the local uSubscription service. + * + * @param rpcClient The client to use for sending the RPC requests. + * @param callOptions The options to use for the RPC calls. + * @throws NullPointerException if rpcClient or callOptions are {@code null}. + */ + public RpcClientBasedUSubscriptionClient (RpcClient rpcClient, CallOptions callOptions) { + this(rpcClient, callOptions, 0x0000, null); + } + + /** + * Creates a new client for interacting with a given uSubscription + * service instance. + * + * @param rpcClient The client to use for sending the RPC requests. + * @param callOptions The options to use for the RPC calls. + * @param subscriptionServiceInstanceId The instance of the subscription service to invoke, + * {@code 0x000} to use the default instance. + * @param subscriptionServiceAuthority The authority that the subscription service runs on, + * or {@code null} if the instance runs on the local authority. + * @throws NullPointerException if rpcClient or callOptions are {@code null}. + * @throws IllegalArgumentException if the instance ID is invalid. + */ + public RpcClientBasedUSubscriptionClient ( + RpcClient rpcClient, + CallOptions callOptions, + int subscriptionServiceInstanceId, + String subscriptionServiceAuthority) { + Objects.requireNonNull(rpcClient, "RpcClient missing"); + Objects.requireNonNull(callOptions, "CallOptions missing"); + if (subscriptionServiceInstanceId < 0 || subscriptionServiceInstanceId >= 0xFFFF) { + throw new IllegalArgumentException("Invalid subscription service instance ID"); + } + this.rpcClient = rpcClient; + this.callOptions = callOptions; + this.serviceUri = getUSubscriptionServiceUri(subscriptionServiceInstanceId, subscriptionServiceAuthority); + } + + private static UUri getUSubscriptionServiceUri(int instanceId, String authority) { + final var options = USUBSCRIPTION_DEFAULT_DESCRIPTOR.getOptions(); + var builder = UUri.newBuilder(); + Optional.ofNullable(authority).ifPresent(builder::setAuthorityName); + return builder + .setUeId((instanceId << 16) | options.getExtension(Uoptions.serviceId)) + .setUeVersionMajor(options.getExtension(Uoptions.serviceVersionMajor)) + .build(); + } + + private CompletionStage invokeMethod( + int methodId, + UPayload request, + CallOptions options, + Class responseType) { + Objects.requireNonNull(request, "Request missing"); + Objects.requireNonNull(options, "CallOptions missing"); + Objects.requireNonNull(responseType, "Response type missing"); + + final var method = UUri.newBuilder(serviceUri).setResourceId(methodId).build(); + return rpcClient.invokeMethod(method, request, options) + .thenApply(responsePayload -> UPayload.unpackOrDefaultInstance(responsePayload, responseType)); + } + + @Override + public UUri getSubscriptionServiceNotificationTopic() { + return UUri.newBuilder(serviceUri) + .setResourceId(NOTIFICATION_TOPIC_ID) + .build(); + } + + @Override + public CompletionStage subscribe(SubscriptionRequest request) { + Objects.requireNonNull(request, "Subscribe request missing"); + return invokeMethod( + SUBSCRIBE_METHOD_ID, + UPayload.pack(request), + callOptions, + SubscriptionResponse.class); + } + + @Override + public CompletionStage unsubscribe(UnsubscribeRequest request) { + Objects.requireNonNull(request, "Unsubscribe request missing"); + return invokeMethod( + UNSUBSCRIBE_METHOD_ID, + UPayload.pack(request), + callOptions, + UnsubscribeResponse.class); + } + + @Override + public CompletionStage fetchSubscribers(FetchSubscribersRequest request) { + Objects.requireNonNull(request, "Request missing"); + + return invokeMethod( + FETCH_SUBSCRIBERS_METHOD_ID, + UPayload.pack(request), + callOptions, + FetchSubscribersResponse.class); + } + + @Override + public CompletionStage fetchSubscriptions(FetchSubscriptionsRequest request) { + Objects.requireNonNull(request, "Request missing"); + + return invokeMethod( + FETCH_SUBSCRIPTIONS_METHOD_ID, + UPayload.pack(request), + callOptions, + FetchSubscriptionsResponse.class); + } + + @Override + public CompletionStage registerForNotifications(NotificationsRequest request) { + Objects.requireNonNull(request, "Request missing"); + return invokeMethod( + REGISTER_NOTIFICATIONS_METHOD_ID, + UPayload.pack(request), + callOptions, + NotificationsResponse.class); + } + + @Override + public CompletionStage unregisterForNotifications(NotificationsRequest request) { + Objects.requireNonNull(request, "Request missing"); + return invokeMethod( + UNREGISTER_NOTIFICATIONS_METHOD_ID, + UPayload.pack(request), + callOptions, + NotificationsResponse.class); + } +} diff --git a/src/main/java/org/eclipse/uprotocol/client/usubscription/v3/USubscriptionClient.java b/src/main/java/org/eclipse/uprotocol/client/usubscription/v3/USubscriptionClient.java index b188ec62..4ba4b301 100644 --- a/src/main/java/org/eclipse/uprotocol/client/usubscription/v3/USubscriptionClient.java +++ b/src/main/java/org/eclipse/uprotocol/client/usubscription/v3/USubscriptionClient.java @@ -14,247 +14,105 @@ import java.util.concurrent.CompletionStage; -import org.eclipse.uprotocol.communication.CallOptions; +import org.eclipse.uprotocol.communication.Notifier; import org.eclipse.uprotocol.communication.UStatusException; +import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscribersRequest; import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscribersResponse; import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscriptionsRequest; import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscriptionsResponse; +import org.eclipse.uprotocol.core.usubscription.v3.NotificationsRequest; import org.eclipse.uprotocol.core.usubscription.v3.NotificationsResponse; import org.eclipse.uprotocol.core.usubscription.v3.SubscribeAttributes; +import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionRequest; import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionResponse; -import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionStatus; -import org.eclipse.uprotocol.transport.UListener; -import org.eclipse.uprotocol.v1.UCode; -import org.eclipse.uprotocol.v1.UStatus; +import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeRequest; +import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeResponse; import org.eclipse.uprotocol.v1.UUri; /** - * The Client-side interface for communicating with the USubscription service. + * The client-side interface for interacting with a USubscription service instance. + * + * @see + * USubscription service specification */ public interface USubscriptionClient { /** - * Subscribes to a given topic. - * - * The API will return a {@link CompletionStage} with the response {@link SubscriptionResponse} or exception - * with {@link UStatusException} containing the reason for the failure. - * - * @param topic The topic to subscribe to. - * @param listener The listener to be called when a message is received on the topic. - * @return Returns the CompletionStage with {@link SubscriptionResponse} or exception with the failure. + * Gets the topic that the USubscription service instance uses for sending subscription change notifications. + *

+ * Clients can use this topic to register a listener for these notifications using + * {@link Notifier#registerNotificationListener(UUri, org.eclipse.uprotocol.transport.UListener)}. + * + * @return The topic. */ - default CompletionStage subscribe(UUri topic, UListener listener) { - return subscribe(topic, listener, CallOptions.DEFAULT); - } + UUri getSubscriptionServiceNotificationTopic(); /** * Subscribes to a given topic. - * - * The API will return a {@link CompletionStage} with the response {@link SubscriptionResponse} or exception - * with {@link UStatusException} containing the reason for the failure. - * - * @param topic The topic to subscribe to. - * @param listener The listener to be called when a message is received on the topic. - * @param options The {@link CallOptions} to be used for the subscription. - * @return Returns the CompletionStage with {@link SubscriptionResponse} or exception with the failure. + * + * @param request The request to send. + * @return The outcome of the operation. The stage will be completed with a {@link UStatusException} + * if the request has failed. + * @throws NullPointerException if request is {@code null}. */ - default CompletionStage subscribe(UUri topic, UListener listener, CallOptions options) { - return subscribe(topic, listener, options, null); - } - - - /** - * Subscribes to a given topic. - * - * The API will return a {@link CompletionStage} with the response {@link SubscriptionResponse} or exception - * with the failure if the subscription was not successful. The optional passed {@link SubscriptionChangeHandler} - * is used to receive notifications of changes to the subscription status like a transition from - * {@link SubscriptionStatus.State#SUBSCRIBE_PENDING} to {@link SubscriptionStatus.State#SUBSCRIBED} that - * occurs when we subscribe to remote topics that the device we are on has not yet a subscriber that has - * subscribed to said topic. - * - * @param topic The topic to subscribe to. - * @param listener The listener to be called when a messages are received. - * @param options The {@link CallOptions} to be used for the subscription. - * @param handler {@link SubscriptionChangeHandler} to handle changes to subscription states. - * @return Returns the CompletionStage with {@link SubscriptionResponse} or exception with the failure - * reason as {@link UStatus}. {@link UCode#ALREADY_EXISTS} will be returned if you call this API multiple - * times passing a different handler. - */ - CompletionStage subscribe(UUri topic, UListener listener, CallOptions options, - SubscriptionChangeHandler handler); - + CompletionStage subscribe(SubscriptionRequest request); /** * Unsubscribes from a given topic. - * - * The subscriber no longer wishes to be subscribed to said topic so we issue a unsubscribe - * request to the USubscription service. The API will return a {@link CompletionStage} with the - * {@link UStatus} of the result. If we are unable to unsubscribe to the topic with USubscription - * service, the listener and handler (if any) will remain registered. - * - * @param topic The topic to unsubscribe to. - * @param listener The listener to be called when a message is received on the topic. - * @return Returns {@link UStatus} with the result from the unsubscribe request. + * + * @param request The request to send. + * @return The outcome of the operation. The stage will be completed with a {@link UStatusException} + * if the request has failed. + * @throws NullPointerException if request is {@code null}. */ - default CompletionStage unsubscribe(UUri topic, UListener listener) { - return unsubscribe(topic, listener, CallOptions.DEFAULT); - } + CompletionStage unsubscribe(UnsubscribeRequest request); /** - * Unsubscribes from a given topic. + * Fetches a list of subscribers that are currently subscribed to a given topic. * - * The subscriber no longer wishes to be subscribed to said topic so we issue a unsubscribe - * request to the USubscription service. The API will return a {@link CompletionStage} with the - * {@link UStatus} of the result. If we are unable to unsubscribe to the topic with USubscription - * service, the listener and handler (if any) will remain registered. - * - * @param topic The topic to unsubscribe to. - * @param listener The listener to be called when a message is received on the topic. - * @param options The {@link CallOptions} to be used for the unsubscribe request. - * @return Returns {@link UStatus} with the result from the unsubscribe request. + * @param request The request to send. + * @return The outcome of the operation. The stage will be completed with a {@link UStatusException} + * if the request has failed. + * @throws NullPointerException if request is {@code null}. */ - CompletionStage unsubscribe(UUri topic, UListener listener, CallOptions options); - + CompletionStage fetchSubscribers(FetchSubscribersRequest request); /** - * Unregister a listener and removes any registered {@link SubscriptionChangeHandler} for the topic. - * - * This method is used to remove handlers/listeners without notifying the uSubscription service - * so that we can be persistently subscribed even when the uE is not running. + * Fetches all subscriptions for a given topic or subscriber. + *

+ * API provides more information than {@code #fetchSubscribers(UUri)} in that it also returns + * {@link SubscribeAttributes} per subscriber that might be useful to the producer to know. * - * @param topic The topic to subscribe to. - * @param listener The listener to be called when a message is received on the topic. - * @return Returns {@link UStatus} with the status of the listener unregister request. + * @param request The request to send. + * @return The outcome of the operation. The stage will be completed with a {@link UStatusException} + * if the request has failed. + * @throws NullPointerException if request is {@code null}. */ - CompletionStage unregisterListener(UUri topic, UListener listener); - + CompletionStage fetchSubscriptions(FetchSubscriptionsRequest request); /** - * Register for Subscription Change Notifications. - * + * Registers for notifications about changes to the subscription status for a given topic. + *

* This API allows producers to register to receive subscription change notifications for * topics that they produce only. * * NOTE: Subscribers are automatically registered to receive notifications when they call - * {@code subscribe()} API passing a {@link SubscriptionChangeHandler} so they do not need to - * call this API. + * {@link #subscribe(SubscriptionRequest)}. * - * @param topic The topic to register for notifications. - * @param handler The {@link SubscriptionChangeHandler} to handle the subscription changes. - * @return {@link CompletionStage} completed successfully if uSubscription service accepts the - * request to register the caller to be notified of subscription changes, or - * the CompletionStage completes exceptionally with {@link UStatus} that indicates - * the failure reason. + * @param request The request to send. + * @return The outcome of the operation. The stage will be completed with a {@link UStatusException} + * if the request has failed. + * @throws NullPointerException if request is {@code null}. */ - default CompletionStage registerForNotifications(UUri topic, - SubscriptionChangeHandler handler) { - return registerForNotifications(topic, handler, CallOptions.DEFAULT); - } - + CompletionStage registerForNotifications(NotificationsRequest request); /** - * Register for Subscription Change Notifications. - * - * This API allows producers to register to receive subscription change notifications for - * topics that they produce only. - * - * NOTE: Subscribers are automatically registered to receive notifications when they call - * {@code subscribe()} API passing a {@link SubscriptionChangeHandler} so they do not need to - * call this API. - * - * @param topic The topic to register for notifications. - * @param handler The {@link SubscriptionChangeHandler} to handle the subscription changes. - * @param options The {@link CallOptions} to be used for the request. - * @return {@link CompletionStage} completed successfully if uSubscription service accepts the - * request to register the caller to be notified of subscription changes, or - * the CompletionStage completes exceptionally with {@link UStatus} that indicates - * the failure reason. - */ - CompletionStage registerForNotifications(UUri topic, - SubscriptionChangeHandler handler, CallOptions options); - - - /** - * Unregister for subscription change notifications. - * - * @param topic The topic to unregister for notifications. - * @return {@link CompletionStage} completed successfully with {@link NotificationsResponse} with - * the status of the API call to uSubscription service, or completed unsuccessfully with - * {@link UStatus} with the reason for the failure. - */ - default CompletionStage unregisterForNotifications(UUri topic) { - return unregisterForNotifications(topic, CallOptions.DEFAULT); - } - - - /** - * Unregister for subscription change notifications. - * - * @param topic The topic to unregister for notifications. - * @param options The {@link CallOptions} to be used for the request. - * @return {@link CompletionStage} completed successfully with {@link NotificationsResponse} with - * the status of the API call to uSubscription service, or completed unsuccessfully with - * {@link UStatus} with the reason for the failure. - */ - CompletionStage unregisterForNotifications(UUri topic, CallOptions options); - - - /** - * Fetch the list of subscribers for a given produced topic. - * - * @param topic The topic to fetch the subscribers for. - * @return {@link CompletionStage} completed successfully with {@link FetchSubscribersResponse} with - * the list of subscribers, or completed unsuccessfully with {@link UStatus} with the reason - * for the failure. - */ - default CompletionStage fetchSubscribers(UUri topic) { - return fetchSubscribers(topic, CallOptions.DEFAULT); - } - - - /** - * Fetch the list of subscribers for a given produced topic. - * - * @param topic The topic to fetch the subscribers for. - * @param options The {@link CallOptions} to be used for the request. - * @return {@link CompletionStage} completed successfully with {@link FetchSubscribersResponse} with - * the list of subscribers, or completed unsuccessfully with {@link UStatus} with the reason - * for the failure. - */ - CompletionStage fetchSubscribers(UUri topic, CallOptions options); - - - /** - * Fetch list of Subscriptions for a given topic. - * - * API provides more information than {@code #fetchSubscribers(UUri)} in that it also returns - * {@link SubscribeAttributes} per subscriber that might be useful to the producer to know. - * - * @param request The request containing the topic to fetch subscriptions for. - * @return {@link CompletionStage} completed successfully with {@link FetchSubscriptionsResponse} that - * contains the subscription information per subscriber to the topic or completed unsuccessfully with - * {@link UStatus} with the reason for the failure. {@link UCode#PERMISSION_DENIED} is returned if the - * topic ue_id does not equal the callers ue_id. - */ - default CompletionStage fetchSubscriptions(FetchSubscriptionsRequest request) { - return fetchSubscriptions(request, CallOptions.DEFAULT); - } - - - /** - * Fetch list of Subscriptions for a given topic. - * - * API provides more information than {@code fetchSubscribers()} in that it also returns - * {@link SubscribeAttributes} per subscriber that might be useful to the producer to know. + * Cancels a registration for subscription change notifications. * - * @param request The request containing the topic to fetch subscriptions for. - * @param options The {@link CallOptions} to be used for the request. - * @return {@link CompletionStage} completed successfully with {@link FetchSubscriptionsResponse} that - * contains the subscription information per subscriber to the topic or completed unsuccessfully with - * {@link UStatus} with the reason for the failure. {@link UCode#PERMISSION_DENIED} is returned if the - * topic ue_id does not equal the callers ue_id. + * @param request The request to send. + * @return The outcome of the operation. The stage will be completed with a {@link UStatusException} + * if the request has failed. + * @throws NullPointerException if request is {@code null}. */ - CompletionStage fetchSubscriptions(FetchSubscriptionsRequest request, - CallOptions options); + CompletionStage unregisterForNotifications(NotificationsRequest request); } diff --git a/src/main/java/org/eclipse/uprotocol/communication/AbstractCommunicationLayerClient.java b/src/main/java/org/eclipse/uprotocol/communication/AbstractCommunicationLayerClient.java new file mode 100644 index 00000000..567a84a5 --- /dev/null +++ b/src/main/java/org/eclipse/uprotocol/communication/AbstractCommunicationLayerClient.java @@ -0,0 +1,36 @@ +/** + * SPDX-FileCopyrightText: 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.eclipse.uprotocol.communication; + +import java.util.Objects; + +import org.eclipse.uprotocol.transport.LocalUriProvider; +import org.eclipse.uprotocol.transport.UTransport; + +public class AbstractCommunicationLayerClient { + private final UTransport transport; + private final LocalUriProvider uriProvider; + + protected AbstractCommunicationLayerClient(UTransport transport, LocalUriProvider uriProvider) { + this.transport = Objects.requireNonNull(transport); + this.uriProvider = Objects.requireNonNull(uriProvider); + } + + protected UTransport getTransport() { + return transport; + } + + protected LocalUriProvider getUriProvider() { + return uriProvider; + } +} diff --git a/src/main/java/org/eclipse/uprotocol/communication/CallOptions.java b/src/main/java/org/eclipse/uprotocol/communication/CallOptions.java index bfe66b91..2c6d5b9b 100644 --- a/src/main/java/org/eclipse/uprotocol/communication/CallOptions.java +++ b/src/main/java/org/eclipse/uprotocol/communication/CallOptions.java @@ -13,7 +13,9 @@ package org.eclipse.uprotocol.communication; import java.util.Objects; +import java.util.Optional; +import org.eclipse.uprotocol.transport.builder.UMessageBuilder; import org.eclipse.uprotocol.v1.UPriority; /** @@ -23,7 +25,7 @@ public record CallOptions (Integer timeout, UPriority priority, String token) { public static final int TIMEOUT_DEFAULT = 10000; // Default timeout of 10 seconds // Default instance. - public static final CallOptions DEFAULT = new CallOptions(TIMEOUT_DEFAULT, UPriority.UPRIORITY_CS4, ""); + public static final CallOptions DEFAULT = new CallOptions(TIMEOUT_DEFAULT, UPriority.UPRIORITY_CS4, null); /** * Check to ensure CallOptions is not null. @@ -31,7 +33,6 @@ public record CallOptions (Integer timeout, UPriority priority, String token) { public CallOptions { Objects.requireNonNull(timeout); Objects.requireNonNull(priority); - Objects.requireNonNull(token); } /** @@ -41,7 +42,7 @@ public record CallOptions (Integer timeout, UPriority priority, String token) { * @param priority The priority of the method invocation. */ public CallOptions(Integer timeout, UPriority priority) { - this(timeout, priority, ""); + this(timeout, priority, null); } /** @@ -50,13 +51,24 @@ public CallOptions(Integer timeout, UPriority priority) { * @param timeout The timeout for the method invocation. */ public CallOptions(Integer timeout) { - this(timeout, UPriority.UPRIORITY_CS4, ""); + this(timeout, UPriority.UPRIORITY_CS4, null); } /** * Constructor for CallOptions. */ public CallOptions() { - this(TIMEOUT_DEFAULT, UPriority.UPRIORITY_CS4, ""); + this(TIMEOUT_DEFAULT, UPriority.UPRIORITY_CS4, null); + } + + /** + * Adds these call options to a message. + * + * @param builder The message builder. + */ + public void applyToMessage(UMessageBuilder builder) { + Optional.ofNullable(this.priority()).ifPresent(builder::withPriority); + Optional.ofNullable(this.timeout()).ifPresent(builder::withTtl); + Optional.ofNullable(this.token()).ifPresent(builder::withToken); } } diff --git a/src/main/java/org/eclipse/uprotocol/communication/InMemoryRpcClient.java b/src/main/java/org/eclipse/uprotocol/communication/InMemoryRpcClient.java index 7f13bb62..e1525e37 100644 --- a/src/main/java/org/eclipse/uprotocol/communication/InMemoryRpcClient.java +++ b/src/main/java/org/eclipse/uprotocol/communication/InMemoryRpcClient.java @@ -12,11 +12,17 @@ */ package org.eclipse.uprotocol.communication; +import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import org.eclipse.uprotocol.transport.LocalUriProvider; import org.eclipse.uprotocol.transport.UListener; import org.eclipse.uprotocol.transport.UTransport; import org.eclipse.uprotocol.transport.builder.UMessageBuilder; @@ -39,52 +45,58 @@ * or directly use the {@link UTransport} to send RPC requests and register listeners that * handle the RPC responses. */ -public class InMemoryRpcClient implements RpcClient { - // The transport to use for sending the RPC requests - private final UTransport transport; - +public class InMemoryRpcClient extends AbstractCommunicationLayerClient implements RpcClient { // Map to store the futures that needs to be completed when the response comes in - private final ConcurrentHashMap> mRequests = new ConcurrentHashMap<>(); + private final Map> mRequests = new ConcurrentHashMap<>(); // Generic listener to handle all RPC response messages - private final UListener mResponseHandler = this::handleResponses; + private final UListener mResponseHandler = this::handleResponse; + + private Consumer unexpectedMessageHandler; - /** - * Constructor for the DefaultRpcClient. - * - * @param transport the transport to use for sending the RPC requests + * Creates a client for a transport. + * + * @param transport The transport to use for sending the RPC requests. + * @param uriProvider The helper for creating URIs that represent local resources. + * @throws NullPointerException if any of the arguments are {@code null}. + * @throws CompletionException if registration of the response listener fails. */ - public InMemoryRpcClient (UTransport transport) { - Objects.requireNonNull(transport, UTransport.TRANSPORT_NULL_ERROR); - this.transport = transport; - - transport.registerListener(UriFactory.ANY, - transport.getSource(), mResponseHandler).toCompletableFuture().join(); - } + public InMemoryRpcClient(UTransport transport, LocalUriProvider uriProvider) { + super(transport, uriProvider); + getTransport().registerListener( + UriFactory.ANY, + getUriProvider().getSource(), + mResponseHandler) + .toCompletableFuture().join(); + } /** - * Invoke a method (send an RPC request) and receive the response - * (the returned {@link CompletionStage} {@link UPayload}.
- * - * @param methodUri The method URI to be invoked. - * @param requestPayload The request message to be sent to the server. - * @param options RPC method invocation call options, see {@link CallOptions} - * @return Returns the CompletionStage with the response payload or exception with the failure - * reason as {@link UStatus}. + * Sets a handler to be invoked for unexpected inbound messages. + * + * @param unexpectedResponseHandler A handler to invoke for incoming messages that cannot + * be processed, either because they are no RPC response messages or because they contain + * an unknown request ID. */ + void setUnexpectedMessageHandler(Consumer handler) { + this.unexpectedMessageHandler = handler; + } + @Override public CompletionStage invokeMethod(UUri methodUri, UPayload requestPayload, CallOptions options) { - options = Objects.requireNonNullElse(options, CallOptions.DEFAULT); - UMessageBuilder builder = UMessageBuilder.request(transport.getSource(), methodUri, options.timeout()); - UMessage request; - - if (!options.token().isBlank()) { - builder.withToken(options.token()); - } - // Build a request uMessage - request = builder.build(requestPayload); + Objects.requireNonNull(methodUri, "Method URI cannot be null"); + Objects.requireNonNull(requestPayload, "Request payload cannot be null"); + Objects.requireNonNull(options, "Call options cannot be null"); + + UMessageBuilder builder = UMessageBuilder.request(getUriProvider().getSource(), methodUri, options.timeout()); + Optional.ofNullable(options.priority()).ifPresent(priority -> builder.withPriority(priority)); + Optional.ofNullable(options.token()) + .filter(s -> !s.isBlank()) + .ifPresent(token -> builder.withToken(token)); + + // Build the request message + final UMessage request = builder.build(requestPayload); // Create the response future and store it in mRequests CompletableFuture responseFuture = new CompletableFuture() @@ -99,51 +111,48 @@ public CompletionStage invokeMethod(UUri methodUri, UPayload requestPa }); // Send the request - CompletionStage status = transport.send(request); - - return status.thenApply(s -> { - if (s.getCode() != UCode.OK) throw new UStatusException(s); - return s; - }).thenCompose(s -> responseFuture.thenApply(responseMessage -> - UPayload.pack(responseMessage.getPayload(), responseMessage.getAttributes().getPayloadFormat()) - )); + return getTransport().send(request) + .thenCompose(s -> responseFuture) + .thenApply(responseMessage -> UPayload.pack( + responseMessage.getPayload(), + responseMessage.getAttributes().getPayloadFormat()) + ); } - /** * Close the RPC client and clean up any resources. */ public void close() { mRequests.clear(); - transport.unregisterListener(UriFactory.ANY, transport.getSource(), mResponseHandler); + getTransport().unregisterListener(UriFactory.ANY, getUriProvider().getSource(), mResponseHandler); } - /** - * Handle the responses coming back from the server. - * - * @param response The response message from the server - */ - private void handleResponses(UMessage response) { - // Only handle responses messages, ignore all other messages like notifications - if (response.getAttributes().getType() != UMessageType.UMESSAGE_TYPE_RESPONSE) { + private void handleResponse(UMessage message) { + // Only handle responses messages + if (message.getAttributes().getType() != UMessageType.UMESSAGE_TYPE_RESPONSE) { + Optional.ofNullable(unexpectedMessageHandler).ifPresent(handler -> handler.accept(message)); return; } - final UAttributes responseAttributes = response.getAttributes(); + final UAttributes responseAttributes = message.getAttributes(); // Check if the response is for a request we made, if not then ignore it final CompletableFuture responseFuture = mRequests.remove(responseAttributes.getReqid()); if (responseFuture == null) { + Optional.ofNullable(unexpectedMessageHandler).ifPresent(handler -> handler.accept(message)); return; } // Check if the response has a commstatus and if it is not OK then complete the future with an exception if (responseAttributes.hasCommstatus() && responseAttributes.getCommstatus() != UCode.OK) { - final UCode code = responseAttributes.getCommstatus(); - responseFuture.completeExceptionally( - new UStatusException(code, "Communication error [" + code + "]")); - return; + // first, try to extract error details from payload + final var exception = UPayload.unpack(message, UStatus.class) + .map(UStatusException::new) + // fall back to a generic error based on commstatus + .orElseGet(() -> new UStatusException(responseAttributes.getCommstatus(), "Communication error")); + responseFuture.completeExceptionally(exception); + } else { + responseFuture.complete(message); } - responseFuture.complete(response); } } diff --git a/src/main/java/org/eclipse/uprotocol/communication/InMemoryRpcServer.java b/src/main/java/org/eclipse/uprotocol/communication/InMemoryRpcServer.java index 557371ea..66402dc5 100644 --- a/src/main/java/org/eclipse/uprotocol/communication/InMemoryRpcServer.java +++ b/src/main/java/org/eclipse/uprotocol/communication/InMemoryRpcServer.java @@ -12,21 +12,29 @@ */ package org.eclipse.uprotocol.communication; +import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; import java.util.concurrent.CompletionStage; import java.util.concurrent.CompletableFuture; +import org.eclipse.uprotocol.transport.LocalUriProvider; import org.eclipse.uprotocol.transport.UListener; import org.eclipse.uprotocol.transport.UTransport; import org.eclipse.uprotocol.transport.builder.UMessageBuilder; import org.eclipse.uprotocol.uri.factory.UriFactory; +import org.eclipse.uprotocol.uri.serializer.UriSerializer; +import org.eclipse.uprotocol.uri.validator.UriValidator; import org.eclipse.uprotocol.v1.UAttributes; import org.eclipse.uprotocol.v1.UCode; import org.eclipse.uprotocol.v1.UMessage; import org.eclipse.uprotocol.v1.UMessageType; import org.eclipse.uprotocol.v1.UStatus; import org.eclipse.uprotocol.v1.UUri; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** @@ -35,152 +43,161 @@ * to register handlers for processing RPC requests from clients. This implementation * uses an in-memory map to store the request handlers that needs to be invoked when the * request comes in from the client. - * - * *NOTE:* Developers are not required to use these APIs, they can implement their own - * or directly use the {@link UTransport} to register listeners that handle - * RPC requests and send RPC responses. + *

+ * NOTE: Developers are not required to use these APIs, they can implement their own + * or directly use a {@link UTransport} to register listeners that handle + * RPC requests and send RPC responses. */ -public class InMemoryRpcServer implements RpcServer { - // The transport to use for sending the RPC requests - private final UTransport transport; +public class InMemoryRpcServer extends AbstractCommunicationLayerClient implements RpcServer { + private static final Logger LOGGER = LoggerFactory.getLogger(InMemoryRpcServer.class); + + protected static final String REQUEST_HANDLER_ERROR_MESSAGE = "Failed to handle RPC request"; // Map to store the request handlers so we can handle the right request on the server side - private final ConcurrentHashMap mRequestsHandlers = new ConcurrentHashMap<>(); + private final Map mRequestsHandlers = new ConcurrentHashMap<>(); // Generic listener to handle all RPC request messages - private final UListener mRequestHandler = this::handleRequests; + private final UListener mRequestHandler = this::handleRequest; + private Consumer unexpectedMessageHandler; + private Consumer sendResponseErrorHandler; /** - * Constructor for the DefaultRpcServer. - * - * @param transport the transport to use for sending the RPC requests + * Creates a new server for a transport. + * + * @param transport The transport to use for receiving RPC requests and + * sending RPC responses. + * @param uriProvider The URI provider to use for generating local resource URIs. + * @throws NullPointerException if transport is {@code null}. */ - public InMemoryRpcServer (UTransport transport) { - Objects.requireNonNull(transport, UTransport.TRANSPORT_NULL_ERROR); - this.transport = transport; + public InMemoryRpcServer (UTransport transport, LocalUriProvider uriProvider) { + super(transport, uriProvider); } - /** - * Register a handler that will be invoked when when requests come in from clients for the given method. + * Sets the handler to invoke when an unexpected message is received. * - *

Note: Only one handler is allowed to be registered per method URI. + * @param handler The handler to invoke when an unexpected message is received. + */ + void setUnexpectedMessageHandler(Consumer handler) { + this.unexpectedMessageHandler = handler; + } + + /** + * Sets the handler to invoke when sending an RPC response fails. * - * @param method Uri for the method to register the listener for. - * @param handler The handler that will process the request for the client. - * @return Returns the status of registering the RpcListener. + * @param handler The handler to invoke when sending an RPC response fails. */ + void setSendErrorHandler(Consumer handler) { + this.sendResponseErrorHandler = handler; + } + @Override - public CompletionStage registerRequestHandler(UUri method, RequestHandler handler) { - if (method == null || handler == null) { - return CompletableFuture.completedFuture( - UStatus.newBuilder() - .setCode(UCode.INVALID_ARGUMENT) - .setMessage("Method URI or handler missing") - .build()); + public CompletionStage registerRequestHandler(UUri originFilter, int resourceId, RequestHandler handler) { + Objects.requireNonNull(originFilter, "Origin filter must not be null"); + Objects.requireNonNull(handler, "Request handler must not be null"); + + // create the method URI for where we want to register the listener + final var method = UUri.newBuilder(getUriProvider().getSource()) + .setResourceId(resourceId) + .build(); + if (!UriValidator.isRpcMethod(method)) { + return CompletableFuture.failedFuture(new UStatusException( + UCode.INVALID_ARGUMENT, "Resource ID must be an RPC method ID")); } - - // Ensure the method URI matches the transport source URI - if (!method.getAuthorityName().equals(transport.getSource().getAuthorityName()) || - method.getUeId() != transport.getSource().getUeId() || - method.getUeVersionMajor() != transport.getSource().getUeVersionMajor()) { - return CompletableFuture.completedFuture( - UStatus.newBuilder() - .setCode(UCode.INVALID_ARGUMENT) - .setMessage("Method URI does not match the transport source URI") - .build()); - } - try { - mRequestsHandlers.compute(method, (key, currentHandler) -> { - if (currentHandler != null) { - throw new UStatusException(UCode.ALREADY_EXISTS, "Handler already registered"); - } - - UStatus status = transport.registerListener(UriFactory.ANY, method, mRequestHandler) - .toCompletableFuture().join(); - if (status.getCode() != UCode.OK) { - throw new UStatusException(status); - } - return handler; - }); - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - } catch (UStatusException e) { - return CompletableFuture.completedFuture(e.getStatus()); + + synchronized (mRequestsHandlers) { + if (mRequestsHandlers.containsKey(method)) { + return CompletableFuture.failedFuture(new UStatusException( + UCode.ALREADY_EXISTS, "Handler already registered")); + } + return getTransport().registerListener(UriFactory.ANY, method, mRequestHandler) + .whenComplete((ok, throwable) -> { + if (throwable != null) { + mRequestsHandlers.remove(method); + } else { + mRequestsHandlers.put(method, handler); + } + }); } } - - /** - * Unregister a handler that will be invoked when when requests come in from clients for the given method. - * - * @param method Resolved UUri for where the listener was registered to receive messages from. - * @param handler The handler for processing requests - * @return Returns status of registering the RpcListener. - */ @Override - public CompletionStage unregisterRequestHandler(UUri method, RequestHandler handler) { - if (method == null || handler == null) { - return CompletableFuture.completedFuture( - UStatus.newBuilder() - .setCode(UCode.INVALID_ARGUMENT) - .setMessage("Method URI or handler missing") - .build()); - } - - // Ensure the method URI matches the transport source URI - if (!method.getAuthorityName().equals(transport.getSource().getAuthorityName()) || - method.getUeId() != transport.getSource().getUeId() || - method.getUeVersionMajor() != transport.getSource().getUeVersionMajor()) { - return CompletableFuture.completedFuture( - UStatus.newBuilder() - .setCode(UCode.INVALID_ARGUMENT) - .setMessage("Method URI does not match the transport source URI") - .build()); + public CompletionStage unregisterRequestHandler( + UUri originFilter, + int resourceId, + RequestHandler handler) { + Objects.requireNonNull(originFilter, "Origin filter must not be null"); + Objects.requireNonNull(handler, "Request handler must not be null"); + + final var method = UUri.newBuilder(getUriProvider().getSource()) + .setResourceId(resourceId) + .build(); + if (!UriValidator.isRpcMethod(method)) { + return CompletableFuture.failedFuture(new UStatusException( + UCode.INVALID_ARGUMENT, "Resource ID must be an RPC method ID")); } if (mRequestsHandlers.remove(method, handler)) { - return transport.unregisterListener(UriFactory.ANY, method, mRequestHandler); + return getTransport().unregisterListener(UriFactory.ANY, method, mRequestHandler); + } else { + return CompletableFuture.failedFuture(new UStatusException( + UCode.NOT_FOUND, "Handler not found")); } - - return CompletableFuture.completedFuture( - UStatus.newBuilder().setCode(UCode.NOT_FOUND).setMessage("Handler not found").build()); } - /** * Generic incoming handler to process RPC requests from clients * @param request The request message from clients */ - private void handleRequests(UMessage request) { + private void handleRequest(UMessage request) { + final UAttributes requestAttributes = request.getAttributes(); + // Only handle request messages, ignore all other messages like notifications - if (request.getAttributes().getType() != UMessageType.UMESSAGE_TYPE_REQUEST) { + if (requestAttributes.getType() != UMessageType.UMESSAGE_TYPE_REQUEST) { + Optional.ofNullable(unexpectedMessageHandler).ifPresent(handler -> handler.accept(request)); return; } - - final UAttributes requestAttributes = request.getAttributes(); - + // Check if the request is for one that we have registered a handler for, if not ignore it - final RequestHandler handler = mRequestsHandlers.get(requestAttributes.getSink()); - if (handler == null) { + final var requestHandler = mRequestsHandlers.get(requestAttributes.getSink()); + if (requestHandler == null) { + Optional.ofNullable(unexpectedMessageHandler).ifPresent(handler -> handler.accept(request)); return; } UPayload responsePayload; - UMessageBuilder responseBuilder = UMessageBuilder.response(request.getAttributes()); + final UMessageBuilder responseBuilder = UMessageBuilder.response(request.getAttributes()); try { - responsePayload = handler.handleRequest(request); + responsePayload = requestHandler.handleRequest(request); + } catch (UStatusException e) { + responseBuilder.withCommStatus(e.getStatus().getCode()); + responsePayload = UPayload.pack(e.getStatus()); } catch (Exception e) { - UCode code = UCode.INTERNAL; - responsePayload = null; - if (e instanceof UStatusException statusException) { - code = statusException.getStatus().getCode(); + if (LOGGER.isInfoEnabled()) { + LOGGER.info(""" + RPC RequestHandler threw unexpected exception while processing RPC request \ + [source: {}, sink: {}]: {}""", + UriSerializer.serialize(request.getAttributes().getSource()), + UriSerializer.serialize(request.getAttributes().getSink()), + e.getMessage()); } - responseBuilder.withCommStatus(code); + + final var status = UStatus.newBuilder() + .setCode(UCode.INTERNAL) + .setMessage(REQUEST_HANDLER_ERROR_MESSAGE) + .build(); + responseBuilder.withCommStatus(status.getCode()); + responsePayload = UPayload.pack(status); } - // TODO: Handle error sending the response - transport.send(responseBuilder.build(responsePayload)); + final var responseMessage = responseBuilder.build(responsePayload); + getTransport().send(responseMessage) + .whenComplete((ok, t) -> { + if (t != null) { + Optional.ofNullable(sendResponseErrorHandler).ifPresent(handler -> handler.accept(t)); + } + }); } } diff --git a/src/main/java/org/eclipse/uprotocol/communication/InMemorySubscriber.java b/src/main/java/org/eclipse/uprotocol/communication/InMemorySubscriber.java new file mode 100644 index 00000000..d0362045 --- /dev/null +++ b/src/main/java/org/eclipse/uprotocol/communication/InMemorySubscriber.java @@ -0,0 +1,325 @@ +/** + * SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.eclipse.uprotocol.communication; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +import org.eclipse.uprotocol.client.usubscription.v3.RpcClientBasedUSubscriptionClient; +import org.eclipse.uprotocol.client.usubscription.v3.USubscriptionClient; +import org.eclipse.uprotocol.core.usubscription.v3.NotificationsRequest; +import org.eclipse.uprotocol.core.usubscription.v3.NotificationsResponse; +import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionRequest; +import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionResponse; +import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeRequest; +import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeResponse; +import org.eclipse.uprotocol.core.usubscription.v3.Update; +import org.eclipse.uprotocol.transport.LocalUriProvider; +import org.eclipse.uprotocol.transport.UListener; +import org.eclipse.uprotocol.transport.UTransport; +import org.eclipse.uprotocol.uri.serializer.UriSerializer; +import org.eclipse.uprotocol.v1.UCode; +import org.eclipse.uprotocol.v1.UMessage; +import org.eclipse.uprotocol.v1.UMessageType; +import org.eclipse.uprotocol.v1.UUri; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A Subscriber which keeps all information about registered subscription change handlers in memory. + *

+ * The subscriber requires a {@link USubscriptionClient implementation} in order to inform a + * USubscription service instance about newly subscribed and unsubscribed topics. It also needs a + * {@link Notifier} for receiving notifications about subscription status updates from the USubscription + * service. Finally, it needs a {@link UTransport} for receiving events that have been published to + * subscribed topics. + *

+ * During startup the subscriber uses the Notifier to register a generic {@link UListener} for receiving + * notifications from the USubscription service. The listener maintains an in-memory mapping + * of subscribed topics to corresponding subscription change handlers. + *

+ * When a client {@link #subscribe(UUri, UListener, Optional) subscribes to a topic}, the USubscription + * service is informed about the new subscription and a (client provided) subscription change handler is + * registered with the listener. When a subscription change notification arrives from the USubscription + * service, the corresponding handler is being looked up and invoked. + */ +public final class InMemorySubscriber implements Subscriber { + private static final Logger LOGGER = LoggerFactory.getLogger(InMemorySubscriber.class); + + private final UTransport transport; + private final USubscriptionClient subscriptionClient; + private final Notifier notifier; + + // topic URI -> subscription change notification handler + private final Map subscriptionChangeHandlers = new ConcurrentHashMap<>(); + + // listener for processing subscription change notifications + private final UListener subscriptionChangeListener = this::handleSubscriptionChangeNotification; + + private Consumer unexpectedMessageHandler; + + /** + * Creates a new USubscription client passing {@link UTransport} and {@link CallOptions} + * used to provide additional options for the RPC requests to uSubscription service. + * + * @param transport The transport to use for sending the notifications + * @param uriProvider The URI provider to use for generating local resource URIs. + * @param options The call options to use for the RPC requests. + * @param subscriptionServiceInstanceId The instance of the subscription service to invoke, + * {@code 0x000} to use the default instance. + * @param subscriptionServiceAuthority The authority that the subscription service runs on, + * or {@code null} if the instance runs on the local authority. + */ + public InMemorySubscriber( + UTransport transport, + LocalUriProvider uriProvider, + CallOptions options, + int subscriptionServiceInstanceId, + String subscriptionServiceAuthority) { + this( + transport, + new RpcClientBasedUSubscriptionClient( + new InMemoryRpcClient(transport, uriProvider), + options, + subscriptionServiceInstanceId, + subscriptionServiceAuthority + ), + new SimpleNotifier(transport, uriProvider)); + } + + /** + * Creates a new USubscription client. + *

+ * Also registers a listener for subscription change notifications from the USubscription service + * instance that the given USubscription client is + * {@link USubscriptionClient#getSubscriptionServiceNotificationTopic() configured to use}. + * + * @param transport The transport to use for sending the notifications. + * @param subscriptionClient The client to use for interacting with the USubscription service. + * @param notifier The notifier to use for registering the notification listener. + */ + public InMemorySubscriber ( + UTransport transport, + USubscriptionClient subscriptionClient, + Notifier notifier) { + Objects.requireNonNull(transport, "Transport missing"); + Objects.requireNonNull(subscriptionClient, "SubscriptionClient missing"); + Objects.requireNonNull(notifier, "Notifier missing"); + this.transport = transport; + this.subscriptionClient = subscriptionClient; + this.notifier = notifier; + + // Register listener for receiving subscription change notifications + notifier.registerNotificationListener( + subscriptionClient.getSubscriptionServiceNotificationTopic(), + subscriptionChangeListener) + .toCompletableFuture().join(); + } + + void setUnexpectedMessageHandler(Consumer handler) { + this.unexpectedMessageHandler = handler; + } + + /** + * Closes this client and cleans up resources. + */ + public void close() { + subscriptionChangeHandlers.clear(); + try { + notifier.unregisterNotificationListener( + subscriptionClient.getSubscriptionServiceNotificationTopic(), + subscriptionChangeListener) + .toCompletableFuture().join(); + } catch (CompletionException e) { + LOGGER.debug("error while unregistering listener for subscription change notifications", e); + } + } + + void addSubscriptionChangeHandler( + UUri topic, + Optional handler) { + + handler.ifPresent(newHandler -> { + + subscriptionChangeHandlers.compute(topic, (k, existingHandler) -> { + if (existingHandler != null && existingHandler != newHandler) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + "Subscription state notification handler already registered for topic [{}]", + UriSerializer.serialize(topic)); + } + throw new UStatusException( + UCode.ALREADY_EXISTS, + "Subscription state notification handler already registered"); + } + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + "Registering subscription state notification handler for topic [{}]", + UriSerializer.serialize(topic)); + } + return newHandler; + }); + }); + } + + boolean hasSubscriptionChangeHandler(UUri topic) { + return subscriptionChangeHandlers.containsKey(topic); + } + + @Override + public CompletionStage subscribe( + UUri topic, + UListener listener, + Optional handler) { + Objects.requireNonNull(topic, "Subscribe topic missing"); + Objects.requireNonNull(listener, "Request listener missing"); + + final var request = SubscriptionRequest.newBuilder().setTopic(topic).build(); + + return subscriptionClient.subscribe(request) + // add the subscription change handler (if the client provided one) so the client + // can be notified of changes to the subscription state. + .thenApply(subscriptionResponse -> { + addSubscriptionChangeHandler(topic, handler); + return subscriptionResponse; + }) + .thenCompose(subscriptionResponse -> { + switch (subscriptionResponse.getStatus().getState()) { + case SUBSCRIBED: + case SUBSCRIBE_PENDING: + return transport.registerListener(topic, listener) + .thenApply(ok -> subscriptionResponse) + .exceptionallyCompose(t -> { + // When registering the listener fails, we have ended up in a situation where we + // have successfully (logically) subscribed to the topic via the USubscription service + // but we have not been able to register the listener with the local transport. + // This means that events might start getting forwarded to the local authority which + // are not being consumed. Apart from this inefficiency, this does not pose a real + // problem and since we return a failed future, the client might be inclined to try + // again and (eventually) succeed in registering the listener as well. + if (LOGGER.isWarnEnabled()) { + LOGGER.warn( + "Failed to register listener for topic [{}]: {}", + UriSerializer.serialize(topic), t.getMessage()); + } + return CompletableFuture.failedStage(t); + }); + default: + // The USubscription service should not return any other subscription state + return CompletableFuture.failedStage(new UStatusException( + UCode.INTERNAL, + "Subscription request resulted in invalid state")); + } + }); + } + + @Override + public CompletionStage unsubscribe(UUri topic, UListener listener) { + Objects.requireNonNull(topic, "Unsubscribe topic missing"); + Objects.requireNonNull(listener, "listener missing"); + + final var request = UnsubscribeRequest.newBuilder().setTopic(topic).build(); + + return subscriptionClient.unsubscribe(request) + .thenApply(unsubscribeResponse -> { + // remove subscription change handler (if one had been registered) + subscriptionChangeHandlers.remove(topic); + return unsubscribeResponse; + }) + .thenCompose(unsubscribeResponse -> { + // remove subscription change handler (if one had been registered) + subscriptionChangeHandlers.remove(topic); + // When this fails, we have ended up in a situation where we + // have successfully (logically) unsubscribed from the topic via the USubscription service + // but we have not been able to unregister the listener from the local transport. + // This means that events originating from entities connected to a different transport + // may no longer get forwarded to the local transport, resulting in the (still registered) + // listener not being invoked for these events. We therefore return an error which should + // trigger the client to try again and (eventually) succeed in unregistering the listener + // as well. + return transport.unregisterListener(topic, listener) + .whenComplete((ok, throwable) -> { + if (throwable != null) { + LOGGER.warn("Failed to unregister listener for topic {}: {}", topic, throwable); + } + }) + .thenApply(ok -> unsubscribeResponse); + }); + } + + @Override + public CompletionStage registerSubscriptionChangeHandler( + UUri topic, + SubscriptionChangeHandler handler) { + Objects.requireNonNull(topic, "Topic missing"); + Objects.requireNonNull(handler, "Handler missing"); + + final var request = NotificationsRequest.newBuilder().setTopic(topic).build(); + return subscriptionClient.registerForNotifications(request) + // Then add the handler so the client can be notified of + // changes to the subscription state. + .thenApply(response -> { + addSubscriptionChangeHandler(topic, Optional.of(handler)); + return response; + }); + } + + @Override + public CompletionStage unregisterSubscriptionChangeHandler(UUri topic) { + Objects.requireNonNull(topic, "Topic missing"); + + final var request = NotificationsRequest.newBuilder().setTopic(topic).build(); + return subscriptionClient.unregisterForNotifications(request) + .thenApply(response -> { + // remove subscription change handler (if one had been registered) + subscriptionChangeHandlers.remove(topic); + return response; + }); + } + + /** + * Handles incoming notifications from the USubscription service. + * + * @param message The notification message from the USubscription service + */ + private void handleSubscriptionChangeNotification(UMessage message) { + // Ignore messages that are not notifications + if (message.getAttributes().getType() != UMessageType.UMESSAGE_TYPE_NOTIFICATION) { + Optional.ofNullable(unexpectedMessageHandler).ifPresent(handler -> handler.accept(message)); + return; + } + + UPayload.unpack(message, Update.class) + // Check if we have a handler registered for the subscription change notification for the specific + // topic that triggered the subscription change notification. It is very possible that the client + // did not register one to begin with (i.e they don't care to receive it) + .ifPresent(subscriptionUpdate -> { + final var topic = subscriptionUpdate.getTopic(); + Optional.ofNullable(subscriptionChangeHandlers.get(topic)) + .ifPresentOrElse(handler -> { + try { + handler.handleSubscriptionChange(topic, subscriptionUpdate.getStatus()); + } catch (Exception e) { + LOGGER.info("Error handling subscription update", e); + } + }, () -> Optional.ofNullable(unexpectedMessageHandler) + .ifPresent(handler -> handler.accept(message))); + }); + } +} diff --git a/src/main/java/org/eclipse/uprotocol/communication/Notifier.java b/src/main/java/org/eclipse/uprotocol/communication/Notifier.java index d233d164..89862ae1 100644 --- a/src/main/java/org/eclipse/uprotocol/communication/Notifier.java +++ b/src/main/java/org/eclipse/uprotocol/communication/Notifier.java @@ -14,80 +14,109 @@ import java.util.concurrent.CompletionStage; -import org.eclipse.uprotocol.v1.UStatus; import org.eclipse.uprotocol.v1.UUri; import org.eclipse.uprotocol.transport.UListener; /** - * Communication Layer (uP-L2) Notification Interface.
- * - * Notifier is an interface that provides the APIs to send notifications (to a client) or - * register/unregister listeners to receive the notifications. + * A client for sending Notification messages to a uEntity. + * + * @see + * Communication Layer API Specifications */ public interface Notifier { /** - * Send a notification to a given topic.
- * - * @param topic The topic to send the notification to. - * @param destination The destination to send the notification to. - * @return Returns the {@link UStatus} with the status of the notification. + * Sends a notification to a uEntity. + *

+ * This default implementation invokes {@link #notify(int, UUri, CallOptions, UPayload)} with the + * given resource ID, destination, {@link CallOptions#DEFAULT default options} and an + * {@link UPayload#EMPTY empty payload}. + * + * @param resourceId The (local) resource identifier representing the origin of the notification. + * @param destination A URI representing the uEntity that the notification should be sent to. + * @return The outcome of the operation. The stage will be failed with a {@link UStatusException} + * if the notification could not be sent. + * @throws NullPointerException if any of the arguments are {@code null}. */ - default CompletionStage notify(UUri topic, UUri destination) { - return notify(topic, destination, null, null); + default CompletionStage notify(int resourceId, UUri destination) { + return notify(resourceId, destination, CallOptions.DEFAULT, UPayload.EMPTY); } /** - * Send a notification to a given topic with specific {@link CallOptions}.
- * - * @param topic The topic to send the notification to. + * Sends a notification to a uEntity. + *

+ * This default implementation invokes {@link #notify(int, UUri, CallOptions, UPayload)} with the + * given resource ID, destination, options and an {@link UPayload#EMPTY empty payload}. + * + * @param resourceId The (local) resource identifier representing the origin of the notification. * @param destination The destination to send the notification to. - * @param options Call options for the notification. - * @return Returns the {@link UStatus} with the status of the notification. + * @param options Options to include in the notification message. {@link CallOptions#DEFAULT} can + * be used for default options. + * @return The outcome of the operation. The stage will be failed with a {@link UStatusException} + * if the notification could not be sent. + * @throws NullPointerException if any of the arguments are {@code null}. */ - default CompletionStage notify(UUri topic, UUri destination, CallOptions options) { - return notify(topic, destination, options, null); + default CompletionStage notify(int resourceId, UUri destination, CallOptions options) { + return notify(resourceId, destination, options, UPayload.EMPTY); } /** - * Send a notification to a given topic passing a payload.
- * - * @param topic The topic to send the notification to. + * Sends a notification to a uEntity. + *

+ * This default implementation invokes {@link #notify(int, UUri, CallOptions, UPayload)} with the + * given resource ID, destination, payload and {@link CallOptions#DEFAULT default options}. + * + * @param resourceId The (local) resource identifier representing the origin of the notification. * @param destination The destination to send the notification to. - * @param payload The payload to send with the notification. - * @return Returns the {@link UStatus} with the status of the notification. + * @param payload The payload to include in the notification message. {@link UPayload#EMPTY} + * can be used if the notification has no payload. + * @return The outcome of the operation. The stage will be failed with a {@link UStatusException} + * if the notification could not be sent. + * @throws NullPointerException if any of the arguments are {@code null}. */ - default CompletionStage notify(UUri topic, UUri destination, UPayload payload) { - return notify(topic, destination, null, payload); + default CompletionStage notify(int resourceId, UUri destination, UPayload payload) { + return notify(resourceId, destination, CallOptions.DEFAULT, payload); } /** - * Send a notification to a given topic passing a payload and with specific {@link CallOptions}.
- * - * @param topic The topic to send the notification to. - * @param destination The destination to send the notification to. - * @param payload The payload to send with the notification. - * @param options Call options for the notification. - * @return Returns the {@link UStatus} with the status of the notification. + * Sends a notification to a uEntity. + * + * @param resourceId The (local) resource identifier representing the origin of the notification. + * @param destination A URI representing the uEntity that the notification should be sent to. + * @param options Options to include in the notification message. {@link CallOptions#DEFAULT} can + * be used for default options. + * @param payload The payload to include in the notification message. {@link UPayload#EMPTY} + * can be used if the notification has no payload. + * @return The outcome of the operation. The stage will be failed with a {@link UStatusException} + * if the notification could not be sent. + * @throws NullPointerException if any of the arguments are {@code null}. */ - CompletionStage notify(UUri topic, UUri destination, CallOptions options, UPayload payload); + CompletionStage notify(int resourceId, UUri destination, CallOptions options, UPayload payload); /** - * Register a listener for a notification topic.
- * - * @param topic The topic to register the listener to. - * @param listener The listener to be called when a message is received on the topic. - * @return Returns the {@link UStatus} with the status of the listener registration. + * Starts listening to a notification topic. + *

+ * More than one handler can be registered for the same topic. + * The same handler can be registered for multiple topics. + * + * @param topic The topic to listen to. The topic must not contain any wildcards. + * @param listener The handler to invoke for each notification that has been sent on the topic. + * @return The outcome of the operation. The stage will be failed with a {@link UStatusException} + * if the listener could not be registered. + * @throws NullPointerException if any of the arguments are {@code null}. */ - CompletionStage registerNotificationListener(UUri topic, UListener listener); + CompletionStage registerNotificationListener(UUri topic, UListener listener); /** - * Unregister a listener from a notification topic.
- * - * @param topic The topic to unregister the listener from. - * @param listener The listener to be unregistered from the topic. - * @return Returns the {@link UStatus} with the status of the listener that was unregistered. + * Unregisters a previously {@link #registerNotificationListener(UUri, UListener) registered handler} + * for listening to notifications. + * + * @param topic The topic that the handler had been registered for. + * @param listener The listener to unregister. + * @return The outcome of the operation. The stage will be failed with a {@link UStatusException} + * if the listener could not be unregistered. + * @throws NullPointerException if any of the arguments are {@code null}. */ - CompletionStage unregisterNotificationListener(UUri topic, UListener listener); + CompletionStage unregisterNotificationListener(UUri topic, UListener listener); } diff --git a/src/main/java/org/eclipse/uprotocol/communication/Publisher.java b/src/main/java/org/eclipse/uprotocol/communication/Publisher.java index d70d8748..07833a40 100644 --- a/src/main/java/org/eclipse/uprotocol/communication/Publisher.java +++ b/src/main/java/org/eclipse/uprotocol/communication/Publisher.java @@ -14,57 +14,72 @@ import java.util.concurrent.CompletionStage; -import org.eclipse.uprotocol.v1.UStatus; -import org.eclipse.uprotocol.v1.UUri; - /** - * uP-L2 interface and data models for Java.
- * - * uP-L1 interfaces implements the core uProtocol across various the communication middlewares - * and programming languages while uP-L2 API are the client-facing APIs that wrap the transport - * functionality into easy to use, language specific, APIs to do the most common functionality - * of the protocol (subscribe, publish, notify, invoke a Method, or handle RPC requests). + * A client for publishing messages to a topic. + * + * @see + * Communication Layer API Specifications */ public interface Publisher { /** - * Publish a message to a topic. - * - * @param topic The topic to publish to. - * @return Returns the {@link UStatus} with the status of the publish. + * Publishes a message to a topic. + *

+ * This default implementation invokes {@link #publish(int, CallOptions, UPayload)} with the + * given resource ID, {@link CallOptions#DEFAULT default options} and an + * {@link UPayload#EMPTY empty payload}. + + * @param resourceId The (local) resource ID of the topic to publish to. + * @return The outcome of the operation. The stage will be failed with a {@link UStatusException} + * if the message could not be published. + * @throws NullPointerException if any of the arguments are {@code null}. */ - default CompletionStage publish(UUri topic) { - return publish(topic, null, null); + default CompletionStage publish(int resourceId) { + return publish(resourceId, CallOptions.DEFAULT, UPayload.EMPTY); } /** - * Publish a message to a topic with specific {@link CallOptions}. + * Publishes a message to a topic. + *

+ * This default implementation invokes {@link #publish(int, CallOptions, UPayload)} with the + * given resource ID, options and an {@link UPayload#EMPTY empty payload}. * - * @param topic The topic to publish to. + * @param resourceId The (local) resource ID of the topic to publish to. * @param options The {@link CallOptions} for the publish. - * @return Returns the {@link UStatus} with the status of the publish. + * @return The outcome of the operation. The stage will be failed with a {@link UStatusException} + * if the message could not be published. + * @throws NullPointerException if any of the arguments are {@code null}. */ - default CompletionStage publish(UUri topic, CallOptions options) { - return publish(topic, options, null); + default CompletionStage publish(int resourceId, CallOptions options) { + return publish(resourceId, options, UPayload.EMPTY); } /** - * Publish a message to a topic passing {@link UPayload} as the payload. - * - * @param topic The topic to publish to. + * Publishes a message to a topic. + *

+ * This default implementation invokes {@link #publish(int, CallOptions, UPayload)} with the + * given resource ID, payload and {@link CallOptions#DEFAULT default options}. + * + * @param resourceId The (local) resource ID of the topic to publish to. * @param payload The {@link UPayload} to publish. - * @return Returns the {@link UStatus} with the status of the publish. + * @return The outcome of the operation. The stage will be failed with a {@link UStatusException} + * if the message could not be published. + * @throws NullPointerException if any of the arguments are {@code null}. */ - default CompletionStage publish(UUri topic, UPayload payload) { - return publish(topic, null, payload); + default CompletionStage publish(int resourceId, UPayload payload) { + return publish(resourceId, CallOptions.DEFAULT, payload); } /** - * Publish a message to a topic passing {@link UPayload} as the payload and with specific {@link CallOptions}. - * - * @param topic The topic to publish to. - * @param options The {@link CallOptions} for the publish. - * @param payload The {@link UPayload} to publish. - * @return Returns the {@link UStatus} with the status of the publish. + * Publishes a message to a topic. + * + * @param resourceId The (local) resource ID of the topic to publish to. + * @param options Options to include in the published message. {@link CallOptions#DEFAULT} can + * be used for default options. + * @param payload Payload to include in the published message. {@link UPayload#EMPTY} + * can be used if the message has no payload. + * @return The outcome of the operation. The stage will be failed with a {@link UStatusException} + * if the message could not be published. + * @throws NullPointerException if any of the arguments are {@code null}. */ - CompletionStage publish(UUri topic, CallOptions options, UPayload payload); + CompletionStage publish(int resourceId, CallOptions options, UPayload payload); } diff --git a/src/main/java/org/eclipse/uprotocol/communication/RpcClient.java b/src/main/java/org/eclipse/uprotocol/communication/RpcClient.java index fc4057d5..d3a72249 100644 --- a/src/main/java/org/eclipse/uprotocol/communication/RpcClient.java +++ b/src/main/java/org/eclipse/uprotocol/communication/RpcClient.java @@ -14,24 +14,26 @@ import java.util.concurrent.CompletionStage; import org.eclipse.uprotocol.v1.UUri; -import org.eclipse.uprotocol.v1.UStatus; /** - * Communication Layer (uP-L2) RPC Client Interface.
- * - * clients use this API to invoke a method (send a request and wait for a reply). + * A client for performing Remote Procedure Calls (RPC) on (other) uEntities. + * + * @see + * Communication Layer API specification */ public interface RpcClient { /** - * API for clients to invoke a method (send an RPC request) and receive the response (the returned - * {@link CompletionStage} {@link UPayload}.
+ * Invokes a method on a service. * - * @param methodUri The method URI to be invoked. - * @param requestPayload The request message to be sent to the server. - * @param options RPC method invocation call options, see {@link CallOptions} - * @return Returns the CompletionStage with the response payload or exception with the failure - * reason as {@link UStatus}. + * @param methodUri The method to be invoked. + * @param requestPayload The payload to include in the RPC request message to be sent + * to the server. Use {@link UPayload#EMPTY} if no payload is required. + * @param options RPC method invocation call options. Use {@link CallOptions#DEFAULT} for default options. + * @return The outcome of the method invocation. The stage will either succeed with the + * response payload, or it will be failed with a {@link UStatusException} if the method invocation + * did not succeed. + * @throws NullPointerException if any of the arguments are {@code null}. */ CompletionStage invokeMethod(UUri methodUri, UPayload requestPayload, CallOptions options); } diff --git a/src/main/java/org/eclipse/uprotocol/communication/RpcMapper.java b/src/main/java/org/eclipse/uprotocol/communication/RpcMapper.java index c24dd862..3926197b 100644 --- a/src/main/java/org/eclipse/uprotocol/communication/RpcMapper.java +++ b/src/main/java/org/eclipse/uprotocol/communication/RpcMapper.java @@ -23,7 +23,10 @@ * RPC Wrapper is an interface that provides static methods to be able to wrap an RPC request with * an RPC Response (uP-L2). APIs that return Message assumes that the payload is either protobuf serialized * UPayloadFormat.UPAYLOAD_FORMAT_PROTOBUF_WRAPPED_IN_ANY) or UPAYLOAD_FORMAT_PROTOBUF. + * + * @deprecated Use the methods for unpacking payload provided by {@link UPayload} instead. */ +@Deprecated(forRemoval = true) public interface RpcMapper { /** @@ -70,7 +73,9 @@ static CompletionStage mapResponse( * @return Returns a CompletionStage containing an RpcResult containing the declared expected * return type T, or a Status containing any errors. * @param The declared expected return type of the RPC method. - */ + * @deprecated Use the methods for unpacking payload provided by {@link UPayload} instead. + */ + @Deprecated(forRemoval = true) static CompletionStage> mapResponseToResult( CompletionStage responseFuture, Class expectedClazz) { @@ -99,6 +104,4 @@ static CompletionStage> mapResponseToResult( return RpcResult.failure(exception.getMessage(), exception); }); } - - } diff --git a/src/main/java/org/eclipse/uprotocol/communication/RpcResult.java b/src/main/java/org/eclipse/uprotocol/communication/RpcResult.java index cd06e1a3..1ad747ba 100644 --- a/src/main/java/org/eclipse/uprotocol/communication/RpcResult.java +++ b/src/main/java/org/eclipse/uprotocol/communication/RpcResult.java @@ -24,6 +24,7 @@ * or a failure with the UStatus returned by the failed call. * @param The type of the successful RPC call. */ +@Deprecated(forRemoval = true) public abstract class RpcResult { private RpcResult() { @@ -31,8 +32,8 @@ private RpcResult() { public abstract boolean isSuccess(); public abstract boolean isFailure(); - public abstract T getOrElse(final T defaultValue); - public abstract T getOrElse(final Supplier defaultValue); + public abstract T getOrElse(T defaultValue); + public abstract T getOrElse(Supplier defaultValue); public abstract RpcResult map(Function f); @@ -44,7 +45,7 @@ private RpcResult() { public abstract T successValue(); - private static class Success extends RpcResult { + private static final class Success extends RpcResult { private final T value; @@ -118,7 +119,7 @@ public String toString() { } } - private static class Failure extends RpcResult { + private static final class Failure extends RpcResult { private final UStatus value; diff --git a/src/main/java/org/eclipse/uprotocol/communication/RpcServer.java b/src/main/java/org/eclipse/uprotocol/communication/RpcServer.java index bc354470..94bff11b 100644 --- a/src/main/java/org/eclipse/uprotocol/communication/RpcServer.java +++ b/src/main/java/org/eclipse/uprotocol/communication/RpcServer.java @@ -14,35 +14,43 @@ import java.util.concurrent.CompletionStage; -import org.eclipse.uprotocol.v1.UStatus; +import org.eclipse.uprotocol.uri.factory.UriFactory; import org.eclipse.uprotocol.v1.UUri; /** - * Communication Layer (uP-L2) Rpc Server interface.
- * - * This interface provides APIs that services can call to register handlers for - * incoming requests for given methods. + * A server for exposing Remote Procedure Call (RPC) endpoints. + * + * @see + * Communication Layer API specification */ public interface RpcServer { /** - * Register a handler that will be invoked when when requests come in from clients for the given method. - * - *

Note: Only one handler is allowed to be registered per method URI. + * Registers an endpoint for RPC requests. + *

+ * Note that only a single endpoint can be registered for a given resource ID. + * However, the same request handler can be registered for multiple endpoints. * - * @param method Uri for the method to register the listener for. - * @param handler The handler that will process the request for the client. - * @return Returns the status of registering the RpcListener. + * @param originFilter A pattern defining origin addresses to accept requests from. Use {@link UriFactory#ANY} + * to match all origin addresses. + * @param resourceId The resource identifier of the (local) method to accept requests for. + * @param handler The handler to invoke for each incoming request that originates from a + * source matching the origin filter. + * @return The outcome of the registration. The stage will be completed with a {@link UStatusException} if + * registration has failed. + * @throws NullPointerException if any of the parameters is {@code null}. */ - CompletionStage registerRequestHandler(UUri method, RequestHandler handler); - + CompletionStage registerRequestHandler(UUri originFilter, int resourceId, RequestHandler handler); /** - * Unregister a handler that will be invoked when when requests come in from clients for the given method. + * Deregisters a previously {@link #registerRequestHandler(UUri, int, RequestHandler) registered endpoint}. * - * @param method Resolved UUri for where the listener was registered to receive messages from. - * @param handler The handler for processing requests - * @return Returns status of registering the RpcListener. + * @param originFilter The origin pattern that the endpoint had been registered for. + * @param resourceId The (local) resource identifier that the endpoint had been registered for. + * @param handler The handler to unregister. + * @return The outcome of the registration. The stage will be completed with a {@link UStatusException} if + * registration has failed. + * @throws NullPointerException if any of the parameters is {@code null}. */ - CompletionStage unregisterRequestHandler(UUri method, RequestHandler handler); + CompletionStage unregisterRequestHandler(UUri originFilter, int resourceId, RequestHandler handler); } diff --git a/src/main/java/org/eclipse/uprotocol/communication/SimpleNotifier.java b/src/main/java/org/eclipse/uprotocol/communication/SimpleNotifier.java index 881d8017..ef425d19 100644 --- a/src/main/java/org/eclipse/uprotocol/communication/SimpleNotifier.java +++ b/src/main/java/org/eclipse/uprotocol/communication/SimpleNotifier.java @@ -13,81 +13,61 @@ package org.eclipse.uprotocol.communication; import java.util.Objects; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import org.eclipse.uprotocol.transport.LocalUriProvider; import org.eclipse.uprotocol.transport.UListener; import org.eclipse.uprotocol.transport.UTransport; import org.eclipse.uprotocol.transport.builder.UMessageBuilder; -import org.eclipse.uprotocol.v1.UStatus; +import org.eclipse.uprotocol.uri.validator.UriValidator; +import org.eclipse.uprotocol.v1.UCode; import org.eclipse.uprotocol.v1.UUri; /** - * The following is an example implementation of the {@link Notifier} interface that - * wraps the {@link UTransport} for implementing the notification pattern to send - * notifications and register to receive notification events. - * - * *NOTE:* Developers are not required to use these APIs, they can implement their own - * or directly use the {@link UTransport} to send notifications and register listeners. + * A Notifier that uses the uProtocol Transport Layer API to send and receive + * notifications to/from (other) uEntities. + *

+ * NOTE: Developers are not required to use these APIs, they can implement + * their own or directly use the {@link UTransport} to send notifications and register + * listeners. */ -public class SimpleNotifier implements Notifier { - // The transport to use for sending the RPC requests - private final UTransport transport; +public class SimpleNotifier extends AbstractCommunicationLayerClient implements Notifier { /** - * Constructor for the DefaultNotifier. + * Creates a new notifier for a transport. * - * @param transport the transport to use for sending the notifications + * @param transport The transport to use for sending the notifications. + * @param uriProvider The helper to use for creating local resource URIs. */ - public SimpleNotifier (UTransport transport) { - Objects.requireNonNull(transport, UTransport.TRANSPORT_NULL_ERROR); - this.transport = transport; + public SimpleNotifier (UTransport transport, LocalUriProvider uriProvider) { + super(transport, uriProvider); } - - /** - * Send a notification to a given topic.
- * - * @param topic The topic to send the notification to. - * @param destination The destination to send the notification to. - * @param options Call options for the notification. - * @param payload The payload to send with the notification. - * @return Returns the {@link UStatus} with the status of the notification. - */ @Override - public CompletionStage notify(UUri topic, UUri destination, CallOptions options, UPayload payload) { - UMessageBuilder builder = UMessageBuilder.notification(topic, destination); - if (options != null) { - builder.withPriority(options.priority()); - builder.withTtl(options.timeout()); - builder.withToken(options.token()); + public CompletionStage notify(int resourceId, UUri destination, CallOptions options, UPayload payload) { + Objects.requireNonNull(destination); + Objects.requireNonNull(options); + Objects.requireNonNull(payload); + final var topic = getUriProvider().getResource(resourceId); + if (!UriValidator.isTopic(topic)) { + return CompletableFuture.failedFuture(new UStatusException( + UCode.INVALID_ARGUMENT, + "Resource ID does not map to a valid topic URI")); } - return transport.send((payload == null) ? builder.build() : - builder.build(payload)); + UMessageBuilder builder = UMessageBuilder.notification(topic, destination); + options.applyToMessage(builder); + return getTransport().send(builder.build(payload)); } - /** - * Register a listener for a notification topic.
- * - * @param topic The topic to register the listener to. - * @param listener The listener to be called when a message is received on the topic. - * @return Returns the {@link UStatus} with the status of the listener registration. - */ @Override - public CompletionStage registerNotificationListener(UUri topic, UListener listener) { - return transport.registerListener(topic, transport.getSource(), listener); + public CompletionStage registerNotificationListener(UUri topic, UListener listener) { + return getTransport().registerListener(topic, getUriProvider().getSource(), listener); } - - /** - * Unregister a listener from a notification topic.
- * - * @param topic The topic to unregister the listener from. - * @param listener The listener to be unregistered from the topic. - * @return Returns the {@link UStatus} with the status of the listener that was unregistered. - */ @Override - public CompletionStage unregisterNotificationListener(UUri topic, UListener listener) { - return transport.unregisterListener(topic, transport.getSource(), listener); + public CompletionStage unregisterNotificationListener(UUri topic, UListener listener) { + return getTransport().unregisterListener(topic, getUriProvider().getSource(), listener); } } diff --git a/src/main/java/org/eclipse/uprotocol/communication/SimplePublisher.java b/src/main/java/org/eclipse/uprotocol/communication/SimplePublisher.java index f37e6c05..ac3f427b 100644 --- a/src/main/java/org/eclipse/uprotocol/communication/SimplePublisher.java +++ b/src/main/java/org/eclipse/uprotocol/communication/SimplePublisher.java @@ -13,53 +13,45 @@ package org.eclipse.uprotocol.communication; import java.util.Objects; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import org.eclipse.uprotocol.transport.LocalUriProvider; import org.eclipse.uprotocol.transport.UTransport; import org.eclipse.uprotocol.transport.builder.UMessageBuilder; -import org.eclipse.uprotocol.v1.UStatus; -import org.eclipse.uprotocol.v1.UUri; +import org.eclipse.uprotocol.uri.validator.UriValidator; +import org.eclipse.uprotocol.v1.UCode; /** - * The following is an example implementation of the {@link Publisher} interface that - * wraps the {@link UTransport} for implementing the notification pattern to send - * notifications. - * - * *NOTE:* Developers are not required to use these APIs, they can implement their own + * A Publisher that uses the uProtocol Transport Layer API for publishing events to topics. + *

+ * NOTE: Developers are not required to use these APIs, they can implement their own * or directly use the {@link UTransport} to send notifications and register listeners. */ -public class SimplePublisher implements Publisher { - // The transport to use for sending the RPC requests - private final UTransport transport; +public class SimplePublisher extends AbstractCommunicationLayerClient implements Publisher { /** - * Constructor for the DefaultPublisher. - * + * Creates a new publisher for a transport. + * * @param transport the transport to use for sending the notifications + * @param uriProvider the URI provider to use for creating local resource URIs */ - public SimplePublisher (UTransport transport) { - Objects.requireNonNull(transport, UTransport.TRANSPORT_NULL_ERROR); - this.transport = transport; + public SimplePublisher(UTransport transport, LocalUriProvider uriProvider) { + super(transport, uriProvider); } - /** - * Publish a message to a topic passing {@link UPayload} as the payload. - * - * @param topic The topic to publish to. - * @param options The {@link CallOptions} for the publish. - * @param payload The {@link UPayload} to publish. - * @return {@link UStatus} with the result for sending the published message - */ @Override - public CompletionStage publish(UUri topic, CallOptions options, UPayload payload) { - Objects.requireNonNull(topic, "Publish topic missing"); - UMessageBuilder builder = UMessageBuilder.publish(topic); - if (options != null) { - builder.withPriority(options.priority()); - builder.withTtl(options.timeout()); - builder.withToken(options.token()); + public CompletionStage publish(int resourceId, CallOptions options, UPayload payload) { + Objects.requireNonNull(options); + Objects.requireNonNull(payload); + final var topic = getUriProvider().getResource(resourceId); + if (!UriValidator.isTopic(topic)) { + return CompletableFuture.failedFuture(new UStatusException( + UCode.INVALID_ARGUMENT, + "Resource ID does not map to a valid topic URI")); } - - return transport.send(builder.build(payload)); + UMessageBuilder builder = UMessageBuilder.publish(topic); + options.applyToMessage(builder); + return getTransport().send(builder.build(payload)); } } diff --git a/src/main/java/org/eclipse/uprotocol/communication/Subscriber.java b/src/main/java/org/eclipse/uprotocol/communication/Subscriber.java new file mode 100644 index 00000000..c11f3726 --- /dev/null +++ b/src/main/java/org/eclipse/uprotocol/communication/Subscriber.java @@ -0,0 +1,90 @@ +/** + * SPDX-FileCopyrightText: 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.eclipse.uprotocol.communication; + +import java.util.Optional; +import java.util.concurrent.CompletionStage; + +import org.eclipse.uprotocol.core.usubscription.v3.NotificationsResponse; +import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionResponse; +import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionStatus; +import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeResponse; +import org.eclipse.uprotocol.transport.UListener; +import org.eclipse.uprotocol.v1.UUri; + +/** + * A client for subscribing to topics. + * + * @see + * Communication Layer API Specifications + */ +public interface Subscriber { + /** + * Registers a handler to invoke for messages that have been published to a given topic. + *

+ * More than one handler can be registered for the same topic. + * The same handler can be registered for multiple topics. + * + * @param topic The topic to subscribe to. The topic must not contain any wildcards. + * @param handler The handler to invoke for each message that has been published to the topic. + * @param subscriptionChangeHandler A handler to invoke for any subscription state changes for + * the given topic, like a transition from {@link SubscriptionStatus.State#SUBSCRIBE_PENDING} to + * {@link SubscriptionStatus.State#SUBSCRIBED} that occurs when the client subscribes to a + * remote topic for which no other local subscribers exist yet. + * @return The outcome of the operation. The stage will be failed with a {@link UStatusException} + * if subscribing to the topic failed. + * @throws NullPointerException if any of the arguments are {@code null}. + */ + CompletionStage subscribe( + UUri topic, + UListener handler, + Optional subscriptionChangeHandler); + + /** + * Deregisters a previously {@link #subscribe(UUri, UListener, Optional) registered handler}. + * + * @param topic The topic that the handler had been registered for. + * @param handler The handler to unregister. + * @return The outcome of the operation. The stage will be failed with a {@link UStatusException} + * if unsubscribing from the topic failed. + * @throws NullPointerException if any of the arguments are {@code null}. + */ + CompletionStage unsubscribe(UUri topic, UListener handler); + + /** + * Registers a handler for receiving subscription change notifications for a topic. + *

+ * This method can be used by event producers to get notified about other uEntities' + * attempts to subscribe to topics that they publish to. + * + * @param topic The topic to get subscription change notifications for. + * @param handler The handler to invoke for subscription changes. + * @return The outcome of the operation. The stage will be failed with a {@link UStatusException} + * if unsubscribing from the topic failed. + * @throws NullPointerException if any of the arguments are {@code null}. + */ + CompletionStage registerSubscriptionChangeHandler( + UUri topic, + SubscriptionChangeHandler handler); + + /** + * Unregisters a {@link #registerSubscriptionChangeHandler(UUri, SubscriptionChangeHandler) previously registered} + * subscription change handler. + * + * @param topic The topic that the handler had been registered for. + * @return The outcome of the operation. The stage will be failed with a {@link UStatusException} + * if unsubscribing from the topic failed. + * @throws NullPointerException if topic is {@code null}. + */ + CompletionStage unregisterSubscriptionChangeHandler(UUri topic); +} diff --git a/src/main/java/org/eclipse/uprotocol/client/usubscription/v3/SubscriptionChangeHandler.java b/src/main/java/org/eclipse/uprotocol/communication/SubscriptionChangeHandler.java similarity index 95% rename from src/main/java/org/eclipse/uprotocol/client/usubscription/v3/SubscriptionChangeHandler.java rename to src/main/java/org/eclipse/uprotocol/communication/SubscriptionChangeHandler.java index 0b36a9fb..88f7e1c8 100644 --- a/src/main/java/org/eclipse/uprotocol/client/usubscription/v3/SubscriptionChangeHandler.java +++ b/src/main/java/org/eclipse/uprotocol/communication/SubscriptionChangeHandler.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: Apache-2.0 */ -package org.eclipse.uprotocol.client.usubscription.v3; +package org.eclipse.uprotocol.communication; import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionStatus; import org.eclipse.uprotocol.v1.UUri; diff --git a/src/main/java/org/eclipse/uprotocol/communication/UClient.java b/src/main/java/org/eclipse/uprotocol/communication/UClient.java index c52e5acf..e237d565 100644 --- a/src/main/java/org/eclipse/uprotocol/communication/UClient.java +++ b/src/main/java/org/eclipse/uprotocol/communication/UClient.java @@ -15,89 +15,92 @@ import java.util.Objects; import java.util.concurrent.CompletionStage; +import org.eclipse.uprotocol.transport.LocalUriProvider; import org.eclipse.uprotocol.transport.UListener; import org.eclipse.uprotocol.transport.UTransport; -import org.eclipse.uprotocol.v1.UStatus; import org.eclipse.uprotocol.v1.UUri; /** - * Default implementation of the communication layer that uses the {@link UTransport}. + * A client for Communication Layer APIs. */ -public class UClient implements RpcServer, Notifier, Publisher, RpcClient { - - // The transport to use for sending the RPC requests - private final UTransport transport; - - private final InMemoryRpcServer rpcServer; - private final SimplePublisher publisher; - private final SimpleNotifier notifier; - private final InMemoryRpcClient rpcClient; - - private UClient (UTransport transport) { - this.transport = transport; - - rpcServer = new InMemoryRpcServer(transport); - publisher = new SimplePublisher(transport); - notifier = new SimpleNotifier(transport); - rpcClient = new InMemoryRpcClient(transport); - } +public final class UClient implements RpcServer, Notifier, Publisher, RpcClient { + + private final RpcServer rpcServer; + private final Publisher publisher; + private final Notifier notifier; + private final RpcClient rpcClient; + /** + * Creates a new client. + * + * @param rpcClient The RPC client to use. + * @param rpcServer The RPC server to use. + * @param publisher The publisher to use. + * @param notifier The notifier to use. + * @throws NullPointerException if any of the arguments are {@code null}. + */ + public UClient(RpcClient rpcClient, RpcServer rpcServer, Publisher publisher, Notifier notifier) { + this.rpcClient = Objects.requireNonNull(rpcClient); + this.rpcServer = Objects.requireNonNull(rpcServer); + this.publisher = Objects.requireNonNull(publisher); + this.notifier = Objects.requireNonNull(notifier); + } @Override - public CompletionStage notify(UUri topic, UUri destination, CallOptions options, UPayload payload) { - return notifier.notify(topic, destination, options, payload); + public CompletionStage notify(int resourceId, UUri destination, CallOptions options, UPayload payload) { + return notifier.notify(resourceId, destination, options, payload); } @Override - public CompletionStage registerNotificationListener(UUri topic, UListener listener) { - Objects.requireNonNull(transport, UTransport.TRANSPORT_NULL_ERROR); + public CompletionStage registerNotificationListener(UUri topic, UListener listener) { return notifier.registerNotificationListener(topic, listener); } @Override - public CompletionStage unregisterNotificationListener(UUri topic, UListener listener) { + public CompletionStage unregisterNotificationListener(UUri topic, UListener listener) { return notifier.unregisterNotificationListener(topic, listener); } @Override - public CompletionStage publish(UUri topic, CallOptions options, UPayload payload) { - return publisher.publish(topic, options, payload); + public CompletionStage publish(int resourceId, CallOptions options, UPayload payload) { + return publisher.publish(resourceId, options, payload); } @Override - public CompletionStage registerRequestHandler(UUri method, RequestHandler handler) { - return rpcServer.registerRequestHandler(method, handler); + public CompletionStage registerRequestHandler(UUri originFilter, int resourceId, RequestHandler handler) { + return rpcServer.registerRequestHandler(originFilter, resourceId, handler); } - @Override - public CompletionStage unregisterRequestHandler(UUri method, RequestHandler handler) { - return rpcServer.unregisterRequestHandler(method, handler); + public CompletionStage unregisterRequestHandler(UUri originFilter, int resourceId, + RequestHandler handler) { + return rpcServer.unregisterRequestHandler(originFilter, resourceId, handler); } - @Override public CompletionStage invokeMethod(UUri methodUri, UPayload requestPayload, CallOptions options) { return rpcClient.invokeMethod(methodUri, requestPayload, options); } - /** - * Create a new instance of UPClient. + * Creates a new client for a transport implementation. * - * @param transport The transport to use for sending the RPC requests + * @param transport The transport to use for sending and receiving messages. + * @param uriProvider The helper to use for creating local resource URIs. * @return Returns a new instance of the RPC client + * @throws NullPointerException if any of the arguments are {@code null}. */ - public static UClient create(UTransport transport) { - Objects.requireNonNull(transport, UTransport.TRANSPORT_NULL_ERROR); - return new UClient(transport); - } - - - public void close() { - rpcClient.close(); + public static UClient create(UTransport transport, LocalUriProvider uriProvider) { + Objects.requireNonNull(transport); + Objects.requireNonNull(uriProvider); + return new UClient( + new InMemoryRpcClient(transport, uriProvider), + new InMemoryRpcServer(transport, uriProvider), + new SimplePublisher(transport, uriProvider), + new SimpleNotifier(transport, uriProvider) + ); } } diff --git a/src/main/java/org/eclipse/uprotocol/communication/UPayload.java b/src/main/java/org/eclipse/uprotocol/communication/UPayload.java index 9cbe278e..d461b038 100644 --- a/src/main/java/org/eclipse/uprotocol/communication/UPayload.java +++ b/src/main/java/org/eclipse/uprotocol/communication/UPayload.java @@ -21,6 +21,7 @@ import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.Message; +import org.eclipse.uprotocol.v1.UCode; import org.eclipse.uprotocol.v1.UMessage; import org.eclipse.uprotocol.v1.UPayloadFormat; @@ -106,18 +107,19 @@ public static Optional unpack(UMessage message, Class return unpack(message.getPayload(), message.getAttributes().getPayloadFormat(), clazz); } - /** - * Unpack a uPayload into {@link Message}. + * Unpacks a protobuf from a message payload into a Java type. *

* IMPORTANT NOTE: If {@link UPayloadFormat} is not * {@link UPayloadFormat#UPAYLOAD_FORMAT_PROTOBUF_WRAPPED_IN_ANY}, * there is no guarantee that the parsing to T is correct as we do not have the data schema. * - * @param payload the payload to unpack - * @param clazz the class of the message to unpack - * @return the unpacked message + * @param payload The payload to unpack. + * @param clazz The Java type to unpack the payload to. + * @return The unpacked type instance. + * @deprecated Use {@link #unpackOrDefaultInstance(UPayload, Class)} instead. */ + @Deprecated(forRemoval = true) public static Optional unpack(UPayload payload, Class clazz) { if (payload == null) { return Optional.empty(); @@ -125,20 +127,42 @@ public static Optional unpack(UPayload payload, Class return unpack(payload.data(), payload.format(), clazz); } + /** + * Unpacks a protobuf from a message payload into a Java type. + * + * @param payload The payload to unpack. + * @param expectedType The Java type to unpack the protobuf to. + * @return An instance of the expected type. The instance will be the default instance if the + * given protobuf is empty and the payload format is {@link UPayloadFormat#UPAYLOAD_FORMAT_PROTOBUF}. + *

+ * IMPORTANT NOTE: If format is not + * {@link UPayloadFormat#UPAYLOAD_FORMAT_PROTOBUF_WRAPPED_IN_ANY} then there is no guarantee + * that the returned instance's fields contain proper values because in the absence of a data schema + * it is unclear, if the protobuf actually represents an instance of the expected type. + * @throws NullPointerException if any of the arguments are {@code null}. + * @throws UStatusException if the protobuf cannot be unpacked to the expected type. + */ + public static T unpackOrDefaultInstance(UPayload payload, Class expectedType) { + return unpackOrDefaultInstance(payload.data(), payload.format(), expectedType); + } /** - * Unpack a uPayload into a {@link Message}. + * Unpacks a protobuf into a Java type. *
* IMPORTANT NOTE: If the format is not {@link UPayloadFormat#UPAYLOAD_FORMAT_PROTOBUF_WRAPPED_IN_ANY}, * there is no guarantee that the parsing to T is correct as we do not have the data schema. * - * @param data The serialized UPayload data - * @param format The serialization format of the payload - * @param clazz the class of the message to unpack - * @return the unpacked message + * @param data The protobuf to unpack. + * @param format The serialization format of the protobuf. + * @param clazz The Java type to unpack the protobuf to. + * @return The unpacked type instance. + * @throws NullPointerException if clazz is {@code null}. + * @deprecated Use {@link #unpackOrDefaultInstance(ByteString, UPayloadFormat, Class)} instead. */ + @Deprecated(forRemoval = true) @SuppressWarnings("unchecked") public static Optional unpack(ByteString data, UPayloadFormat format, Class clazz) { + Objects.requireNonNull(clazz, "clazz must not be null"); format = Objects.requireNonNullElse(format, UPayloadFormat.UPAYLOAD_FORMAT_UNSPECIFIED); if (data == null || data.isEmpty()) { return Optional.empty(); @@ -160,4 +184,57 @@ public static Optional unpack(ByteString data, UPayloadFo return Optional.empty(); } } + + /** + * Unpacks a protobuf to a Java type. + * + * @param protobuf The protobuf to unpack. + * @param format The serialization format of the protobuf. + * @param expectedType The Java type to unpack the protobuf to. + * @return An instance of the expected type. The instance will be the default instance if the + * given protobuf is empty and the payload format is {@link UPayloadFormat#UPAYLOAD_FORMAT_PROTOBUF}. + *

+ * IMPORTANT NOTE: If format is not + * {@link UPayloadFormat#UPAYLOAD_FORMAT_PROTOBUF_WRAPPED_IN_ANY} then there is no guarantee + * that the returned instance's fields contain proper values because in the absence of a data schema + * it is unclear, if the protobuf actually represents an instance of the expected type. + * @throws NullPointerException if any of the arguments are {@code null}. + * @throws UStatusException if the protobuf cannot be unpacked to the expected type. + */ + @SuppressWarnings("unchecked") + public static T unpackOrDefaultInstance( + ByteString protobuf, + UPayloadFormat format, + Class expectedType) { + Objects.requireNonNull(protobuf, "data must not be null"); + Objects.requireNonNull(format, "format must not be null"); + Objects.requireNonNull(expectedType, "expectedType must not be null"); + switch (format) { + case UPAYLOAD_FORMAT_UNSPECIFIED: // Default is WRAPPED_IN_ANY + case UPAYLOAD_FORMAT_PROTOBUF_WRAPPED_IN_ANY : + try { + return Any.parseFrom(protobuf).unpack(expectedType); + } catch (InvalidProtocolBufferException e) { + throw new UStatusException(UCode.INVALID_ARGUMENT, "Failed to unpack Any", e); + } + + case UPAYLOAD_FORMAT_PROTOBUF: + T defaultInstance = com.google.protobuf.Internal.getDefaultInstance(expectedType); + if (protobuf.isEmpty()) { + // this can happen when trying to unpack a proto message that has no fields + // and is therefore encoded as an empty byte array + return defaultInstance; + } else { + try { + return (T) defaultInstance.getParserForType().parseFrom(protobuf); + } catch (InvalidProtocolBufferException e) { + throw new UStatusException(UCode.INVALID_ARGUMENT, "Failed to unpack protobuf", e); + } + } + + default: + throw new UStatusException( + UCode.INVALID_ARGUMENT, "Unsupported payload format"); + } + } } diff --git a/src/main/java/org/eclipse/uprotocol/transport/LocalUriProvider.java b/src/main/java/org/eclipse/uprotocol/transport/LocalUriProvider.java new file mode 100644 index 00000000..7f99b3fc --- /dev/null +++ b/src/main/java/org/eclipse/uprotocol/transport/LocalUriProvider.java @@ -0,0 +1,46 @@ +/** + * SPDX-FileCopyrightText: 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.eclipse.uprotocol.transport; + +import org.eclipse.uprotocol.v1.UUri; + +/** + * A factory for URIs representing this uEntity's resources. + *

+ * Implementations may use arbitrary mechanisms to determine the information that + * is necessary for creating URIs, e.g. environment variables, configuration files etc. + */ +public interface LocalUriProvider { + /** + * Gets the authority used for URIs representing this uEntity's resources. + * + * @return The authority name. + */ + String getAuthority(); + + /** + * Gets the URI that represents the resource that this uEntity expects + * RPC responses and notifications to be sent to. + * + * @return The source URI. + */ + UUri getSource(); + + /** + * Gets a URI that represents a given resource of this uEntity. + * + * @param id The ID of the resource. + * @return The resource URI. + */ + UUri getResource(int id); +} diff --git a/src/main/java/org/eclipse/uprotocol/transport/StaticUriProvider.java b/src/main/java/org/eclipse/uprotocol/transport/StaticUriProvider.java new file mode 100644 index 00000000..ff509188 --- /dev/null +++ b/src/main/java/org/eclipse/uprotocol/transport/StaticUriProvider.java @@ -0,0 +1,79 @@ +/** + * SPDX-FileCopyrightText: 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.eclipse.uprotocol.transport; + +import java.util.Objects; + +import org.eclipse.uprotocol.uri.validator.UriValidator; +import org.eclipse.uprotocol.v1.UUri; + +/** + * A URI provider that is statically configured with the uEntity's authority, entity ID and version. + */ +public final class StaticUriProvider implements LocalUriProvider { + + private final UUri localUri; + + private StaticUriProvider(UUri uuri) { + this.localUri = uuri; + } + + /** + * Creates a new provider for a uEntity with a known local URI. + * + * @param uuri The local URI of the uEntity. + * @return The provider. + * @throws NullPointerException if uuri is {@code null}. + */ + public static StaticUriProvider of(UUri uuri) { + return of(uuri.getAuthorityName(), uuri.getUeId(), uuri.getUeVersionMajor()); + } + + /** + * Creates a new provider for a uEntity with a known authority, entity ID and version. + * + * @param authority The authority name of the uEntity. + * @param entityId The entity ID of the uEntity. + * @param majorVersion The major version of the uEntity. + * @return The provider. + * @throws IllegalArgumentException if the provided parameters are invalid. + * @throws NullPointerException if authority is {@code null}. + */ + public static StaticUriProvider of(String authority, int entityId, int majorVersion) { + Objects.requireNonNull(authority); + UUri localUri = UUri.newBuilder() + .setAuthorityName(authority) + .setUeId(entityId) + .setUeVersionMajor(majorVersion) + .build(); + UriValidator.validate(localUri); + return new StaticUriProvider(localUri); + } + + @Override + public String getAuthority() { + return localUri.getAuthorityName(); + } + + @Override + public UUri getSource() { + return localUri; + } + + @Override + public UUri getResource(int id) { + return UUri.newBuilder(localUri) + .setResourceId(id) + .build(); + } +} diff --git a/src/main/java/org/eclipse/uprotocol/transport/UListener.java b/src/main/java/org/eclipse/uprotocol/transport/UListener.java index 2da79f87..5912cf2b 100644 --- a/src/main/java/org/eclipse/uprotocol/transport/UListener.java +++ b/src/main/java/org/eclipse/uprotocol/transport/UListener.java @@ -15,15 +15,24 @@ import org.eclipse.uprotocol.v1.UMessage; /** - * For any implementation that defines some kind of callback or function that - * will be called to handle incoming messages. + * A handler for processing uProtocol messages. + * + * Implementations contain the details for what should occur when a message is received. + * + * @see + * uProtocol Transport Layer specification */ +/// for details. */ public interface UListener { /** - * Method called to handle/process messages. - * - * @param message Message received. + * Performs some action on receipt of a message. + *

+ * This function is expected to return almost immediately. If it does not, it could potentially + * block processing of succeeding messages. Long-running operations for processing a message should + * therefore be run on a separate thread. + * + * @param message The message to process. */ void onReceive(UMessage message); } diff --git a/src/main/java/org/eclipse/uprotocol/transport/UTransport.java b/src/main/java/org/eclipse/uprotocol/transport/UTransport.java index 1ee08000..84f721ad 100644 --- a/src/main/java/org/eclipse/uprotocol/transport/UTransport.java +++ b/src/main/java/org/eclipse/uprotocol/transport/UTransport.java @@ -13,132 +13,100 @@ package org.eclipse.uprotocol.transport; import java.util.concurrent.CompletionStage; -import java.util.concurrent.CompletableFuture; +import org.eclipse.uprotocol.communication.UStatusException; import org.eclipse.uprotocol.uri.factory.UriFactory; -import org.eclipse.uprotocol.v1.UCode; +import org.eclipse.uprotocol.v1.UAttributes; import org.eclipse.uprotocol.v1.UMessage; -import org.eclipse.uprotocol.v1.UStatus; import org.eclipse.uprotocol.v1.UUri; /** * UTransport is the uP-L1 interface that provides a common API for uE developers to send and receive messages. + *

* UTransport implementations contain the details for connecting to the underlying transport technology and - * sending UMessage using the configured technology. For more information please refer to - * https://github.com/eclipse-uprotocol/up-spec/blob/main/up-l1/README.adoc. + * sending UMessage using the configured technology. + * + * @see + * uProtocol Transport Layer specification */ public interface UTransport { - - /** - * Error message for null transport. - */ - String TRANSPORT_NULL_ERROR = "Transport cannot be null"; - /** - * Send a message over the transport. - * - * @param message the {@link UMessage} to be sent. - * @return Returns {@link UStatus} with {@link UCode} set to the status code - * (successful or failure). + * Sends a message using this transport's message exchange mechanism. + * + * @param message The message to send. The type, source and sink properties of the + * {@link UAttributes} contained in the message determine the addressing semantics. + * @return The outcome of the operation. The stage will be completed with a {@link UStatusException} if + * the message could not be sent. */ - CompletionStage send(UMessage message); - + CompletionStage send(UMessage message); /** - * Register {@code UListener} for {@code UUri} source filters to be called when - * a message is received. - * - * @param sourceFilter The UAttributes::source address pattern that the message - * to receive needs to match. - * @param listener The {@code UListener} that will be execute when the - * message is - * received on the given {@code UUri}. - * @return Returns {@link UStatus} with {@link UCode#OK} if the listener is - * registered correctly, otherwise it returns with the appropriate failure. + * Registers a listener to be called for messages. + *

+ * The listener will be invoked for each message that matches the given source filter pattern + * according to the rules defined by the + * UUri + * specification. + *

+ * This default implementation invokes {@link #registerListener(UUri, UUri, UListener)} with the + * given source filter and a sink filter of {@link UriFactory#ANY}. + * + * @param sourceFilter The source address pattern that messages need to match. + * @param listener The listener to invoke. The listener can be unregistered again + * using {@link #unregisterListener(UUri, UUri, UListener)}. + * @return The outcome of the operation. The stage will be completed with a {@link UStatusException} if + * the listener could not be registered. */ - default CompletionStage registerListener(UUri sourceFilter, UListener listener) { + default CompletionStage registerListener(UUri sourceFilter, UListener listener) { return registerListener(sourceFilter, UriFactory.ANY, listener); } - /** - * Register {@code UListener} for {@code UUri} source and sink filters to be - * called when a message is received. - * - * @param sourceFilter The UAttributes::source address pattern that the message - * to receive needs to match. - * @param sinkFilter The UAttributes::sink address pattern that the message to - * receive needs to match. - * @param listener The {@code UListener} that will be execute when the - * message is - * received on the given {@code UUri}. - * @return Returns {@link UStatus} with {@link UCode#OK} if the listener is - * registered - * correctly, otherwise it returns with the appropriate failure. + * Registers a listener to be called for messages. + *

+ * The listener will be invoked for each message that matches the given source and sink filter patterns + * according to the rules defined by the + * UUri + * specification. + * + * @param sourceFilter The source address pattern that messages need to match. + * @param sinkFilter The sink address pattern that messages need to match. + * @param listener The listener to invoke. The listener can be unregistered again + * using {@link #unregisterListener(UUri, UUri, UListener)}. + * @return The outcome of the operation. The stage will be completed with a {@link UStatusException} if + * the listener could not be registered. */ - CompletionStage registerListener(UUri sourceFilter, UUri sinkFilter, UListener listener); - + CompletionStage registerListener(UUri sourceFilter, UUri sinkFilter, UListener listener); /** - * Unregister {@code UListener} for {@code UUri} source filters. Messages - * arriving on this topic will no longer be processed by this listener. - * - * @param sourceFilter The UAttributes::source address pattern that the message - * to receive needs to match. - * @param listener The {@code UListener} that will no longer want to be - * registered to receive - * messages. - * @return Returns {@link UStatus} with {@link UCode#OK} if the listener is - * unregistered - * correctly, otherwise it returns with the appropriate failure. + * Unregisters a message listener. + *

+ * The listener will no longer be called for any (matching) messages after this function has + * returned successfully. + *

+ * This default implementation invokes {@link #unregisterListener(UUri, UUri, UListener)} with the + * given source filter and a sink filter of {@link UriFactory#ANY}. + * + * @param sourceFilter The source address pattern that the listener had been registered for. + * @param listener The listener to unregister. + * @return The outcome of the operation. The stage will be completed with a {@link UStatusException} if + * the listener could not be unregistered. */ - default CompletionStage unregisterListener(UUri sourceFilter, UListener listener) { + default CompletionStage unregisterListener(UUri sourceFilter, UListener listener) { return unregisterListener(sourceFilter, UriFactory.ANY, listener); } - - /** - * Unregister {@code UListener} for {@code UUri} source and sink filters. - * Messages arriving on this topic will no longer be processed by this listener. - * - * @param sourceFilter The UAttributes::source address pattern that the message - * to receive needs to match. - * @param sinkFilter The UAttributes::sink address pattern that the message to - * receive needs to match. - * @param listener The {@code UListener} that will no longer want to be - * registered to receive - * messages. - * @return Returns {@link UStatus} with {@link UCode#OK} if the listener is - * unregistered - * correctly, otherwise it returns with the appropriate failure. - */ - CompletionStage unregisterListener(UUri sourceFilter, UUri sinkFilter, UListener listener); - - - /** - * Return the source address of the uE. - * The Source address is passed to the constructor of a given transport - * - * @return UUri containing the source address - */ - UUri getSource(); - - - /** - * Open the connection to the transport that will trigger any registered listeners - * to be registered. - * - * @return Returns {@link UStatus} with {@link UCode#OK} if the connection is - * opened correctly, otherwise it returns with the appropriate failure. - */ - default CompletionStage open() { - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - } - - /** - * Close the connection to the transport that will trigger any registered listeners - * to be unregistered. + * Unregisters a message listener. + *

+ * The listener will no longer be called for any (matching) messages after this function has + * returned successfully. + * + * @param sourceFilter The source address pattern that the listener had been registered for. + * @param sinkFilter The sink address pattern that the listener had been registered for. + * @param listener The listener to unregister. + * @return The outcome of the operation. The stage will be completed with a {@link UStatusException} if + * the listener could not be unregistered. */ - default void close() { } + CompletionStage unregisterListener(UUri sourceFilter, UUri sinkFilter, UListener listener); } diff --git a/src/main/java/org/eclipse/uprotocol/uri/factory/UriFactory.java b/src/main/java/org/eclipse/uprotocol/uri/factory/UriFactory.java index e72ff62a..7b80c587 100644 --- a/src/main/java/org/eclipse/uprotocol/uri/factory/UriFactory.java +++ b/src/main/java/org/eclipse/uprotocol/uri/factory/UriFactory.java @@ -19,13 +19,27 @@ import com.google.protobuf.Descriptors.ServiceDescriptor; /** - * URI Factory that builds URIs from protos. + * A factory for uProtocol URIs. */ -public interface UriFactory { - String WILDCARD_AUTHORITY = "*"; - int WILDCARD_ENTITY_ID = 0xFFFF; - int WILDCARD_ENTITY_VERSION = 0xFF; - int WILDCARD_RESOURCE_ID = 0xFFFF; +public final class UriFactory { + + public static final String WILDCARD_AUTHORITY = "*"; + public static final int WILDCARD_ENTITY_ID = 0xFFFF; + public static final int WILDCARD_ENTITY_VERSION = 0xFF; + public static final int WILDCARD_RESOURCE_ID = 0xFFFF; + + /** + * A uProtocol pattern URI that matches all UUris. + */ + public static final UUri ANY = UUri.newBuilder() + .setAuthorityName(WILDCARD_AUTHORITY) + .setUeId(WILDCARD_ENTITY_ID) + .setUeVersionMajor(WILDCARD_ENTITY_VERSION) + .setResourceId(WILDCARD_RESOURCE_ID).build(); + + private UriFactory() { + // Prevent instantiation + } /** * Builds a UEntity for an protobuf generated code Service Descriptor. @@ -34,7 +48,7 @@ public interface UriFactory { * @param resourceId The resource id. * @return Returns a UEntity for an protobuf generated code Service Descriptor. */ - static UUri fromProto(ServiceDescriptor descriptor, int resourceId) { + public static UUri fromProto(ServiceDescriptor descriptor, int resourceId) { return fromProto(descriptor, resourceId, null); } @@ -46,7 +60,7 @@ static UUri fromProto(ServiceDescriptor descriptor, int resourceId) { * @param authorityName The authority name. * @return Returns a UEntity for an protobuf generated code Service Descriptor. */ - static UUri fromProto(ServiceDescriptor descriptor, int resourceId, String authorityName) { + public static UUri fromProto(ServiceDescriptor descriptor, int resourceId, String authorityName) { if (descriptor == null) { return UUri.getDefaultInstance(); } @@ -63,14 +77,4 @@ static UUri fromProto(ServiceDescriptor descriptor, int resourceId, String autho } return builder.build(); } - - - /** - * A uProtocol pattern URI that matches all UUris. - */ - UUri ANY = UUri.newBuilder() - .setAuthorityName(WILDCARD_AUTHORITY) - .setUeId(WILDCARD_ENTITY_ID) - .setUeVersionMajor(WILDCARD_ENTITY_VERSION) - .setResourceId(WILDCARD_RESOURCE_ID).build(); } diff --git a/src/main/java/org/eclipse/uprotocol/uri/validator/UriValidator.java b/src/main/java/org/eclipse/uprotocol/uri/validator/UriValidator.java index 69450711..e62da205 100644 --- a/src/main/java/org/eclipse/uprotocol/uri/validator/UriValidator.java +++ b/src/main/java/org/eclipse/uprotocol/uri/validator/UriValidator.java @@ -30,6 +30,35 @@ public interface UriValidator { */ int DEFAULT_RESOURCE_ID = 0; + /** + * Validates a UUri against the uProtocol specification. + * + * @param uuri The UUri to validate. + * @throws NullPointerException if the UUri is null. + * @throws IllegalArgumentException if the UUri does not comply with the UUri specification. + */ + static void validate(UUri uuri) { + if (uuri == null) { + throw new NullPointerException("URI cannot be null"); + } + + if (uuri.getAuthorityName().length() > 128) { + throw new IllegalArgumentException("Authority name exceeds maximum length of 128 characters"); + } + + // no need to check uEntity ID which is of Java primitive type (signed) int but actually represents + // an unsigned 32 bit integer, thus any value is valid + + if ((uuri.getUeVersionMajor() & 0xFFFF_FF00) != 0) { + throw new IllegalArgumentException("uEntity version major must be in range [0, 0x%X]" + .formatted(UriFactory.WILDCARD_ENTITY_VERSION)); + } + + if ((uuri.getResourceId() & 0xFFFF_0000) != 0) { + throw new IllegalArgumentException("uEntity resource ID must be in range [0, 0x%X]" + .formatted(UriFactory.WILDCARD_RESOURCE_ID)); + } + } /** * Indicates that this URI is an empty as it does not contain authority, entity, diff --git a/src/test/java/org/eclipse/uprotocol/client/usubscription/v3/InMemoryUSubscriptionClientTest.java b/src/test/java/org/eclipse/uprotocol/client/usubscription/v3/InMemoryUSubscriptionClientTest.java index e89c4878..e69de29b 100644 --- a/src/test/java/org/eclipse/uprotocol/client/usubscription/v3/InMemoryUSubscriptionClientTest.java +++ b/src/test/java/org/eclipse/uprotocol/client/usubscription/v3/InMemoryUSubscriptionClientTest.java @@ -1,1171 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Apache License Version 2.0 which is available at - * https://www.apache.org/licenses/LICENSE-2.0 - * - * SPDX-License-Identifier: Apache-2.0 - */ -package org.eclipse.uprotocol.client.usubscription.v3; - -import java.util.concurrent.BrokenBarrierException; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.Executors; -import java.util.concurrent.CyclicBarrier; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -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.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import org.mockito.Mock; -import org.junit.jupiter.api.extension.ExtendWith; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.mockito.Mockito.mock; - -import org.mockito.junit.jupiter.MockitoExtension; -import org.eclipse.uprotocol.communication.CallOptions; -import org.eclipse.uprotocol.communication.InMemoryRpcClient; -import org.eclipse.uprotocol.communication.SimpleNotifier; -import org.eclipse.uprotocol.communication.UPayload; -import org.eclipse.uprotocol.communication.UStatusException; -import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscribersResponse; -import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscriptionsRequest; -import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscriptionsResponse; -import org.eclipse.uprotocol.core.usubscription.v3.NotificationsResponse; -import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionResponse; -import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionStatus; -import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeResponse; -import org.eclipse.uprotocol.core.usubscription.v3.Update; -import org.eclipse.uprotocol.transport.UListener; -import org.eclipse.uprotocol.transport.UTransport; -import org.eclipse.uprotocol.transport.builder.UMessageBuilder; -import org.eclipse.uprotocol.v1.UCode; -import org.eclipse.uprotocol.v1.UMessage; -import org.eclipse.uprotocol.v1.UStatus; -import org.eclipse.uprotocol.v1.UUri; - -@ExtendWith(MockitoExtension.class) -public class InMemoryUSubscriptionClientTest { - - @Mock - private UTransport transport; - - @Mock - private InMemoryRpcClient rpcClient; - - @Mock - private SimpleNotifier notifier; - - @Mock - private SubscriptionChangeHandler subscriptionChangeHandler; - - private final UUri topic = UUri.newBuilder() - .setAuthorityName("hartley") - .setUeId(3) - .setUeVersionMajor(1) - .setResourceId(0x8000) - .build(); - - private final UUri source = UUri.newBuilder() - .setAuthorityName("Hartley") - .setUeId(4) - .setUeVersionMajor(1) - .build(); - private final UListener listener = new UListener() { - @Override - public void onReceive(UMessage message) { - // Do nothing - } - }; - - - @BeforeEach - public void setup() { - rpcClient = mock(InMemoryRpcClient.class); - notifier = mock(SimpleNotifier.class); - transport = mock(UTransport.class); - } - - - @Test - @DisplayName("Testing creation of InMemoryUSubscriptionClient passing only the transport") - public void testCreationOfInMemoryUSubscriptionClientPassingOnlyTheTransport() { - when(transport.getSource()).thenReturn(source); - - when(transport.registerListener(any(UUri.class), any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - assertDoesNotThrow(() -> { - new InMemoryUSubscriptionClient(transport); - }); - - verify(transport, times(2)).getSource(); - verify(transport, times(2)).registerListener(any(), any(), any()); - } - - - @Test - @DisplayName("Testing creation of InMemoryUSubscriptionClient passing null for the transport") - public void testCreationOfInMemoryUSubscriptionClientPassingNullForTheTransport() { - assertThrows(NullPointerException.class, () -> { - new InMemoryUSubscriptionClient(null); - }); - } - - - @Test - @DisplayName("Testing simple mock of RpcClient and notifier happy path") - public void testSimpleMockOfRpcClientAndNotifier() { - - final SubscriptionResponse response = SubscriptionResponse.newBuilder() - .setTopic(topic) - .setStatus(SubscriptionStatus.newBuilder().setState(SubscriptionStatus.State.SUBSCRIBED).build()) - .build(); - - when(transport.registerListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(response))); - - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertDoesNotThrow(() -> { - assertEquals(subscriber.subscribe(topic, listener).toCompletableFuture().get().getStatus().getState(), - SubscriptionStatus.State.SUBSCRIBED); - }); - - verify(rpcClient, times(1)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, times(1)).registerListener(any(), any()); - } - - - @Test - @DisplayName("Testing simple mock of RpcClient when usubscription returned SUBSCRIBE_PENDING") - public void testSimpleMockOfRpcClientAndNotifierReturnedSubscribePending() { - - final SubscriptionResponse response = SubscriptionResponse.newBuilder() - .setTopic(topic) - .setStatus(SubscriptionStatus.newBuilder().setState(SubscriptionStatus.State.SUBSCRIBE_PENDING).build()) - .build(); - - when(transport.registerListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(response))); - - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertDoesNotThrow(() -> { - assertEquals(subscriber.subscribe(topic, listener).toCompletableFuture().get().getStatus().getState(), - SubscriptionStatus.State.SUBSCRIBE_PENDING); - }); - - verify(rpcClient, times(1)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, times(1)).registerListener(any(), any()); - } - - - @Test - @DisplayName("Testing simple mock of RpcClient and notifier when usubscription returned UNSUBSCRIBED") - public void testSimpleMockWhenSubscriptionServiceReturnsUnsubscribed() { - - final SubscriptionResponse response = SubscriptionResponse.newBuilder() - .setTopic(topic) - .setStatus(SubscriptionStatus.newBuilder().setState(SubscriptionStatus.State.UNSUBSCRIBED).build()) - .build(); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(response))); - - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertDoesNotThrow(() -> { - assertEquals(subscriber.subscribe(topic, listener).toCompletableFuture().get().getStatus().getState(), - SubscriptionStatus.State.UNSUBSCRIBED); - }); - - verify(rpcClient, times(1)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, never()).registerListener(any(), any()); - } - - - - @Test - @DisplayName("Test subscribe using mock RpcClient and SimplerNotifier when invokemethod return an exception") - void testSubscribeUsingMockRpcClientAndSimplerNotifierWhenInvokemethodReturnAnException() { - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.failedFuture( - new UStatusException(UCode.PERMISSION_DENIED, "Not permitted"))); - - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertThrows(CompletionException.class, () -> { - CompletionStage response = subscriber.subscribe(topic, listener); - assertTrue(response.toCompletableFuture().isCompletedExceptionally()); - response.handle((r, e) -> { - e = e.getCause(); - assertTrue(e instanceof UStatusException); - assertEquals(((UStatusException) e).getCode(), UCode.PERMISSION_DENIED); - return null; - }); - response.toCompletableFuture().join(); - }); - - verify(rpcClient, times(1)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, times(0)).registerListener(any(), any()); - } - - - @Test - @DisplayName("Test subscribe using mock RpcClient and SimplerNotifier when" + - "we pass a subscription change notification handler") - void testSubscribeWhenWePassASubscriptionChangeNotificationHandler() { - when(transport.registerListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(SubscriptionResponse.newBuilder() - .setTopic(topic) - .setStatus(SubscriptionStatus.newBuilder().setState(SubscriptionStatus.State.SUBSCRIBED).build()) - .build()))); - - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertDoesNotThrow(() -> { - assertEquals(subscriber.subscribe(topic, listener, CallOptions.DEFAULT, subscriptionChangeHandler) - .toCompletableFuture().get().getStatus().getState(), SubscriptionStatus.State.SUBSCRIBED); - }); - - verify(rpcClient, times(1)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, times(1)).registerListener(any(), any()); - } - - - @Test - @DisplayName("Test subscribe to the same topic twice passing the same parameters") - void testSubscribeWhenWeTryToSubscribeToTheSameTopicTwice() { - when(transport.registerListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(SubscriptionResponse.newBuilder() - .setTopic(topic) - .setStatus(SubscriptionStatus.newBuilder().setState(SubscriptionStatus.State.SUBSCRIBED).build()) - .build()))); - - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertDoesNotThrow(() -> { - assertEquals(subscriber.subscribe(topic, listener, CallOptions.DEFAULT, subscriptionChangeHandler) - .toCompletableFuture().get().getStatus().getState(), SubscriptionStatus.State.SUBSCRIBED); - }); - - assertDoesNotThrow(() -> { - assertEquals(subscriber.subscribe(topic, listener, CallOptions.DEFAULT, subscriptionChangeHandler) - .toCompletableFuture().get().getStatus().getState(), SubscriptionStatus.State.SUBSCRIBED); - }); - - verify(rpcClient, times(2)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, times(2)).registerListener(any(), any()); - } - - - @Test - @DisplayName("Test subscribe to the same topic twice passing different SubscriptionChangeHandlers") - void testSubscribeToTheSameTopicTwicePassingDifferentSubscriptionChangeHandlers() { - when(transport.registerListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(SubscriptionResponse.newBuilder() - .setTopic(topic) - .setStatus(SubscriptionStatus.newBuilder().setState(SubscriptionStatus.State.SUBSCRIBE_PENDING).build()) - .build()))); - - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertDoesNotThrow(() -> { - assertEquals(subscriber.subscribe(topic, listener, CallOptions.DEFAULT, subscriptionChangeHandler) - .toCompletableFuture().get().getStatus().getState(), SubscriptionStatus.State.SUBSCRIBE_PENDING); - }); - - assertThrows( CompletionException.class, () -> { - CompletionStage response = subscriber.subscribe( - topic, - listener, - CallOptions.DEFAULT, - mock(SubscriptionChangeHandler.class)); - - assertTrue(response.toCompletableFuture().isCompletedExceptionally()); - response.handle((r, e) -> { - e = e.getCause(); - assertTrue(e instanceof UStatusException); - assertEquals(((UStatusException) e).getCode(), UCode.ALREADY_EXISTS); - return null; - }); - response.toCompletableFuture().join(); - }); - - verify(rpcClient, times(2)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, times(2)).registerListener(any(), any()); - } - - - @Test - @DisplayName("Test unsubscribe using mock RpcClient and SimplerNotifier") - void testUnsubscribeUsingMockRpcClientAndSimplerNotifier() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(transport.unregisterListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(UnsubscribeResponse.getDefaultInstance()))); - - when(notifier.unregisterNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertDoesNotThrow(() -> { - assertEquals(subscriber.unsubscribe(topic, listener).toCompletableFuture().get().getCode(), UCode.OK); - }); - - subscriber.close(); - - verify(rpcClient, times(1)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).unregisterNotificationListener(any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, times(1)).unregisterListener(any(), any()); - } - - - @Test - @DisplayName("Test unsubscribe using when invokemethod return an exception") - void testUnsubscribeWhenInvokemethodReturnAnException() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.failedStage(new UStatusException(UCode.CANCELLED, "Operation cancelled"))); - - when(notifier.unregisterNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertDoesNotThrow(() -> { - CompletionStage response = subscriber.unsubscribe(topic, listener); - assertNotNull(response); - assertFalse(response.toCompletableFuture().isCompletedExceptionally()); - assertEquals(response.toCompletableFuture().get().getCode(), UCode.CANCELLED); - }); - - subscriber.close(); - - verify(rpcClient, times(1)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).unregisterNotificationListener(any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, times(0)).unregisterListener(any(), any()); - } - - - @Test - @DisplayName("Test unsubscribe when invokemethod returned OK but we failed to unregister the listener") - void testUnsubscribeWhenInvokemethodReturnedOkButWeFailedToUnregisterTheListener() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(SubscriptionResponse.newBuilder() - .setTopic(topic) - .setStatus(SubscriptionStatus.newBuilder().setState(SubscriptionStatus.State.SUBSCRIBE_PENDING).build()) - .build()))); - - when(transport.unregisterListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.ABORTED).build())); - - when(notifier.unregisterNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertDoesNotThrow(() -> { - CompletionStage response = subscriber.unsubscribe(topic, listener); - assertNotNull(response); - assertFalse(response.toCompletableFuture().isCompletedExceptionally()); - assertEquals(response.toCompletableFuture().get().getCode(), UCode.ABORTED); - }); - - subscriber.close(); - - verify(rpcClient, times(1)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).unregisterNotificationListener(any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, times(1)).unregisterListener(any(), any()); - } - - - @Test - @DisplayName("Test handling going from SUBSCRIBE_PENDING to SUBSCRIBED state") - void testHandlingGoingFromSubscribePendingToSubscribedState() - throws InterruptedException, BrokenBarrierException { - - // Create a CyclicBarrier with a count of 2 so we synchronize the subscription with the - // changes in states for the usubscription service - CyclicBarrier barrier = new CyclicBarrier(2); - - when(transport.registerListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(SubscriptionResponse.newBuilder() - .setTopic(topic) - .setStatus(SubscriptionStatus.newBuilder().setState(SubscriptionStatus.State.SUBSCRIBE_PENDING).build()) - .build()))); - - // Fake sending the subscription change notification - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenAnswer(invocation -> { - Executors.newSingleThreadExecutor().execute(new Runnable() { - @Override - public void run() { - try { - barrier.await(); - } catch (Exception e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - Update update = Update.newBuilder().setTopic(topic).setStatus(SubscriptionStatus.newBuilder() - .setState(SubscriptionStatus.State.SUBSCRIBED).build()).build(); - UMessage message = UMessageBuilder.notification(topic, source).build(UPayload.pack(update)); - invocation.getArgument(1, UListener.class).onReceive(message); - } - }); - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - }); - - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - SubscriptionChangeHandler handler = new SubscriptionChangeHandler() { - @Override - public void handleSubscriptionChange(UUri topic, SubscriptionStatus status) { - assertEquals(status.getState(), SubscriptionStatus.State.SUBSCRIBED); - } - }; - assertDoesNotThrow(() -> { - assertEquals(subscriber.subscribe(topic, listener, CallOptions.DEFAULT, handler) - .toCompletableFuture().get().getStatus().getState(), SubscriptionStatus.State.SUBSCRIBE_PENDING); - }); - - // Wait for a specific time (500ms) - Thread.sleep(100); - - // Release the barrier by calling await() again - barrier.await(); - - verify(rpcClient, times(1)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, times(1)).registerListener(any(), any()); - } - - @Test - @DisplayName("Test handling going from SUBSCRIBE_PENDING to SUBSCRIBED state but handler throws exception") - void testHandlingGoingFromSubscribePendingToSubscribedStateButHandlerThrowsException() - throws InterruptedException, BrokenBarrierException { - - // Create a CyclicBarrier with a count of 2 so we synchronize the subscription with the - // changes in states for the usubscription service - CyclicBarrier barrier = new CyclicBarrier(2); - - when(transport.registerListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(SubscriptionResponse.newBuilder() - .setTopic(topic) - .setStatus(SubscriptionStatus.newBuilder().setState(SubscriptionStatus.State.SUBSCRIBE_PENDING).build()) - .build()))); - - // Fake sending the subscription change notification - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenAnswer(invocation -> { - Executors.newSingleThreadExecutor().execute(new Runnable() { - @Override - public void run() { - try { - barrier.await(); - } catch (Exception e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - Update update = Update.newBuilder().setTopic(topic).setStatus(SubscriptionStatus.newBuilder() - .setState(SubscriptionStatus.State.SUBSCRIBED).build()).build(); - UMessage message = UMessageBuilder.notification(topic, source).build(UPayload.pack(update)); - invocation.getArgument(1, UListener.class).onReceive(message); - } - }); - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - }); - - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - SubscriptionChangeHandler handler = new SubscriptionChangeHandler() { - @Override - public void handleSubscriptionChange(UUri topic, SubscriptionStatus status) { - throw new UnsupportedOperationException("Throwing exception in the handler"); - } - }; - assertDoesNotThrow(() -> { - assertEquals(subscriber.subscribe(topic, listener, CallOptions.DEFAULT, handler) - .toCompletableFuture().get().getStatus().getState(), SubscriptionStatus.State.SUBSCRIBE_PENDING); - }); - - // Wait for a specific time (500ms) - Thread.sleep(100); - - // Release the barrier by calling await() again - barrier.await(); - - verify(rpcClient, times(1)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, times(1)).registerListener(any(), any()); - } - - - @Test - @DisplayName("Test notification handling when we pass the wrong message type to the handler") - void testNotificationHandlingWhenWePassTheWrongMessageTypeToTheHandler() - throws InterruptedException, BrokenBarrierException { - - // Create a CyclicBarrier with a count of 2 so we synchronize the subscription with the - // changes in states for the usubscription service - CyclicBarrier barrier = new CyclicBarrier(2); - - when(transport.registerListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.packToAny(SubscriptionResponse.newBuilder() - .setTopic(topic) - .setStatus(SubscriptionStatus.newBuilder().setState(SubscriptionStatus.State.SUBSCRIBE_PENDING).build()) - .build()))); - - // Fake sending the subscription change notification - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenAnswer(invocation -> { - Executors.newSingleThreadExecutor().execute(new Runnable() { - @Override - public void run() { - try { - barrier.await(); - } catch (Exception e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - Update update = Update.newBuilder().setTopic(topic).setStatus(SubscriptionStatus.newBuilder() - .setState(SubscriptionStatus.State.SUBSCRIBED).build()).build(); - UMessage message = UMessageBuilder.publish(topic).build(UPayload.pack(update)); - invocation.getArgument(1, UListener.class).onReceive(message); - } - }); - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - }); - - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - SubscriptionChangeHandler handler = new SubscriptionChangeHandler() { - @Override - public void handleSubscriptionChange(UUri topic, SubscriptionStatus status) { - throw new UnsupportedOperationException("This handler should not be called"); - } - }; - assertDoesNotThrow(() -> { - assertEquals(subscriber.subscribe(topic, listener, CallOptions.DEFAULT, handler) - .toCompletableFuture().get().getStatus().getState(), SubscriptionStatus.State.SUBSCRIBE_PENDING); - }); - - // Wait for a specific time (500ms) - Thread.sleep(100); - - // Release the barrier by calling await() again - barrier.await(); - - verify(rpcClient, times(1)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, times(1)).registerListener(any(), any()); - } - - - @Test - @DisplayName("Test notification handling when we get something other than the Update message") - void testNotificationHandlingWhenWeGetSomethingOtherThanTheUpdateMessage() - throws InterruptedException, BrokenBarrierException { - - // Create a CyclicBarrier with a count of 2 so we synchronize the subscription with the - // changes in states for the usubscription service - CyclicBarrier barrier = new CyclicBarrier(2); - - when(transport.registerListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(SubscriptionResponse.newBuilder() - .setTopic(topic) - .setStatus(SubscriptionStatus.newBuilder().setState(SubscriptionStatus.State.SUBSCRIBE_PENDING).build()) - .build()))); - - // Fake sending the subscription change notification - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenAnswer(invocation -> { - Executors.newSingleThreadExecutor().execute(new Runnable() { - @Override - public void run() { - try { - barrier.await(); - } catch (Exception e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - UMessage message = UMessageBuilder.notification(topic, source) - .build(UPayload.packToAny(source)); - invocation.getArgument(1, UListener.class).onReceive(message); - } - }); - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - }); - - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - SubscriptionChangeHandler handler = new SubscriptionChangeHandler() { - @Override - public void handleSubscriptionChange(UUri topic, SubscriptionStatus status) { - throw new UnsupportedOperationException("This handler should not be called"); - } - }; - assertDoesNotThrow(() -> { - assertEquals(subscriber.subscribe(topic, listener, CallOptions.DEFAULT, handler) - .toCompletableFuture().get().getStatus().getState(), SubscriptionStatus.State.SUBSCRIBE_PENDING); - }); - - // Wait for a specific time (500ms) - Thread.sleep(100); - - // Release the barrier by calling await() again - barrier.await(); - - verify(rpcClient, times(1)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, times(1)).registerListener(any(), any()); - } - - - @Test - @DisplayName("Test notification handling when we didn't register a notification handler") - void testNotificationHandlingWhenWeDidntRegisterANotificationHandler() - throws InterruptedException, BrokenBarrierException { - - // Create a CyclicBarrier with a count of 2 so we synchronize the subscription with the - // changes in states for the usubscription service - CyclicBarrier barrier = new CyclicBarrier(2); - - when(transport.registerListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(SubscriptionResponse.newBuilder() - .setTopic(topic) - .setStatus(SubscriptionStatus.newBuilder().setState(SubscriptionStatus.State.SUBSCRIBE_PENDING).build()) - .build()))); - - // Fake sending the subscription change notification - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenAnswer(invocation -> { - Executors.newSingleThreadExecutor().execute(new Runnable() { - @Override - public void run() { - try { - barrier.await(); - } catch (Exception e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - Update update = Update.newBuilder().setTopic(topic).setStatus(SubscriptionStatus.newBuilder() - .setState(SubscriptionStatus.State.SUBSCRIBED).build()).build(); - UMessage message = UMessageBuilder.notification(topic, source).build(UPayload.pack(update)); - invocation.getArgument(1, UListener.class).onReceive(message); - } - }); - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - }); - - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertDoesNotThrow(() -> { - assertEquals(subscriber.subscribe(topic, listener) - .toCompletableFuture().get().getStatus().getState(), SubscriptionStatus.State.SUBSCRIBE_PENDING); - }); - - // Wait for a specific time (100ms) - Thread.sleep(100); - - // Release the barrier by calling await() again - barrier.await(); - - verify(rpcClient, times(1)).invokeMethod(any(), any(), any()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - verify(transport, times(1)).registerListener(any(), any()); - } - - - @Test - @DisplayName("Test registerNotification() api when passed a null topic") - void testRegisterNotificationApiWhenPassedANullTopic() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertThrows(NullPointerException.class, () -> { - subscriber.registerForNotifications(null, subscriptionChangeHandler); - }); - - verify(notifier, times(1)).registerNotificationListener(any(), any()); - } - - - @Test - @DisplayName("Test registerNotification() api when passed a null handler") - void testRegisterNotificationApiWhenPassedANullHandler() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertThrows(NullPointerException.class, () -> { - subscriber.registerForNotifications(topic, null); - }); - - verify(notifier, times(1)).registerNotificationListener(any(), any()); - } - - @Test - @DisplayName("Test unregisterListener() api for the happy path") - void testUnregisterListenerApiForTheHappyPath() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(transport.registerListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(transport.unregisterListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(SubscriptionResponse.newBuilder() - .setTopic(topic) - .setStatus(SubscriptionStatus.newBuilder().setState(SubscriptionStatus.State.SUBSCRIBED).build()) - .build()))); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertDoesNotThrow(() -> { - assertEquals(subscriber.subscribe(topic, listener).toCompletableFuture().get().getStatus().getState(), - SubscriptionStatus.State.SUBSCRIBED); - assertEquals(subscriber.unregisterListener(topic, listener).toCompletableFuture().get().getCode(), - UCode.OK); - }); - - verify(transport, times(1)).unregisterListener(any(), any()); - } - - - @Test - @DisplayName("Test registerNotification() api when passed a valid topic and handler") - void testRegisterNotificationApiWhenPassedAValidTopicAndHandler() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(transport.getSource()).thenReturn(source); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(NotificationsResponse.getDefaultInstance()))); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - UUri topic = UUri.newBuilder(transport.getSource()).setResourceId(0x8000).build(); - - assertDoesNotThrow(() -> subscriber.registerForNotifications(topic, subscriptionChangeHandler) - .toCompletableFuture().get()); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - } - - - @Test - @DisplayName("Test registerNotification() api when invokeMethod() throws an exception") - void testRegisterNotificationApiWhenInvokeMethodThrowsAnException() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(transport.getSource()).thenReturn(source); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.failedFuture( - new UStatusException(UCode.PERMISSION_DENIED, "Not permitted"))); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - UUri topic = UUri.newBuilder(transport.getSource()).setResourceId(0x8000).build(); - - assertDoesNotThrow(() -> { - var response = subscriber.registerForNotifications(topic, subscriptionChangeHandler); - assertTrue(response.toCompletableFuture().isCompletedExceptionally()); - response.handle((r, e) -> { - e = e.getCause(); - assertTrue(e instanceof UStatusException); - assertEquals(((UStatusException) e).getCode(), UCode.PERMISSION_DENIED); - return null; - }).toCompletableFuture().get(); - }); - } - - - @Test - @DisplayName("Test registerNotification() calling the API twice passing the same topic and handler") - void testRegisterNotificationApiCallingTheApiTwicePassingTheSameTopicAndHandler() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(transport.getSource()).thenReturn(source); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(NotificationsResponse.getDefaultInstance()))); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - UUri topic = UUri.newBuilder(transport.getSource()).setResourceId(0x8000).build(); - - assertDoesNotThrow(() -> { - assertTrue(NotificationsResponse.getDefaultInstance().equals( - subscriber.registerForNotifications(topic, subscriptionChangeHandler).toCompletableFuture().get())); - }); - - assertDoesNotThrow(() -> { - assertTrue(NotificationsResponse.getDefaultInstance().equals( - subscriber.registerForNotifications(topic, subscriptionChangeHandler).toCompletableFuture().get())); - }); - - verify(notifier, times(1)).registerNotificationListener(any(), any()); - } - - - @Test - @DisplayName("Test registerNotification() calling the API twice passing the same topic but different handlers") - void testRegisterNotificationApiCallingTheApiTwicePassingTheSameTopicButDifferentHandlers() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(transport.getSource()).thenReturn(source); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(NotificationsResponse.getDefaultInstance()))); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - UUri topic = UUri.newBuilder(transport.getSource()).setResourceId(0x8000).build(); - - assertDoesNotThrow(() -> { - assertTrue(NotificationsResponse.getDefaultInstance().equals( - subscriber.registerForNotifications(topic, subscriptionChangeHandler).toCompletableFuture().get())); - }); - - assertDoesNotThrow(() -> { - CompletionStage response = subscriber.registerForNotifications( - topic, - mock(SubscriptionChangeHandler.class)); - assertTrue(response.toCompletableFuture().isCompletedExceptionally()); - - response.handle((r, e) -> { - assertNotNull(e); - e = e.getCause(); - assertTrue(e instanceof UStatusException); - assertEquals(((UStatusException) e).getCode(), UCode.ALREADY_EXISTS); - return null; - }).toCompletableFuture().join(); - }); - - verify(notifier, times(1)).registerNotificationListener(any(), any()); - } - - - @Test - @DisplayName("Test unregisterNotification() api for the happy path") - void testUnregisterNotificationApiForTheHappyPath() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(transport.getSource()).thenReturn(source); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture(UPayload.pack(NotificationsResponse.getDefaultInstance()))); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - UUri topic = UUri.newBuilder(transport.getSource()).setResourceId(0x8000).build(); - - assertDoesNotThrow(() -> { - subscriber.registerForNotifications(topic, subscriptionChangeHandler).toCompletableFuture().get(); - subscriber.unregisterForNotifications(topic).toCompletableFuture().get(); - }); - - verify(notifier, times(1)).registerNotificationListener(any(), any()); - } - - - @Test - @DisplayName("Test unregisterNotification() api when passed a null topic") - void testUnregisterNotificationApiWhenPassedANullTopic() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertThrows(NullPointerException.class, () -> { - subscriber.unregisterForNotifications(null); - }); - - verify(notifier, times(1)).registerNotificationListener(any(), any()); - } - - - @Test - @DisplayName("Test unregisterNotification() api when passed a null handler") - void testUnregisterNotificationApiWhenPassedANullHandler() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertThrows(NullPointerException.class, () -> { - subscriber.unregisterForNotifications(topic, null); - }); - - verify(notifier, times(1)).registerNotificationListener(any(), any()); - } - - - @Test - @DisplayName("Test calling unregisterNotification() api when we never registered the notification below") - void testCallingUnregisterNotificationApiWhenWeNeverRegisteredTheNotificationBelow() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.failedFuture(new UStatusException(UCode.NOT_FOUND, "Not found"))); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - UUri topic = UUri.newBuilder(source).setResourceId(0x8000).build(); - - assertDoesNotThrow(() -> { - CompletionStage response = subscriber.unregisterForNotifications(topic); - assertTrue(response.toCompletableFuture().isCompletedExceptionally()); - - response.handle((r, e) -> { - assertNotNull(e); - e = e.getCause(); - assertTrue(e instanceof UStatusException); - assertEquals(((UStatusException) e).getCode(), UCode.NOT_FOUND); - return null; - }).toCompletableFuture().join(); - }); - - verify(transport, never()).getSource(); - verify(notifier, times(1)).registerNotificationListener(any(), any()); - } - - - @Test - @DisplayName("Test fetchSubscribers() when pssing null topic") - void testFetchSubscribersWhenPassingNullTopic() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertThrows(NullPointerException.class, () -> { - subscriber.fetchSubscribers(null).toCompletableFuture().get(); - }); - } - - - @Test - @DisplayName("Test fetchSubscribers() when passing a valid topic") - void testFetchSubscribersWhenPassingAValidTopic() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture( - UPayload.pack(FetchSubscribersResponse.getDefaultInstance()))); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertDoesNotThrow(() -> { - assertTrue(FetchSubscribersResponse.getDefaultInstance().equals( - subscriber.fetchSubscribers(topic).toCompletableFuture().get())); - }); - - verify(notifier, times(1)).registerNotificationListener(any(), any()); - } - - - @Test - @DisplayName("Test fetchSubscribers() when passing when invokeMethod returns NOT_PERMITTED") - void testFetchSubscribersWhenPassingWhenInvokeMethodReturnsNotPermitted() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.failedFuture( - new UStatusException(UCode.PERMISSION_DENIED, "Not permitted"))); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertDoesNotThrow(() -> { - CompletionStage response = subscriber.fetchSubscribers(topic); - assertTrue(response.toCompletableFuture().isCompletedExceptionally()); - response.handle((r, e) -> { - e = e.getCause(); - assertTrue(e instanceof UStatusException); - assertEquals(((UStatusException) e).getCode(), UCode.PERMISSION_DENIED); - return null; - }).toCompletableFuture().join(); - }); - - verify(notifier, times(1)).registerNotificationListener(any(), any()); - } - - - @Test - @DisplayName("Test fetchSubscriptions() passing null FetchSubscriptionRequest") - void testFetchSubscriptionsPassingNullFetchSubscriptionRequest() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - assertThrows(NullPointerException.class, () -> { - subscriber.fetchSubscriptions(null).toCompletableFuture().get(); - }); - - verify(notifier, times(1)).registerNotificationListener(any(), any()); - } - - - @Test - @DisplayName("Test fetchSubscriptions() passing a valid FetchSubscriptionRequest") - void testFetchSubscriptionsPassingAValidFetchSubscriptionRequest() { - when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) - .thenReturn(CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build())); - - when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) - .thenReturn(CompletableFuture.completedFuture( - UPayload.pack(FetchSubscriptionsResponse.getDefaultInstance()))); - - InMemoryUSubscriptionClient subscriber = new InMemoryUSubscriptionClient(transport, rpcClient, notifier); - assertNotNull(subscriber); - - FetchSubscriptionsRequest request = FetchSubscriptionsRequest.newBuilder().setTopic(topic).build(); - - assertDoesNotThrow(() -> { - assertTrue(FetchSubscriptionsResponse.getDefaultInstance().equals( - subscriber.fetchSubscriptions(request).toCompletableFuture().get())); - }); - - verify(notifier, times(1)).registerNotificationListener(any(), any()); - } -} diff --git a/src/test/java/org/eclipse/uprotocol/client/usubscription/v3/RpcClientBasedUSubscriptionClientTest.java b/src/test/java/org/eclipse/uprotocol/client/usubscription/v3/RpcClientBasedUSubscriptionClientTest.java new file mode 100644 index 00000000..f57651ee --- /dev/null +++ b/src/test/java/org/eclipse/uprotocol/client/usubscription/v3/RpcClientBasedUSubscriptionClientTest.java @@ -0,0 +1,151 @@ +/** + * SPDX-FileCopyrightText: 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.eclipse.uprotocol.client.usubscription.v3; + +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; + +import org.eclipse.uprotocol.communication.CallOptions; +import org.eclipse.uprotocol.communication.InMemoryRpcClient; +import org.eclipse.uprotocol.communication.RpcClient; +import org.eclipse.uprotocol.communication.UPayload; +import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscribersRequest; +import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscribersResponse; +import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscriptionsRequest; +import org.eclipse.uprotocol.core.usubscription.v3.FetchSubscriptionsResponse; +import org.eclipse.uprotocol.core.usubscription.v3.NotificationsRequest; +import org.eclipse.uprotocol.core.usubscription.v3.NotificationsResponse; +import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionRequest; +import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionResponse; +import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeRequest; +import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeResponse; +import org.eclipse.uprotocol.v1.UUri; + +class RpcClientBasedUSubscriptionClientTest { + + private static final UUri TOPIC = UUri.newBuilder() + .setAuthorityName("hartley") + .setUeId(3) + .setUeVersionMajor(1) + .setResourceId(0x8000) + .build(); + + private RpcClient rpcClient; + private CallOptions callOptions; + private USubscriptionClient subscriptionClient; + + @BeforeEach + void setup() { + rpcClient = mock(InMemoryRpcClient.class); + callOptions = CallOptions.DEFAULT; + subscriptionClient = new RpcClientBasedUSubscriptionClient(rpcClient, callOptions); + } + + @Test + @DisplayName("Test constructors require RpcClient") + void testConstructorsRequireRpcClient() { + assertThrows( + NullPointerException.class, + () -> new RpcClientBasedUSubscriptionClient(null, CallOptions.DEFAULT)); + assertThrows( + NullPointerException.class, + () -> new RpcClientBasedUSubscriptionClient(null, CallOptions.DEFAULT, 0x000, "my-vehicle")); + } + + @Test + @DisplayName("Test constructor rejects invalid instance ID") + void testConstructorRejectsInvalidInstanceId() { + assertThrows( + IllegalArgumentException.class, + () -> new RpcClientBasedUSubscriptionClient(rpcClient, CallOptions.DEFAULT, -1, "local")); + assertThrows( + IllegalArgumentException.class, + () -> new RpcClientBasedUSubscriptionClient(rpcClient, CallOptions.DEFAULT, 0xFFFF, "local")); + } + + @Test + void testSubscribeInvokesRpcClient() { + var request = SubscriptionRequest.newBuilder().setTopic(TOPIC).build(); + var response = SubscriptionResponse.getDefaultInstance(); + when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) + .thenReturn(CompletableFuture.completedFuture(UPayload.pack(response))); + var actualResponse = subscriptionClient.subscribe(request).toCompletableFuture().join(); + verify(rpcClient).invokeMethod(any(UUri.class), eq(UPayload.pack(request)), eq(callOptions)); + assertEquals(response, actualResponse); + } + + @Test + void testUnsubscribeInvokesRpcClient() { + var request = UnsubscribeRequest.newBuilder().setTopic(TOPIC).build(); + var response = UnsubscribeResponse.getDefaultInstance(); + when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) + .thenReturn(CompletableFuture.completedFuture(UPayload.pack(response))); + var actualResponse = subscriptionClient.unsubscribe(request).toCompletableFuture().join(); + verify(rpcClient).invokeMethod(any(UUri.class), eq(UPayload.pack(request)), eq(callOptions)); + assertEquals(response, actualResponse); + } + + @Test + void testFetchSubscribersInvokesRpcClient() { + var request = FetchSubscribersRequest.newBuilder().setTopic(TOPIC).build(); + var response = FetchSubscribersResponse.getDefaultInstance(); + when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) + .thenReturn(CompletableFuture.completedFuture(UPayload.pack(response))); + var actualResponse = subscriptionClient.fetchSubscribers(request).toCompletableFuture().join(); + verify(rpcClient).invokeMethod(any(UUri.class), eq(UPayload.pack(request)), eq(callOptions)); + assertEquals(response, actualResponse); + } + + @Test + void testFetchSubscriptionsInvokesRpcClient() { + var request = FetchSubscriptionsRequest.newBuilder().setTopic(TOPIC).build(); + var response = FetchSubscriptionsResponse.getDefaultInstance(); + when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) + .thenReturn(CompletableFuture.completedFuture(UPayload.pack(response))); + var actualResponse = subscriptionClient.fetchSubscriptions(request).toCompletableFuture().join(); + verify(rpcClient).invokeMethod(any(UUri.class), eq(UPayload.pack(request)), eq(callOptions)); + assertEquals(response, actualResponse); + } + + @Test + void testRegisterForNotificationsInvokesRpcClient() { + var request = NotificationsRequest.newBuilder().setTopic(TOPIC).build(); + var response = NotificationsResponse.getDefaultInstance(); + when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) + .thenReturn(CompletableFuture.completedFuture(UPayload.pack(response))); + var actualResponse = subscriptionClient.registerForNotifications(request).toCompletableFuture().join(); + verify(rpcClient).invokeMethod(any(UUri.class), eq(UPayload.pack(request)), eq(callOptions)); + assertEquals(response, actualResponse); + } + + @Test + void testUnregisterForNotificationsInvokesRpcClient() { + var request = NotificationsRequest.newBuilder().setTopic(TOPIC).build(); + var response = NotificationsResponse.getDefaultInstance(); + when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) + .thenReturn(CompletableFuture.completedFuture(UPayload.pack(response))); + var actualResponse = subscriptionClient.unregisterForNotifications(request).toCompletableFuture().join(); + verify(rpcClient).invokeMethod(any(UUri.class), eq(UPayload.pack(request)), eq(callOptions)); + assertEquals(response, actualResponse); + } +} diff --git a/src/test/java/org/eclipse/uprotocol/communication/CallOptionsTest.java b/src/test/java/org/eclipse/uprotocol/communication/CallOptionsTest.java index b71bdb42..274e5c2e 100644 --- a/src/test/java/org/eclipse/uprotocol/communication/CallOptionsTest.java +++ b/src/test/java/org/eclipse/uprotocol/communication/CallOptionsTest.java @@ -12,6 +12,8 @@ */ package org.eclipse.uprotocol.communication; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -22,27 +24,27 @@ import org.junit.jupiter.api.Test; -public class CallOptionsTest { +class CallOptionsTest { @Test @DisplayName("Test building a null CallOptions that is equal to the default") - public void testBuildNullCallOptions() { + void testBuildNullCallOptions() { CallOptions options = new CallOptions(); assertTrue(options.equals(CallOptions.DEFAULT)); } @Test @DisplayName("Test building a CallOptions with a timeout") - public void testBuildCallOptionsWithTimeout() { + void testBuildCallOptionsWithTimeout() { CallOptions options = new CallOptions(1000); assertEquals(1000, options.timeout()); assertEquals(UPriority.UPRIORITY_CS4, options.priority()); - assertTrue(options.token().isEmpty()); + assertNull(options.token()); } @Test @DisplayName("Test building a CallOptions with a priority") - public void testBuildCallOptionsWithPriority() { + void testBuildCallOptionsWithPriority() { CallOptions options = new CallOptions(1000, UPriority.UPRIORITY_CS4); assertEquals(UPriority.UPRIORITY_CS4, options.priority()); } @@ -50,7 +52,7 @@ public void testBuildCallOptionsWithPriority() { @Test @DisplayName("Test building a CallOptions with all parameters") - public void testBuildCallOptionsWithAllParameters() { + void testBuildCallOptionsWithAllParameters() { CallOptions options = new CallOptions(1000, UPriority.UPRIORITY_CS4, "token"); assertEquals(1000, options.timeout()); assertEquals(UPriority.UPRIORITY_CS4, options.priority()); @@ -59,28 +61,28 @@ public void testBuildCallOptionsWithAllParameters() { @Test @DisplayName("Test building a CallOptions with a blank token") - public void testBuildCallOptionsWithBlankToken() { + void testBuildCallOptionsWithBlankToken() { CallOptions options = new CallOptions(1000, UPriority.UPRIORITY_CS4, ""); assertTrue(options.token().isEmpty()); } @Test @DisplayName("Test isEquals when passed parameter is not equals") - public void testIsEqualsWithNull() { + void testIsEqualsWithNull() { CallOptions options = new CallOptions(1000, UPriority.UPRIORITY_CS4, "token"); - assertFalse(options.equals(null)); + assertNotNull(options); } @Test @DisplayName("Test isEquals when passed parameter is equals") - public void testIsEqualsWithSameObject() { + void testIsEqualsWithSameObject() { CallOptions options = new CallOptions(1000, UPriority.UPRIORITY_CS4, "token"); assertTrue(options.equals(options)); } @Test @DisplayName("Test isEquals when timeout is not the same") - public void testIsEqualsWithDifferentParameters() { + void testIsEqualsWithDifferentParameters() { CallOptions options = new CallOptions(1001, UPriority.UPRIORITY_CS3, "token"); CallOptions otherOptions = new CallOptions(1000, UPriority.UPRIORITY_CS3, "token"); assertFalse(options.equals(otherOptions)); @@ -89,7 +91,7 @@ public void testIsEqualsWithDifferentParameters() { @Test @DisplayName("Test isEquals when priority is not the same") - public void testIsEqualsWithDifferentParametersPriority() { + void testIsEqualsWithDifferentParametersPriority() { CallOptions options = new CallOptions(1000, UPriority.UPRIORITY_CS4, "token"); CallOptions otherOptions = new CallOptions(1000, UPriority.UPRIORITY_CS3, "token"); assertFalse(options.equals(otherOptions)); @@ -98,7 +100,7 @@ public void testIsEqualsWithDifferentParametersPriority() { @Test @DisplayName("Test isEquals when token is not the same") - public void testIsEqualsWithDifferentParametersToken() { + void testIsEqualsWithDifferentParametersToken() { CallOptions options = new CallOptions(1000, UPriority.UPRIORITY_CS3, "Mytoken"); CallOptions otherOptions = new CallOptions(1000, UPriority.UPRIORITY_CS3, "token"); assertFalse(options.equals(otherOptions)); @@ -108,7 +110,7 @@ public void testIsEqualsWithDifferentParametersToken() { @SuppressWarnings("unlikely-arg-type") @Test @DisplayName("Test equals when object passed is not the same type as CallOptions") - public void testIsEqualsWithDifferentType() { + void testIsEqualsWithDifferentType() { CallOptions options = new CallOptions(1000, UPriority.UPRIORITY_CS4, "token"); UUri uri = UUri.getDefaultInstance(); assertFalse(options.equals(uri)); diff --git a/src/test/java/org/eclipse/uprotocol/communication/CommunicationLayerClientTestBase.java b/src/test/java/org/eclipse/uprotocol/communication/CommunicationLayerClientTestBase.java new file mode 100644 index 00000000..b494c362 --- /dev/null +++ b/src/test/java/org/eclipse/uprotocol/communication/CommunicationLayerClientTestBase.java @@ -0,0 +1,68 @@ +/** + * SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.eclipse.uprotocol.communication; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; + +import java.util.concurrent.CompletableFuture; + +import org.eclipse.uprotocol.transport.LocalUriProvider; +import org.eclipse.uprotocol.transport.StaticUriProvider; +import org.eclipse.uprotocol.transport.UListener; +import org.eclipse.uprotocol.transport.UTransport; +import org.eclipse.uprotocol.v1.UMessage; +import org.eclipse.uprotocol.v1.UUri; +import org.junit.jupiter.api.BeforeEach; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +class CommunicationLayerClientTestBase { + protected static final UUri TRANSPORT_SOURCE = UUri.newBuilder() + .setAuthorityName("my-vehicle") + .setUeId(0xa1) + .setUeVersionMajor(0x01) + .setResourceId(0x0000) + .build(); + protected static final UUri TOPIC_URI = UUri.newBuilder(TRANSPORT_SOURCE) + .setResourceId(0xa100) + .build(); + protected static final UUri DESTINATION_URI = UUri.newBuilder() + .setAuthorityName("other-vehicle") + .setUeId(0x2bbbb) + .setUeVersionMajor(0x02) + .setResourceId(0x0000) + .build(); + protected static final UUri METHOD_URI = UUri.newBuilder(TRANSPORT_SOURCE) + .setResourceId(0x00a) + .build(); + + protected UTransport transport; + protected LocalUriProvider uriProvider; + protected ArgumentCaptor responseListener; + protected ArgumentCaptor requestMessage; + + @BeforeEach + void setUpTransport() { + transport = mock(UTransport.class); + Mockito.lenient().when(transport.registerListener(any(UUri.class), any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + Mockito.lenient().when(transport.unregisterListener(any(UUri.class), any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + Mockito.lenient().when(transport.send(any(UMessage.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + uriProvider = StaticUriProvider.of(TRANSPORT_SOURCE); + responseListener = ArgumentCaptor.forClass(UListener.class); + requestMessage = ArgumentCaptor.forClass(UMessage.class); + } +} diff --git a/src/test/java/org/eclipse/uprotocol/communication/InMemoryRpcClientTest.java b/src/test/java/org/eclipse/uprotocol/communication/InMemoryRpcClientTest.java index fba3d129..1d7d5cad 100644 --- a/src/test/java/org/eclipse/uprotocol/communication/InMemoryRpcClientTest.java +++ b/src/test/java/org/eclipse/uprotocol/communication/InMemoryRpcClientTest.java @@ -12,212 +12,209 @@ */ package org.eclipse.uprotocol.communication; +import static org.junit.Assert.assertNotEquals; 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.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.Optional; -import java.util.concurrent.CompletionStage; import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; +import java.util.stream.Stream; import java.util.concurrent.CompletableFuture; -import org.eclipse.uprotocol.transport.UTransport; + import org.eclipse.uprotocol.transport.builder.UMessageBuilder; +import org.eclipse.uprotocol.uri.factory.UriFactory; import org.eclipse.uprotocol.uuid.factory.UuidFactory; -import org.eclipse.uprotocol.v1.UAttributes; import org.eclipse.uprotocol.v1.UCode; import org.eclipse.uprotocol.v1.UMessage; +import org.eclipse.uprotocol.v1.UPayloadFormat; import org.eclipse.uprotocol.v1.UPriority; -import org.eclipse.uprotocol.v1.UStatus; import org.eclipse.uprotocol.v1.UUri; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; - -public class InMemoryRpcClientTest { - @Test - @DisplayName("Test calling invokeMethod passing UPayload") - public void testInvokeMethodWithPayload() { - UPayload payload = UPayload.packToAny(UUri.newBuilder().build()); - RpcClient rpcClient = new InMemoryRpcClient(new TestUTransport()); - final CompletionStage response = rpcClient.invokeMethod(createMethodUri(), payload, null); - assertNotNull(response); - assertDoesNotThrow(() -> { - UPayload payload1 = response.toCompletableFuture().get(); - assertTrue(payload.equals(payload1)); - }); +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import com.google.common.truth.Truth; +import com.google.protobuf.ByteString; + +class InMemoryRpcClientTest extends CommunicationLayerClientTestBase { + + private static void assertMessageHasOptions(CallOptions options, UMessage message) { + Optional.ofNullable(options.timeout()) + .ifPresent(timeout -> assertEquals(timeout, message.getAttributes().getTtl())); + Optional.ofNullable(options.priority()) + .ifPresent(priority -> assertEquals(priority, message.getAttributes().getPriority())); + Optional.ofNullable(options.token()) + .ifPresent(token -> assertEquals(token, message.getAttributes().getToken())); } - @Test - @DisplayName("Test calling invokeMethod passing a UPaylod and calloptions") - public void testInvokeMethodWithPayloadAndCallOptions() { - UPayload payload = UPayload.packToAny(UUri.newBuilder().build()); - CallOptions options = new CallOptions(1000, UPriority.UPRIORITY_CS5); - RpcClient rpcClient = new InMemoryRpcClient(new TestUTransport()); - CompletionStage response = rpcClient.invokeMethod(createMethodUri(), payload, options); - assertNotNull(response); - assertDoesNotThrow(() -> { - UPayload result = response.toCompletableFuture().get(); - assertTrue(result.equals(payload)); - }); - assertFalse(response.toCompletableFuture().isCompletedExceptionally()); + private static Stream callOptionsAndPayloadProvider() { + return Stream.of( + Arguments.of(CallOptions.DEFAULT, UPayload.EMPTY, UCode.OK), + Arguments.of( + new CallOptions(3000, UPriority.UPRIORITY_CS4, null), + UPayload.packToAny(UUri.newBuilder().build()), + UCode.OK), + Arguments.of( + new CallOptions(4000, UPriority.UPRIORITY_CS5, ""), + UPayload.pack(UUri.newBuilder().build()), + UCode.OK), + Arguments.of( + new CallOptions(5000, UPriority.UPRIORITY_CS6, "my-token"), + UPayload.pack(ByteString.copyFromUtf8("hello"), UPayloadFormat.UPAYLOAD_FORMAT_TEXT), + (UCode) null) + ); } - @Test - @DisplayName("Test calling invokeMethod passing a Null UPayload") - public void testInvokeMethodWithNullPayload() { - RpcClient rpcClient = new InMemoryRpcClient(new TestUTransport()); - CompletionStage response = rpcClient.invokeMethod(createMethodUri(), null, CallOptions.DEFAULT); - assertNotNull(response); - assertDoesNotThrow(() -> { - UPayload payload = response.toCompletableFuture().get(); - assertEquals(payload, UPayload.EMPTY); - }); - assertFalse(response.toCompletableFuture().isCompletedExceptionally()); + @ParameterizedTest(name = "Test successful RPC, sending and receiving a payload: {index} - {arguments}") + @MethodSource("callOptionsAndPayloadProvider") + void testInvokeMethodWithPayloadSucceeds(CallOptions options, UPayload payload, UCode responseStatus) { + RpcClient rpcClient = new InMemoryRpcClient(transport, uriProvider); + + var response = rpcClient.invokeMethod(METHOD_URI, payload, options); + verify(transport).registerListener(any(UUri.class), any(UUri.class), responseListener.capture()); + verify(transport).send(requestMessage.capture()); + assertEquals(payload.data(), requestMessage.getValue().getPayload()); + assertMessageHasOptions(options, requestMessage.getValue()); + + var requestAttributes = requestMessage.getValue().getAttributes(); + var responseMessageBuilder = UMessageBuilder.response(requestAttributes); + Optional.ofNullable(responseStatus) + .ifPresent(status -> responseMessageBuilder.withCommStatus(status)); + var responseMessage = responseMessageBuilder.build(payload); + responseListener.getValue().onReceive(responseMessage); + + var receivedPayload = assertDoesNotThrow(() -> response.toCompletableFuture().get()); + assertEquals(payload, receivedPayload); } @Test - @DisplayName("Test calling invokeMethod with TimeoutUTransport that will timeout the request") - public void testInvokeMethodWithTimeoutTransport() { + @DisplayName("Test running into timeout when invoking method") + void testInvokeMethodFailsForTimeout() { final UPayload payload = UPayload.packToAny(UUri.newBuilder().build()); final CallOptions options = new CallOptions(100, UPriority.UPRIORITY_CS5, "token"); - RpcClient rpcClient = new InMemoryRpcClient(new TimeoutUTransport()); - final CompletionStage response = rpcClient.invokeMethod(createMethodUri(), payload, options); - - Exception exception = assertThrows(java.util.concurrent.ExecutionException.class, - response.toCompletableFuture()::get); - assertEquals(exception.getMessage(), - "org.eclipse.uprotocol.communication.UStatusException: Request timed out"); - assertEquals(((UStatus) (((UStatusException) exception.getCause())).getStatus()).getCode(), - UCode.DEADLINE_EXCEEDED); - } - - @Test - @DisplayName("Test calling close for DefaultRpcClient when there are multiple response listeners registered") - public void testCloseWithMultipleListeners() { - InMemoryRpcClient rpcClient = new InMemoryRpcClient(new TestUTransport()); - UPayload payload = UPayload.packToAny(UUri.newBuilder().build()); - CompletionStage response = rpcClient.invokeMethod(createMethodUri(), payload, null); - assertNotNull(response); - CompletionStage response2 = rpcClient.invokeMethod(createMethodUri(), payload, null); - assertNotNull(response2); - rpcClient.close(); + RpcClient rpcClient = new InMemoryRpcClient(transport, uriProvider); + var exception = assertThrows(ExecutionException.class, () -> { + rpcClient.invokeMethod(METHOD_URI, payload, options).toCompletableFuture().get(); + }); + Truth.assertThat(exception).hasCauseThat().isInstanceOf(UStatusException.class); + assertEquals( + UCode.DEADLINE_EXCEEDED, + ((UStatusException) exception.getCause()).getStatus().getCode() + ); } @Test - @DisplayName("Test calling invokeMethod when we use the CommStatusTransport") - public void testInvokeMethodWithCommStatusTransport() { - RpcClient rpcClient = new InMemoryRpcClient(new CommStatusTransport()); - UPayload payload = UPayload.packToAny(UUri.newBuilder().build()); - CompletionStage response = rpcClient.invokeMethod(createMethodUri(), payload, null); - - Exception exception = assertThrows(java.util.concurrent.ExecutionException.class, - response.toCompletableFuture()::get); - assertTrue(response.toCompletableFuture().isCompletedExceptionally()); - assertEquals(exception.getMessage(), - "org.eclipse.uprotocol.communication.UStatusException: Communication error [FAILED_PRECONDITION]"); - - assertEquals(((UStatus) (((UStatusException) exception.getCause())).getStatus()).getCode(), - UCode.FAILED_PRECONDITION); + @DisplayName("Test invoking method fails for transport error") + void testInvokeMethodFailsForTransportError() { + when(transport.send(any(UMessage.class))) + .thenReturn(CompletableFuture.failedFuture( + new UStatusException(UCode.UNAVAILABLE, "transport not ready"))); + RpcClient rpcClient = new InMemoryRpcClient(transport, uriProvider); + var exception = assertThrows(ExecutionException.class, () -> { + rpcClient.invokeMethod(METHOD_URI, UPayload.EMPTY, CallOptions.DEFAULT).toCompletableFuture().get(); + }); + verify(transport).send(requestMessage.capture()); + assertEquals(UPayload.EMPTY.data(), requestMessage.getValue().getPayload()); + assertMessageHasOptions(CallOptions.DEFAULT, requestMessage.getValue()); + + Truth.assertThat(exception).hasCauseThat().isInstanceOf(UStatusException.class); + assertEquals( + UCode.UNAVAILABLE, + ((UStatusException) exception.getCause()).getStatus().getCode() + ); } @Test - @DisplayName("Test calling invokeMethod when we use the ErrorUTransport that fails the transport send()") - public void testInvokeMethodWithErrorTransport() { - UTransport transport = new TestUTransport() { - @Override - public CompletionStage send(UMessage message) { - return CompletableFuture.completedFuture( - UStatus.newBuilder().setCode(UCode.FAILED_PRECONDITION).build()); - } - }; - - RpcClient rpcClient = new InMemoryRpcClient(transport); - UPayload payload = UPayload.packToAny(UUri.newBuilder().build()); - CompletionStage response = rpcClient.invokeMethod(createMethodUri(), payload, null); - - Exception exception = assertThrows(java.util.concurrent.ExecutionException.class, - response.toCompletableFuture()::get); - assertTrue(response.toCompletableFuture().isCompletedExceptionally()); - assertEquals(exception.getMessage(), - "org.eclipse.uprotocol.communication.UStatusException: "); - assertEquals(((UStatus) (((UStatusException) exception.getCause())).getStatus()).getCode(), - UCode.FAILED_PRECONDITION); + @DisplayName("Test unsuccessful RPC, with service returning error") + void testInvokeMethodFailsForErroneousServiceInvocation() { + RpcClient rpcClient = new InMemoryRpcClient(transport, uriProvider); + var response = rpcClient.invokeMethod(METHOD_URI, UPayload.EMPTY, CallOptions.DEFAULT); + verify(transport).registerListener(any(UUri.class), any(UUri.class), responseListener.capture()); + verify(transport).send(requestMessage.capture()); + assertEquals(UPayload.EMPTY.data(), requestMessage.getValue().getPayload()); + assertMessageHasOptions(CallOptions.DEFAULT, requestMessage.getValue()); + + var requestAttributes = requestMessage.getValue().getAttributes(); + var responseMessage = UMessageBuilder.response(requestAttributes) + .withCommStatus(UCode.RESOURCE_EXHAUSTED) + .build(); + responseListener.getValue().onReceive(responseMessage); + + var exception = assertThrows(ExecutionException.class, () -> response.toCompletableFuture().get()); + Truth.assertThat(exception).hasCauseThat().isInstanceOf(UStatusException.class); + assertEquals( + UCode.RESOURCE_EXHAUSTED, + ((UStatusException) exception.getCause()).getStatus().getCode() + ); } - - @Test - @DisplayName("Test calling handleResponse when it gets a response for an unknown request") - public void testHandleResponseForUnknownRequest() { - UTransport transport = new TestUTransport() { - @Override - public UMessage buildResponse(UMessage request) { - UAttributes attributes = UAttributes.newBuilder(request.getAttributes()) - .setId(UuidFactory.Factories.UPROTOCOL.factory().create()).build(); - return UMessageBuilder.response(attributes).build(); - } - }; - - InMemoryRpcClient rpcClient = new InMemoryRpcClient(transport); - - CallOptions options = new CallOptions(10, UPriority.UPRIORITY_CS5); - CompletionStage response = rpcClient.invokeMethod(createMethodUri(), null, options); - assertNotNull(response); - assertThrows(ExecutionException.class, () -> { - UPayload payload = response.toCompletableFuture().get(); - assertEquals(payload, UPayload.EMPTY); - }); - assertTrue(response.toCompletableFuture().isCompletedExceptionally()); + static Stream unexpectedMessageHandlerProvider() { + return Stream.of( + Arguments.of((Consumer) null), + Arguments.of(mock(Consumer.class)) + ); } - - @Test - @DisplayName("Test calling handleResponse when it gets a message that is not a response") - public void testHandleResponseForNonResponseMessage() { - UTransport transport = new TestUTransport() { - @Override - public UMessage buildResponse(UMessage request) { - UUri topic = UUri.newBuilder(getSource()).setResourceId(0x8000).build(); - return UMessageBuilder.publish(topic).build(); - } - }; - - InMemoryRpcClient rpcClient = new InMemoryRpcClient(transport); - - CallOptions options = new CallOptions(10, UPriority.UPRIORITY_CS5); - CompletionStage response = rpcClient.invokeMethod(createMethodUri(), null, options); - assertNotNull(response); - assertThrows(ExecutionException.class, () -> { - UPayload payload = response.toCompletableFuture().get(); - assertEquals(payload, UPayload.EMPTY); - }); - assertTrue(response.toCompletableFuture().isCompletedExceptionally()); + @ParameterizedTest(name = "Test client handles unexpected incoming messages: {index} - {arguments}") + @MethodSource("unexpectedMessageHandlerProvider") + void testHandleUnexpectedResponse(Consumer unexpectedMessageHandler) { + var rpcClient = new InMemoryRpcClient(transport, uriProvider); + Optional.ofNullable(unexpectedMessageHandler).ifPresent(rpcClient::setUnexpectedMessageHandler); + verify(transport).registerListener(any(UUri.class), any(UUri.class), responseListener.capture()); + + // send an arbitrary request + rpcClient.invokeMethod(METHOD_URI, UPayload.EMPTY, CallOptions.DEFAULT); + verify(transport).send(requestMessage.capture()); + + // create unsolicited response message + final var reqId = UuidFactory.Factories.UPROTOCOL.factory().create(); + assertNotEquals(reqId, requestMessage.getValue().getAttributes().getId()); + var responseMessage = UMessageBuilder.response( + METHOD_URI, + TRANSPORT_SOURCE, + reqId) + .build(); + responseListener.getValue().onReceive(responseMessage); + + if (unexpectedMessageHandler != null) { + // assert that the unexpected response is handled correctly + verify(unexpectedMessageHandler).accept(responseMessage); + } + + // create unsolicited notification message + var notificationMessage = UMessageBuilder.notification( + UUri.newBuilder() + .setAuthorityName("hartley") + .setUeId(10) + .setUeVersionMajor(1) + .setResourceId(0x9100) + .build(), + TRANSPORT_SOURCE) + .build(); + responseListener.getValue().onReceive(notificationMessage); + + if (unexpectedMessageHandler != null) { + // assert that the unexpected notification is handled correctly + verify(unexpectedMessageHandler).accept(notificationMessage); + } } @Test - @DisplayName("Test calling invokeMethod when we set comm status to UCode.OK") - public void testInvokeMethodWithCommStatusUCodeOKTransport() { - RpcClient rpcClient = new InMemoryRpcClient(new CommStatusOkTransport()); - UPayload payload = UPayload.packToAny(UUri.newBuilder().build()); - CompletionStage response = rpcClient.invokeMethod(createMethodUri(), payload, null); - assertFalse(response.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> { - Optional unpackedStatus = UPayload.unpack(response.toCompletableFuture().get(), UStatus.class); - assertTrue(unpackedStatus.isPresent()); - assertEquals(UCode.OK, unpackedStatus.get().getCode()); - assertEquals("No Communication Error", unpackedStatus.get().getMessage()); - }); - } - - - - private UUri createMethodUri() { - return UUri.newBuilder() - .setAuthorityName("hartley") - .setUeId(10) - .setUeVersionMajor(1) - .setResourceId(3).build(); + @DisplayName("Test close() unregisters the response listener from the transport") + void testCloseUnregistersResponseListenerFromTransport() { + InMemoryRpcClient rpcClient = new InMemoryRpcClient(transport, uriProvider); + verify(transport).registerListener(eq(UriFactory.ANY), eq(TRANSPORT_SOURCE), responseListener.capture()); + rpcClient.close(); + verify(transport).unregisterListener(eq(UriFactory.ANY), eq(TRANSPORT_SOURCE), eq(responseListener.getValue())); } } diff --git a/src/test/java/org/eclipse/uprotocol/communication/InMemoryRpcServerTest.java b/src/test/java/org/eclipse/uprotocol/communication/InMemoryRpcServerTest.java index e10fbe01..a8313c58 100644 --- a/src/test/java/org/eclipse/uprotocol/communication/InMemoryRpcServerTest.java +++ b/src/test/java/org/eclipse/uprotocol/communication/InMemoryRpcServerTest.java @@ -12,466 +12,351 @@ */ package org.eclipse.uprotocol.communication; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.Assert.assertThrows; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import java.util.concurrent.CompletionStage; -import org.eclipse.uprotocol.transport.UTransport; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import org.eclipse.uprotocol.transport.UListener; import org.eclipse.uprotocol.transport.builder.UMessageBuilder; +import org.eclipse.uprotocol.uri.factory.UriFactory; +import org.eclipse.uprotocol.v1.UAttributes; import org.eclipse.uprotocol.v1.UCode; import org.eclipse.uprotocol.v1.UMessage; +import org.eclipse.uprotocol.v1.UMessageType; +import org.eclipse.uprotocol.v1.UPayloadFormat; import org.eclipse.uprotocol.v1.UStatus; import org.eclipse.uprotocol.v1.UUri; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; - -public class InMemoryRpcServerTest { - @Test - @DisplayName("Test registering and unregister a request listener") - public void testRegisteringRequestListener() { - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - return UPayload.EMPTY; - } - }; - UUri method = createMethodUri(); - RpcServer server = new InMemoryRpcServer(new TestUTransport()); - final CompletionStage result = server.registerRequestHandler(method, handler); - assertFalse(result.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> assertEquals(result.toCompletableFuture().get().getCode(), UCode.OK)); - - // second time should return an error - final CompletionStage result2 = server.unregisterRequestHandler(method, handler); - assertFalse(result2.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> assertEquals(result2.toCompletableFuture().get().getCode(), UCode.OK)); +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.google.protobuf.ByteString; + +@ExtendWith(MockitoExtension.class) +class InMemoryRpcServerTest extends CommunicationLayerClientTestBase { + + @Mock + private RequestHandler handler; + + private static Stream invalidArgsForRegisterRequestHandler() { + return Stream.of( + Arguments.of(UriFactory.ANY, 0, null, NullPointerException.class), + Arguments.of(null, 0, mock(RequestHandler.class), NullPointerException.class), + Arguments.of(UriFactory.ANY, 0x0000, mock(RequestHandler.class), CompletionException.class), + Arguments.of(UriFactory.ANY, 0x8000, mock(RequestHandler.class), CompletionException.class) + ); } - @Test - @DisplayName("Test registering twice the same request handler for the same method") - public void testRegisteringTwiceTheSameRequestHandler() { - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - return UPayload.EMPTY; - } - }; - RpcServer server = new InMemoryRpcServer(new TestUTransport()); - assertFalse(server.registerRequestHandler(createMethodUri(), handler) - .toCompletableFuture().isCompletedExceptionally()); - - CompletionStage result = server.registerRequestHandler(createMethodUri(), handler); - assertFalse(result.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> { - assertEquals(result.toCompletableFuture().get().getCode(), UCode.ALREADY_EXISTS); - }); + @ParameterizedTest(name = "Test registerRequestHandler fails for invalid arguments: {index} => {arguments}") + @MethodSource("invalidArgsForRegisterRequestHandler") + void testRegisterRequestHandlerFailsForNullParameters( + UUri uri, + int methodId, + RequestHandler handler, + Class expectedException) { + RpcServer server = new InMemoryRpcServer(transport, uriProvider); + assertThrows(expectedException, () -> server.registerRequestHandler(uri, methodId, handler) + .toCompletableFuture().join()); } - @Test - @DisplayName("Test unregistering a request handler that wasn't registered already") - public void testUnregisteringNonRegisteredRequestHandler() { - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - throw new UnsupportedOperationException("Unimplemented method 'handleRequest'"); - } - }; - RpcServer server = new InMemoryRpcServer(new TestUTransport()); - CompletionStage result = server.unregisterRequestHandler(createMethodUri(), handler); - assertFalse(result.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> { - assertEquals(result.toCompletableFuture().get().getCode(), UCode.NOT_FOUND); - }); + @ParameterizedTest(name = "Test unregisterRequestHandler fails for invalid arguments: {index} => {arguments}") + @MethodSource("invalidArgsForRegisterRequestHandler") + void testUnregisterRequestHandlerFailsForNullParameters( + UUri uri, + int methodId, + RequestHandler handler, + Class expectedException) { + RpcServer server = new InMemoryRpcServer(transport, uriProvider); + assertThrows(expectedException, () -> server.unregisterRequestHandler(uri, methodId, handler) + .toCompletableFuture().join()); } @Test - @DisplayName("Test register a request handler where authority does not match the transport source authority") - public void testRegisteringRequestListenerWithWrongAuthority() { - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - return UPayload.EMPTY; - } - }; - RpcServer server = new InMemoryRpcServer(new TestUTransport()); - UUri method = UUri.newBuilder() - .setAuthorityName("Steven") - .setUeId(4) - .setUeVersionMajor(1) - .setResourceId(3).build(); - CompletionStage status = server.registerRequestHandler(method, handler); - assertFalse(status.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> { - UStatus result = status.toCompletableFuture().get(); - assertEquals(result.getCode(), UCode.INVALID_ARGUMENT); - assertEquals(result.getMessage(), "Method URI does not match the transport source URI"); - }); + @DisplayName("Test registering and unregistering a request listener") + void testRegisterRequestListenerSucceeds() { + final RpcServer server = new InMemoryRpcServer(transport, uriProvider); + final var originFilter = UriFactory.ANY; + server.registerRequestHandler( + originFilter, + METHOD_URI.getResourceId(), + handler) + .toCompletableFuture() + .join(); + verify(transport).registerListener( + eq(originFilter), + eq(METHOD_URI), + any(UListener.class)); + + server.unregisterRequestHandler( + originFilter, + METHOD_URI.getResourceId(), + handler) + .toCompletableFuture() + .join(); + verify(transport).unregisterListener( + eq(originFilter), + eq(METHOD_URI), + any(UListener.class)); } @Test - @DisplayName("Test register a request handler where ue_id does not match the transport source ue)_id") - public void testRegisteringRequestListenerWithWrongUeId() { - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - return UPayload.EMPTY; - } - }; - RpcServer server = new InMemoryRpcServer(new TestUTransport()); - UUri method = UUri.newBuilder() - .setAuthorityName("Hartley") - .setUeId(5) - .setUeVersionMajor(1) - .setResourceId(3).build(); - - CompletionStage status = server.registerRequestHandler(method, handler); - assertFalse(status.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> { - UStatus result = status.toCompletableFuture().get(); - assertEquals(result.getCode(), UCode.INVALID_ARGUMENT); - assertEquals(result.getMessage(), "Method URI does not match the transport source URI"); - }); + @DisplayName("Test registering the same request handler twice for the same endpoint") + void testRegisteringTwiceTheSameRequestHandler() { + final RpcServer server = new InMemoryRpcServer(transport, uriProvider); + + final var originFilter = UriFactory.ANY; + server.registerRequestHandler( + originFilter, + METHOD_URI.getResourceId(), + handler) + .toCompletableFuture() + .join(); + verify(transport, times(1)).registerListener( + eq(originFilter), + eq(METHOD_URI), + any(UListener.class)); + + var exception = assertThrows(CompletionException.class, () -> server.registerRequestHandler( + originFilter, + METHOD_URI.getResourceId(), + handler) + .toCompletableFuture() + .join()); + assertEquals(UCode.ALREADY_EXISTS, ((UStatusException) exception.getCause()).getCode()); } @Test - @DisplayName("Test register request handler where ue_version_major does not " + - "match the transport source ue_version_major") - public void testRegisteringRequestListenerWithWrongUeVersionMajor() { - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - return UPayload.EMPTY; - } - }; - RpcServer server = new InMemoryRpcServer(new TestUTransport()); - UUri method = UUri.newBuilder() - .setAuthorityName("Hartley") - .setUeId(4) - .setUeVersionMajor(2) - .setResourceId(3).build(); - - CompletionStage status = server.registerRequestHandler(method, handler); - assertFalse(status.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> { - UStatus result = status.toCompletableFuture().get(); - assertEquals(result.getCode(), UCode.INVALID_ARGUMENT); - assertEquals(result.getMessage(), "Method URI does not match the transport source URI"); - }); - + @DisplayName("Test unregistering a request handler that wasn't registered already") + void testUnregisterRequestHandlerFailsForUnknownHandler() { + final RpcServer server = new InMemoryRpcServer(transport, uriProvider); + + var exception = assertThrows( + CompletionException.class, + () -> server.unregisterRequestHandler(UriFactory.ANY, 1, handler) + .toCompletableFuture().join()); + assertEquals(UCode.NOT_FOUND, ((UStatusException) exception.getCause()).getCode()); + verify(transport, never()).unregisterListener( + any(UUri.class), + any(UUri.class), + any(UListener.class)); } @Test - @DisplayName("Test unregister requesthandler where authority not match the transport source URI") - public void testUnregisteringRequestHandlerWithWrongAuthority() { - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - return UPayload.EMPTY; - } - }; - RpcServer server = new InMemoryRpcServer(new TestUTransport()); - UUri method = UUri.newBuilder() - .setAuthorityName("Steven") - .setUeId(4) - .setUeVersionMajor(1) - .setResourceId(3).build(); - - CompletionStage status = server.unregisterRequestHandler(method, handler); - - assertFalse(status.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> { - UStatus result = status.toCompletableFuture().get(); - assertEquals(result.getCode(), UCode.INVALID_ARGUMENT); - assertEquals(result.getMessage(), "Method URI does not match the transport source URI"); - }); + @DisplayName("Test registering a request handler with unavailable transport fails") + void testRegisteringRequestListenerFailsIfTransportIsUnavailable() { + when(transport.registerListener(any(UUri.class), any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.failedFuture(new UStatusException(UCode.UNAVAILABLE, "unavailable"))); + RpcServer server = new InMemoryRpcServer(transport, uriProvider); + + var exception = assertThrows( + CompletionException.class, + () -> server.registerRequestHandler(UriFactory.ANY, METHOD_URI.getResourceId(), handler) + .toCompletableFuture().join()); + assertEquals(UCode.UNAVAILABLE, ((UStatusException) exception.getCause()).getCode()); + verify(transport, times(1)).registerListener( + eq(UriFactory.ANY), + eq(METHOD_URI), + any(UListener.class)); } - @Test - @DisplayName("Test unregister request handler where ue_id does not match the transport source URI") - public void testUnregisteringRequestHandlerWithWrongUeId() { - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - return UPayload.EMPTY; - } - }; - RpcServer server = new InMemoryRpcServer(new TestUTransport()); - UUri method = UUri.newBuilder() - .setAuthorityName("Hartley") - .setUeId(5) - .setUeVersionMajor(1) - .setResourceId(3).build(); - - CompletionStage result = server.unregisterRequestHandler(method, handler); - assertFalse(result.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> { - UStatus status = result.toCompletableFuture().get(); - assertEquals(status.getCode(), UCode.INVALID_ARGUMENT); - assertEquals(status.getMessage(), "Method URI does not match the transport source URI"); - }); + private static Stream requestHandlerExceptionProvider() { + return Stream.of( + Arguments.of( + new UStatusException(UStatus.newBuilder() + .setCode(UCode.PERMISSION_DENIED) + .setMessage("Client is not authorized to invoke this operation") + .build()), + UCode.PERMISSION_DENIED, + "Client is not authorized to invoke this operation"), + Arguments.of( + new IllegalStateException("service not ready (yet)"), + UCode.INTERNAL, + InMemoryRpcServer.REQUEST_HANDLER_ERROR_MESSAGE) + ); } - @Test - @DisplayName("Test unregister request handler where ue_version_major does not match the transport source URI") - public void testUnregisteringRequestHandlerWithWrongUeVersionMajor() { - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - return UPayload.EMPTY; - } - }; - RpcServer server = new InMemoryRpcServer(new TestUTransport()); - UUri method = UUri.newBuilder() - .setAuthorityName("Hartley") - .setUeId(4) - .setUeVersionMajor(2) - .setResourceId(3).build(); - - CompletionStage result = server.unregisterRequestHandler(method, handler); - assertFalse(result.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> { - UStatus status = result.toCompletableFuture().get(); - assertEquals(status.getCode(), UCode.INVALID_ARGUMENT); - assertEquals(status.getMessage(), "Method URI does not match the transport source URI"); - }); - } + @ParameterizedTest(name = "Test request handler throws exception: {index} => {arguments}") + @MethodSource("requestHandlerExceptionProvider") + void testHandleRequestHandlesException(Exception thrownException, UCode expectedCode, String expectedMessage) { + when(handler.handleRequest(any(UMessage.class))).thenThrow(thrownException); + RpcServer server = new InMemoryRpcServer(transport, uriProvider); + server.registerRequestHandler(UriFactory.ANY, METHOD_URI.getResourceId(), handler) + .toCompletableFuture().join(); + final ArgumentCaptor requestListener = ArgumentCaptor.forClass(UListener.class); + verify(transport).registerListener(eq(UriFactory.ANY), eq(METHOD_URI), requestListener.capture()); - @Test - @DisplayName("Test register a request handler when we use the ErrorUTransport that returns an error") - public void testRegisteringRequestListenerWithErrorTransport() { - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - return UPayload.EMPTY; - } - }; - RpcServer server = new InMemoryRpcServer(new ErrorUTransport()); - CompletionStage result = server.registerRequestHandler(createMethodUri(), handler); - assertFalse(result.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> { - UStatus status = result.toCompletableFuture().get(); - assertEquals(status.getCode(), UCode.FAILED_PRECONDITION); - }); - } + final var request = UMessageBuilder.request(uriProvider.getSource(), METHOD_URI, 5000).build(); + requestListener.getValue().onReceive(request); + verify(handler).handleRequest(request); - @Test - @DisplayName("Test handleRequests when we have 2 RpcServers and the request is not for the second instance" + - "this is to test that we pull from mRequestHandlers and remove returns nothing") - public void testHandlerequests() { - // test transport that will trigger the handleRequest() - UTransport transport = new EchoUTransport(); - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - throw new UnsupportedOperationException("this should not be called"); - } - }; - - RpcServer server = new InMemoryRpcServer(transport); - - UUri method = createMethodUri(); - UUri method2 = UUri.newBuilder(method).setResourceId(69).build(); - - server.registerRequestHandler(method, handler) - .thenApplyAsync(status -> { - UMessage request = UMessageBuilder.request(transport.getSource(), method, 1000).build(); - assertDoesNotThrow(() -> { - return transport.send(request).toCompletableFuture().get(); - }); - return status; - }) - .thenApplyAsync(status -> { - assertDoesNotThrow(() -> { - return server.registerRequestHandler(method2, handler).toCompletableFuture().get(); - }); - return status; - }); - } + final ArgumentCaptor responseMessage = ArgumentCaptor.forClass(UMessage.class); + verify(transport).send(responseMessage.capture()); - @Test - @DisplayName("Test handleRequests the handler triggered an exception") - public void testHandlerequestsException() { - // test transport that will trigger the handleRequest() - UTransport transport = new EchoUTransport(); - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - throw new UStatusException(UStatus.newBuilder().setCode(UCode.FAILED_PRECONDITION) - .setMessage("Steven it failed!").build()); - } - }; - - RpcServer server = new InMemoryRpcServer(transport); - - UUri method = createMethodUri(); - - server.registerRequestHandler(method, handler).thenApply(status -> { - UMessage request = UMessageBuilder.request(transport.getSource(), method, 1000).build(); - CompletionStage result = transport.send(request); - assertFalse(result.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> { - assertEquals(result.toCompletableFuture().get().getCode(), UCode.FAILED_PRECONDITION); - }); - return status; - }); + assertEquals(expectedCode, responseMessage.getValue().getAttributes().getCommstatus()); + final var status = UPayload.unpack(responseMessage.getValue(), UStatus.class); + assertEquals(expectedCode, status.get().getCode()); + assertEquals(expectedMessage, status.get().getMessage()); } - @Test - @DisplayName("Test handleRequests the handler triggered an unknown exception") - public void testHandlerequestsUnknownException() { - // test transport that will trigger the handleRequest() - UTransport transport = new EchoUTransport(); - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - throw new UnsupportedOperationException("Steven it failed!"); - } - }; - - RpcServer server = new InMemoryRpcServer(transport); - - UUri method = createMethodUri(); - - server.registerRequestHandler(method, handler).thenAcceptAsync(status -> { - // fake sending a request message that will trigger the handler to be called but since it is - // not for the same method as the one registered, it should be ignored and the handler not called - UMessage request = UMessageBuilder.request(transport.getSource(), method, 1000).build(); - - CompletionStage result = transport.send(request); - assertFalse(result.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> { - assertEquals(result.toCompletableFuture().get().getCode(), UCode.INTERNAL); - assertEquals(result.toCompletableFuture().get().getMessage(), "Steven it failed!"); - }); - - }); + private static Stream errorHandlersProvider() { + return Stream.of( + Arguments.of( + mock(Consumer.class), + mock(Consumer.class), + new UStatusException(UCode.UNAVAILABLE, "unavailable") + ), + Arguments.of( + null, + null, + new UStatusException(UCode.DEADLINE_EXCEEDED, "message expired") + ), + Arguments.of( + null, + null, + new IllegalStateException("not ready") + ) + ); } - @Test - @DisplayName("Test handleRequests when we receive a request for a method that we do not have a registered handler") - public void testHandlerequestsNoHandler() { - // test transport that will trigger the handleRequest() - UTransport transport = new EchoUTransport(); - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - throw new UnsupportedOperationException("this should not be called"); - } - }; - - RpcServer server = new InMemoryRpcServer(transport); - UUri method = createMethodUri(); - UUri method2 = UUri.newBuilder(method).setResourceId(69).build(); - - assertDoesNotThrow(() -> { - server.registerRequestHandler(method, handler).thenApplyAsync(result -> { - assertEquals(result.getCode(), UCode.OK); - UMessage request = UMessageBuilder.request(transport.getSource(), method2, 1000).build(); - assertDoesNotThrow(() -> { - UStatus status = transport.send(request).toCompletableFuture().get(); - assertEquals(status.getCode(), UCode.NOT_FOUND); - }); - return result; - }); - }); + @ParameterizedTest(name = "Test request handler reports send error: {index} => {arguments}") + @MethodSource("errorHandlersProvider") + void testRequestHandlerReportsSendError( + Consumer sendResponseErrorHandler, + Consumer unexpectedMessageHandler, + Exception sendError) { + + final var request = UMessageBuilder.request(uriProvider.getSource(), METHOD_URI, 5000) + .build(UPayload.pack(ByteString.copyFromUtf8("Hello"), UPayloadFormat.UPAYLOAD_FORMAT_TEXT)); + final var responsePayload = UPayload.pack( + ByteString.copyFromUtf8("Hello again"), + UPayloadFormat.UPAYLOAD_FORMAT_TEXT); + when(handler.handleRequest(any(UMessage.class))).thenReturn(responsePayload); + when(transport.send(any(UMessage.class))) + .thenReturn(CompletableFuture.failedFuture(sendError)); + + var server = new InMemoryRpcServer(transport, uriProvider); + Optional.ofNullable(sendResponseErrorHandler).ifPresent(server::setSendErrorHandler); + Optional.ofNullable(unexpectedMessageHandler).ifPresent(server::setUnexpectedMessageHandler); + server.registerRequestHandler(UriFactory.ANY, METHOD_URI.getResourceId(), handler) + .toCompletableFuture().join(); + final ArgumentCaptor requestListener = ArgumentCaptor.forClass(UListener.class); + verify(transport).registerListener(eq(UriFactory.ANY), eq(METHOD_URI), requestListener.capture()); + + requestListener.getValue().onReceive(request); + verify(handler).handleRequest(request); + + final ArgumentCaptor responseMessage = ArgumentCaptor.forClass(UMessage.class); + verify(transport).send(responseMessage.capture()); + if (sendResponseErrorHandler != null) { + verify(sendResponseErrorHandler).accept(sendError); + } + // Default handler just logs the error, so nothing to verify } - @Test - @DisplayName("Test handling a request where the handler returns a payload and completes successfully") - public void testHandlerequestsWithPayload() { - // test transport that will trigger the handleRequest() - UTransport transport = new EchoUTransport(); - - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - return UPayload.EMPTY; - } - }; - - RpcServer server = new InMemoryRpcServer(transport); - - UUri method = createMethodUri(); - - server.registerRequestHandler(method, handler).thenAccept(status -> { - UMessage request = UMessageBuilder.request(transport.getSource(), method, 1000).build(); - CompletionStage result = transport.send(request); - assertFalse(result.toCompletableFuture().isCompletedExceptionally()); - assertDoesNotThrow(() -> { - assertEquals(result.toCompletableFuture().get().getCode(), UCode.OK); - }); - }); + static Stream unexpectedMessageHandlerProvider() { + return Stream.of( + Arguments.of((Consumer) null), + Arguments.of(mock(Consumer.class)) + ); } - @Test - @DisplayName("Test registerRequestHandler when passed parameters are null") - public void testRegisterrequesthandlerNullParameters() { - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - return UPayload.EMPTY; - } - }; - - RpcServer server = new InMemoryRpcServer(new TestUTransport()); - assertDoesNotThrow(() -> { - UStatus status = server.registerRequestHandler(null, null).toCompletableFuture().get(); - assertEquals(status.getCode(), UCode.INVALID_ARGUMENT); - assertEquals(status.getMessage(), "Method URI or handler missing"); - }); - - assertDoesNotThrow(() -> { - UStatus status = server.registerRequestHandler(createMethodUri(), null).toCompletableFuture().get(); - assertEquals(status.getCode(), UCode.INVALID_ARGUMENT); - assertEquals(status.getMessage(), "Method URI or handler missing"); - }); - - assertDoesNotThrow(() -> { - UStatus status = server.registerRequestHandler(null, handler).toCompletableFuture().get(); - assertEquals(status.getCode(), UCode.INVALID_ARGUMENT); - assertEquals(status.getMessage(), "Method URI or handler missing"); - }); + @ParameterizedTest + @MethodSource("unexpectedMessageHandlerProvider") + void testRequestHandlerIgnoresRequestsWithoutHandler(Consumer unexpectedMessageHandler) { + final var unsolicitedRequest = UMessageBuilder.request(uriProvider.getSource(), METHOD_URI, 5000).build(); + + var server = new InMemoryRpcServer(transport, uriProvider); + Optional.ofNullable(unexpectedMessageHandler).ifPresent(server::setUnexpectedMessageHandler); + server.registerRequestHandler(UriFactory.ANY, METHOD_URI.getResourceId(), handler) + .toCompletableFuture().join(); + final ArgumentCaptor requestListener = ArgumentCaptor.forClass(UListener.class); + verify(transport).registerListener(eq(UriFactory.ANY), eq(METHOD_URI), requestListener.capture()); + + server.unregisterRequestHandler(UriFactory.ANY, METHOD_URI.getResourceId(), handler) + .toCompletableFuture().join(); + + requestListener.getValue().onReceive(unsolicitedRequest); + verify(handler, never()).handleRequest(any(UMessage.class)); + verify(transport, never()).send(any(UMessage.class)); + if (unexpectedMessageHandler != null) { + verify(unexpectedMessageHandler).accept(unsolicitedRequest); + } } @Test - @DisplayName("Test unregisterRequestHandler when passed parameters are null") - public void testUnregisterrequesthandlerNullParameters() { - RequestHandler handler = new RequestHandler() { - @Override - public UPayload handleRequest(UMessage request) { - return UPayload.EMPTY; - } - }; - - RpcServer server = new InMemoryRpcServer(new TestUTransport()); - assertDoesNotThrow(() -> { - UStatus status = server.unregisterRequestHandler(null, null).toCompletableFuture().get(); - assertEquals(status.getCode(), UCode.INVALID_ARGUMENT); - assertEquals(status.getMessage(), "Method URI or handler missing"); - }); - - assertDoesNotThrow(() -> { - UStatus status = server.unregisterRequestHandler(createMethodUri(), null).toCompletableFuture().get(); - assertEquals(status.getCode(), UCode.INVALID_ARGUMENT); - assertEquals(status.getMessage(), "Method URI or handler missing"); - }); - - assertDoesNotThrow(() -> { - UStatus status = server.unregisterRequestHandler(null, handler).toCompletableFuture().get(); - assertEquals(status.getCode(), UCode.INVALID_ARGUMENT); - assertEquals(status.getMessage(), "Method URI or handler missing"); - }); + void testHandleRequestIgnoresNonRequestMessages() { + final var invalidNotification = UMessage.newBuilder() + .setAttributes(UAttributes.newBuilder() + .setType(UMessageType.UMESSAGE_TYPE_NOTIFICATION) + .setSource(TRANSPORT_SOURCE) + .setSink(METHOD_URI) + .build()) + .build(); + + @SuppressWarnings("unchecked") + final Consumer sendResponseErrorHandler = mock(Consumer.class); + @SuppressWarnings("unchecked") + final Consumer unexpectedMessageHandler = mock(Consumer.class); + + var server = new InMemoryRpcServer( + transport, + uriProvider); + server.setSendErrorHandler(sendResponseErrorHandler); + server.setUnexpectedMessageHandler(unexpectedMessageHandler); + server.registerRequestHandler(UriFactory.ANY, METHOD_URI.getResourceId(), handler) + .toCompletableFuture().join(); + final ArgumentCaptor requestListener = ArgumentCaptor.forClass(UListener.class); + verify(transport).registerListener(eq(UriFactory.ANY), eq(METHOD_URI), requestListener.capture()); + + requestListener.getValue().onReceive(invalidNotification); + verify(handler, never()).handleRequest(any(UMessage.class)); + verify(sendResponseErrorHandler, never()).accept(any(Throwable.class)); + verify(transport, never()).send(any(UMessage.class)); + verify(unexpectedMessageHandler).accept(invalidNotification); } - - // Helper method to create a UUri that matches that of the default TestUTransport - private UUri createMethodUri() { - return UUri.newBuilder() - .setAuthorityName("Hartley") - .setUeId(4) - .setUeVersionMajor(1) - .setResourceId(3).build(); + @Test + @DisplayName("Test handling a request where the handler returns a payload and completes successfully") + void testHandleRequestSucceedsWithPayload() { + final var request = UMessageBuilder.request(uriProvider.getSource(), METHOD_URI, 5000) + .build(UPayload.pack(ByteString.copyFromUtf8("Hello"), UPayloadFormat.UPAYLOAD_FORMAT_TEXT)); + final var responsePayload = UPayload.pack( + ByteString.copyFromUtf8("Hello again"), + UPayloadFormat.UPAYLOAD_FORMAT_TEXT); + when(handler.handleRequest(any(UMessage.class))).thenReturn(responsePayload); + + RpcServer server = new InMemoryRpcServer(transport, uriProvider); + server.registerRequestHandler(UriFactory.ANY, METHOD_URI.getResourceId(), handler) + .toCompletableFuture().join(); + final ArgumentCaptor requestListener = ArgumentCaptor.forClass(UListener.class); + verify(transport).registerListener(eq(UriFactory.ANY), eq(METHOD_URI), requestListener.capture()); + + requestListener.getValue().onReceive(request); + verify(handler).handleRequest(request); + + final ArgumentCaptor responseMessage = ArgumentCaptor.forClass(UMessage.class); + verify(transport).send(responseMessage.capture()); + + assertEquals(UCode.OK, responseMessage.getValue().getAttributes().getCommstatus()); + assertEquals(responsePayload.data(), responseMessage.getValue().getPayload()); } - } diff --git a/src/test/java/org/eclipse/uprotocol/communication/InMemorySubscriberTest.java b/src/test/java/org/eclipse/uprotocol/communication/InMemorySubscriberTest.java new file mode 100644 index 00000000..7d102490 --- /dev/null +++ b/src/test/java/org/eclipse/uprotocol/communication/InMemorySubscriberTest.java @@ -0,0 +1,671 @@ +/** + * SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.eclipse.uprotocol.communication; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Consumer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +import org.mockito.junit.jupiter.MockitoExtension; + +import org.eclipse.uprotocol.client.usubscription.v3.USubscriptionClient; +import org.eclipse.uprotocol.core.usubscription.v3.NotificationsRequest; +import org.eclipse.uprotocol.core.usubscription.v3.NotificationsResponse; +import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionRequest; +import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionResponse; +import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionStatus; +import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeRequest; +import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeResponse; +import org.eclipse.uprotocol.core.usubscription.v3.Update; +import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionStatus.State; +import org.eclipse.uprotocol.transport.LocalUriProvider; +import org.eclipse.uprotocol.transport.StaticUriProvider; +import org.eclipse.uprotocol.transport.UListener; +import org.eclipse.uprotocol.transport.UTransport; +import org.eclipse.uprotocol.transport.builder.UMessageBuilder; +import org.eclipse.uprotocol.uuid.factory.UuidFactory; +import org.eclipse.uprotocol.v1.UAttributes; +import org.eclipse.uprotocol.v1.UCode; +import org.eclipse.uprotocol.v1.UMessage; +import org.eclipse.uprotocol.v1.UMessageType; +import org.eclipse.uprotocol.v1.UUri; + +@ExtendWith(MockitoExtension.class) +class InMemorySubscriberTest { + private static final UUri SUBSCRIPTION_SERVICE_URI = UUri.newBuilder() + .setAuthorityName("some-host") + .setUeId(0x0002_0000) + .setUeVersionMajor(0x03) + .build(); + private static final UUri SUBSCRIPTION_NOTIFICATION_TOPIC_URI = UUri.newBuilder(SUBSCRIPTION_SERVICE_URI) + .setResourceId(0x8000) + .build(); + private static final UUri TOPIC = UUri.newBuilder() + .setAuthorityName("my-vehicle") + .setUeId(0xa103) + .setUeVersionMajor(0x06) + .setResourceId(0xa10f) + .build(); + + private static final UUri SOURCE = UUri.newBuilder() + .setAuthorityName("my-vehicle") + .setUeId(0x0004) + .setUeVersionMajor(0x01) + .build(); + + private UTransport transport; + private LocalUriProvider uriProvider; + private Notifier notifier; + private USubscriptionClient subscriptionClient; + + @Mock + private SubscriptionChangeHandler subscriptionChangeHandler; + private ArgumentCaptor notificationListener; + + @Mock + private UListener listener; + + @BeforeEach + void setup() { + uriProvider = StaticUriProvider.of(SOURCE); + + transport = mock(UTransport.class); + Mockito.lenient().when(transport.registerListener(any(UUri.class), any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + Mockito.lenient().when(transport.registerListener(any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + Mockito.lenient().when(transport.unregisterListener(any(UUri.class), any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + Mockito.lenient().when(transport.unregisterListener(any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + + notifier = mock(Notifier.class); + Mockito.lenient().when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + Mockito.lenient().when(notifier.unregisterNotificationListener(any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + + subscriptionClient = mock(USubscriptionClient.class); + Mockito.lenient().when(subscriptionClient.getSubscriptionServiceNotificationTopic()) + .thenReturn(SUBSCRIPTION_NOTIFICATION_TOPIC_URI); + + notificationListener = ArgumentCaptor.forClass(UListener.class); + } + + @Test + @DisplayName("Test constructors reject invalid arguments") + void testConstructorsRejectInvalidArguments() { + assertThrows(NullPointerException.class, () -> new InMemorySubscriber( + null, + uriProvider, + CallOptions.DEFAULT, + 0x0000, + "local")); + assertThrows(NullPointerException.class, () -> new InMemorySubscriber( + transport, + null, + CallOptions.DEFAULT, + 0x0000, + "local")); + assertThrows(NullPointerException.class, () -> new InMemorySubscriber( + transport, + uriProvider, + null, + 0x0000, + "local")); + assertThrows(IllegalArgumentException.class, () -> new InMemorySubscriber( + transport, + uriProvider, + CallOptions.DEFAULT, + 0xFFFF, + "local")); + assertThrows(IllegalArgumentException.class, () -> new InMemorySubscriber( + transport, + uriProvider, + CallOptions.DEFAULT, + -1, + "local")); + + assertThrows(NullPointerException.class, () -> new InMemorySubscriber( + null, + subscriptionClient, + notifier)); + assertThrows(NullPointerException.class, () -> new InMemorySubscriber( + transport, + null, + notifier)); + assertThrows(NullPointerException.class, () -> new InMemorySubscriber( + transport, + subscriptionClient, + null)); + } + + @Test + void testConstructorForTransportAndUriProvider() { + new InMemorySubscriber( + transport, + uriProvider, + CallOptions.DEFAULT, + 0x0002, + SUBSCRIPTION_NOTIFICATION_TOPIC_URI.getAuthorityName()); + verify(transport).registerListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + eq(SOURCE), + any(UListener.class)); + } + + @Test + void testSubscriberRegistersNotificationListener() { + // WHEN trying to create a Subscriber + new InMemorySubscriber(transport, subscriptionClient, notifier); + // THEN the Subscriber registers a notification listener + verify(notifier).registerNotificationListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + notificationListener.capture()); + } + + @Test + void testSubscriberCreationFailsWhenNotifierFailsToRegisterListener() { + // GIVEN a Notifier that is not connected to its transport + when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.failedFuture( + new UStatusException(UCode.UNAVAILABLE, "not available"))); + + // WHEN trying to create a Subscriber for this Notifier + final var exception = assertThrows(CompletionException.class, () -> new InMemorySubscriber( + transport, + subscriptionClient, + notifier + )); + // THEN creation fails + verify(notifier).registerNotificationListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + any(UListener.class)); + assertEquals(UCode.UNAVAILABLE, ((UStatusException) exception.getCause()).getStatus().getCode()); + } + + + @Test + void testCloseForSuccessfulUnregistration() { + // GIVEN a Notifier that succeeds to unregister listeners + when(notifier.unregisterNotificationListener(any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + assertCloseIgnoresFailureToUnregisterNotificationListener(notifier); + } + + @Test + void testCloseForFailedUnregistration() { + // GIVEN a Notifier that fails to unregister listeners + when(notifier.unregisterNotificationListener(any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.failedFuture( + new UStatusException(UCode.UNAVAILABLE, "not available"))); + assertCloseIgnoresFailureToUnregisterNotificationListener(notifier); + } + + private void assertCloseIgnoresFailureToUnregisterNotificationListener(Notifier notifier) { + // GIVEN a Subscriber using the Notifier + var subscriber = new InMemorySubscriber(transport, subscriptionClient, notifier); + verify(notifier).registerNotificationListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + notificationListener.capture()); + + // WHEN trying to close the Subscriber + subscriber.close(); + + // THEN the listener is getting unregistered + verify(notifier).unregisterNotificationListener( + SUBSCRIPTION_NOTIFICATION_TOPIC_URI, + notificationListener.getValue()); + } + + @Test + void testSubscribeFailsWhenUSubscriptionInvocationFails() { + // GIVEN a USubscription client + // that fails to perform subscription due to different reasons + when(subscriptionClient.subscribe(any(SubscriptionRequest.class))) + .thenReturn(CompletableFuture.failedFuture( + new UStatusException(UCode.UNAVAILABLE, "not connected"))) + .thenReturn(CompletableFuture.completedFuture( + SubscriptionResponse.newBuilder() + .setTopic(TOPIC) + .setStatus(SubscriptionStatus.newBuilder() + .setState(SubscriptionStatus.State.UNSUBSCRIBED) + .setMessage("unsupported topic") + .build()) + .build())) + .thenReturn(CompletableFuture.completedFuture( + SubscriptionResponse.newBuilder() + .setTopic(TOPIC) + .setStatus(SubscriptionStatus.newBuilder() + .setMessage("unknown state") + .build()) + .build())); + + // and a Subscriber using that USubscription client + var subscriber = new InMemorySubscriber(transport, subscriptionClient, notifier); + verify(notifier).registerNotificationListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + notificationListener.capture()); + + // WHEN subscribing to a topic + var attempt1 = subscriber.subscribe(TOPIC, listener, Optional.empty()); + + // THEN the first attempt fails + var exception = assertThrows(CompletionException.class, () -> attempt1.toCompletableFuture().join()); + assertEquals(UCode.UNAVAILABLE, ((UStatusException) exception.getCause()).getCode()); + + // AND the second attempt fails as well + var attempt2 = subscriber.subscribe(TOPIC, listener, Optional.empty()); + exception = assertThrows(CompletionException.class, () -> attempt2.toCompletableFuture().join()); + assertEquals(UCode.INTERNAL, ((UStatusException) exception.getCause()).getCode()); + + // AND the third attempt fails as well + var attempt3 = subscriber.subscribe(TOPIC, listener, Optional.empty()); + exception = assertThrows(CompletionException.class, () -> attempt3.toCompletableFuture().join()); + assertEquals(UCode.INTERNAL, ((UStatusException) exception.getCause()).getCode()); + + verify(subscriptionClient, times(3)).subscribe(argThat(req -> req.getTopic().equals(TOPIC))); + } + + @Test + void testRepeatedSubscribeFailsForDifferentSubscriptionChangeHandlers() { + // GIVEN a USubscription client + // that succeeds to subscribe to topics + when(subscriptionClient.subscribe(any(SubscriptionRequest.class))) + .thenReturn(CompletableFuture.completedFuture( + SubscriptionResponse.newBuilder() + .setTopic(TOPIC) + .setStatus(SubscriptionStatus.newBuilder() + .setState(SubscriptionStatus.State.SUBSCRIBED) + .build()) + .build())); + + // AND a transport + when(transport.registerListener(any(UUri.class), any(UListener.class))) + // that fails to register a listener on the first attempt + .thenReturn(CompletableFuture.failedFuture( + new UStatusException(UCode.UNAVAILABLE, "not connected"))) + // but succeeds to do so on the second attempt + .thenReturn(CompletableFuture.completedFuture(null)); + + // AND a Subscriber using that USubscription client, Notifier and transport + var subscriber = new InMemorySubscriber(transport, subscriptionClient, notifier); + verify(notifier).registerNotificationListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + notificationListener.capture()); + + // WHEN subscribing to a topic + var attempt1 = subscriber.subscribe(TOPIC, listener, Optional.of(subscriptionChangeHandler)); + + // THEN the first attempt fails due to the transport having failed + var exception = assertThrows(CompletionException.class, () -> attempt1.toCompletableFuture().join()); + assertEquals(UCode.UNAVAILABLE, ((UStatusException) exception.getCause()).getCode()); + + // AND a second attempt using a different subscription change handler + var attempt2 = subscriber.subscribe(TOPIC, listener, Optional.of(mock(SubscriptionChangeHandler.class))); + exception = assertThrows(CompletionException.class, () -> attempt2.toCompletableFuture().join()); + // fails with an ALREADY_EXISTS error + assertEquals(UCode.ALREADY_EXISTS, ((UStatusException) exception.getCause()).getCode()); + + verify(transport, times(1)).registerListener(TOPIC, listener); + verify(subscriptionClient, times(2)).subscribe(argThat(req -> req.getTopic().equals(TOPIC))); + } + + @Test + void testSubscribeSucceedsOnSecondAttempt() { + + // GIVEN a USubscription client that succeeds to subscribe to topics + when(subscriptionClient.subscribe(any(SubscriptionRequest.class))) + .thenReturn(CompletableFuture.completedFuture( + SubscriptionResponse.newBuilder() + .setTopic(TOPIC) + .setStatus(SubscriptionStatus.newBuilder() + .setState(SubscriptionStatus.State.SUBSCRIBED) + .build()) + .build())); + + // and a transport + when(transport.registerListener(any(UUri.class), any(UListener.class))) + // that first fails to register a listener + .thenReturn(CompletableFuture.failedFuture( + new UStatusException(UCode.UNAVAILABLE, "not connected"))) + // but succeeds on the second attempt + .thenReturn(CompletableFuture.completedStage(null)); + + + // and a Subscriber using that USubscription client, Notifier and transport + var subscriber = new InMemorySubscriber(transport, subscriptionClient, notifier); + verify(notifier).registerNotificationListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + notificationListener.capture()); + + // WHEN subscribing to a topic + var attempt1 = subscriber.subscribe(TOPIC, listener, Optional.of(subscriptionChangeHandler)); + + // THEN the first attempt fails due to the transport having failed + var exception = assertThrows(CompletionException.class, () -> attempt1.toCompletableFuture().join()); + assertEquals(UCode.UNAVAILABLE, ((UStatusException) exception.getCause()).getCode()); + + // but the second attempt succeeds + subscriber.subscribe(TOPIC, listener, Optional.of(subscriptionChangeHandler)) + .toCompletableFuture().join(); + verify(subscriptionClient, times(2)).subscribe(argThat(req -> req.getTopic().equals(TOPIC))); + verify(transport, times(2)).registerListener(TOPIC, listener); + + // and the subscription change handler receives notifications + var update = Update.newBuilder() + .setTopic(TOPIC) + .setStatus(SubscriptionStatus.newBuilder() + .setState(SubscriptionStatus.State.UNSUBSCRIBED) + .build()) + .build(); + var subscriptionChange = UMessageBuilder.notification(SUBSCRIPTION_NOTIFICATION_TOPIC_URI, SOURCE) + .build(UPayload.pack(update)); + notificationListener.getValue().onReceive(subscriptionChange); + verify(subscriptionChangeHandler, times(1)).handleSubscriptionChange( + eq(TOPIC), any(SubscriptionStatus.class)); + } + + @Test + void testUnsubscribeFailsForUnknownListener() { + + // GIVEN a USubscription client + // that succeeds to unsubscribe from topics + when(subscriptionClient.unsubscribe(any(UnsubscribeRequest.class))) + .thenReturn(CompletableFuture.completedFuture(UnsubscribeResponse.newBuilder().build())); + + // AND a transport + // which fails to unregister an unknown listener + when(transport.unregisterListener(any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.failedFuture( + new UStatusException(UCode.NOT_FOUND, "no such listener"))); + + // AND a Subscriber using that USubscription client, Notifier and transport + var subscriber = new InMemorySubscriber(transport, subscriptionClient, notifier); + verify(notifier).registerNotificationListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + notificationListener.capture()); + + // WHEN unsubscribing from a topic for which no listener had been registered + var attempt = subscriber.unsubscribe(TOPIC, listener); + + // THEN the attempt fails + var exception = assertThrows(CompletionException.class, () -> attempt.toCompletableFuture().join()); + assertEquals(UCode.NOT_FOUND, ((UStatusException) exception.getCause()).getCode()); + verify(subscriptionClient).unsubscribe(argThat(req -> req.getTopic().equals(TOPIC))); + verify(transport).unregisterListener(TOPIC, listener); + } + + @Test + void testUnsubscribeFailsIfUSubscriptionInvocationFails() { + // GIVEN a USubscription client + // that fails to unsubscribe from topics + when(subscriptionClient.unsubscribe(any(UnsubscribeRequest.class))) + .thenReturn(CompletableFuture.failedFuture( + new UStatusException(UCode.UNAVAILABLE, "unknown"))); + + // AND a transport + // which succeeds to unregister listeners + // (no need to stub explicitly, because this is the default) + + // AND a Subscriber using that USubscription client, Notifier and transport + var subscriber = new InMemorySubscriber(transport, subscriptionClient, notifier); + verify(notifier).registerNotificationListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + notificationListener.capture()); + // which already has a listener and corresponding subscription change handler + // registered for a topic + subscriber.addSubscriptionChangeHandler(TOPIC, Optional.of(subscriptionChangeHandler)); + assertTrue(subscriber.hasSubscriptionChangeHandler(TOPIC)); + + // WHEN unsubscribing from the topic + var attempt = subscriber.unsubscribe(TOPIC, listener); + + // THEN the the attempt fails + var exception = assertThrows( + CompletionException.class, + () -> attempt.toCompletableFuture().join()); + assertEquals(UCode.UNAVAILABLE, ((UStatusException) exception.getCause()).getCode()); + verify(subscriptionClient).unsubscribe(argThat(req -> req.getTopic().equals(TOPIC))); + verify(transport, never()).unregisterListener(TOPIC, listener); + // AND the subscription change handler is still registered + assertTrue(subscriber.hasSubscriptionChangeHandler(TOPIC)); + } + + @Test + void testUnsubscribeSucceeds() { + // GIVEN a USubscription client + // that succeeds to unsubscribe from topics + when(subscriptionClient.unsubscribe(any(UnsubscribeRequest.class))) + .thenReturn(CompletableFuture.completedFuture(UnsubscribeResponse.newBuilder().build())); + + // and a transport + // which succeeds to unregister listeners + // (no need to stub explicitly, because this is the default) + + // AND a Subscriber using that USubscription client, Notifier and transport + var subscriber = new InMemorySubscriber(transport, subscriptionClient, notifier); + verify(notifier).registerNotificationListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + notificationListener.capture()); + // which already has a listener and corresponding subscription change handler + // registered for a topic + subscriber.addSubscriptionChangeHandler(TOPIC, Optional.of(subscriptionChangeHandler)); + assertTrue(subscriber.hasSubscriptionChangeHandler(TOPIC)); + + // WHEN unsubscribing from the topic + var attempt = subscriber.unsubscribe(TOPIC, listener); + + // THEN the the attempt succeeds + attempt.toCompletableFuture().join(); + verify(subscriptionClient).unsubscribe(argThat(req -> req.getTopic().equals(TOPIC))); + verify(transport).unregisterListener(TOPIC, listener); + // AND the subscription change handler is no longer registered + assertFalse(subscriber.hasSubscriptionChangeHandler(TOPIC)); + } + + @Test + void testUnsubscribeSucceedsOnSecondAttempt() { + // GIVEN a USubscription client + // that succeeds to unsubscribe from topics + when(subscriptionClient.unsubscribe(any(UnsubscribeRequest.class))) + .thenReturn(CompletableFuture.completedFuture(UnsubscribeResponse.newBuilder().build())); + + // and a transport + when(transport.unregisterListener(any(UUri.class), any(UListener.class))) + // that first fails to unregister a listener + .thenReturn(CompletableFuture.failedFuture( + new UStatusException(UCode.UNAVAILABLE, "not connected"))) + // but succeeds on the second attempt + .thenReturn(CompletableFuture.completedFuture(null)); + + // AND a Subscriber using that USubscription client, Notifier and transport + var subscriber = new InMemorySubscriber(transport, subscriptionClient, notifier); + verify(notifier).registerNotificationListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + notificationListener.capture()); + // which already has a listener and corresponding subscription change handler + // registered for a topic + subscriber.addSubscriptionChangeHandler(TOPIC, Optional.of(subscriptionChangeHandler)); + assertTrue(subscriber.hasSubscriptionChangeHandler(TOPIC)); + + // WHEN unsubscribing from a topic for which a listener had been registered before + var attempt = subscriber.unsubscribe(TOPIC, listener); + + // THEN the first attempt fails + var exception = assertThrows( + CompletionException.class, + () -> attempt.toCompletableFuture().join()); + assertEquals(UCode.UNAVAILABLE, ((UStatusException) exception.getCause()).getCode()); + + // but the second attempt succeeds + subscriber.unsubscribe(TOPIC, listener).toCompletableFuture().join(); + // AND the handler has been removed + assertFalse(subscriber.hasSubscriptionChangeHandler(TOPIC)); + + verify(subscriptionClient, times(2)).unsubscribe(argThat(req -> req.getTopic().equals(TOPIC))); + verify(transport, times(2)).unregisterListener(TOPIC, listener); + } + + @Test + void testHandleSubscriptionChangeNotificationHandlesInvalidMessages() { + // GIVEN a Subscriber + var subscriber = new InMemorySubscriber(transport, subscriptionClient, notifier); + verify(notifier).registerNotificationListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + notificationListener.capture()); + + @SuppressWarnings("unchecked") + Consumer unexpectedMessageHandler = mock(Consumer.class); + subscriber.setUnexpectedMessageHandler(unexpectedMessageHandler); + + // WHEN a non-notification message is received + var malformedPublishMessage = UMessage.newBuilder() + .setAttributes(UAttributes.newBuilder() + .setId(UuidFactory.Factories.UPROTOCOL.factory().create()) + .setType(UMessageType.UMESSAGE_TYPE_PUBLISH) + .setSource(SUBSCRIPTION_NOTIFICATION_TOPIC_URI) + .setSink(SOURCE) + .build()) + .build(); + notificationListener.getValue().onReceive(malformedPublishMessage); + + // THEN the unexpected message handler is invoked + verify(unexpectedMessageHandler).accept(malformedPublishMessage); + + // AND when a notification message is received for which no + // handler had been registered + var subscriptionChange = Update.newBuilder().setTopic(TOPIC).build(); + var notificationMessage = UMessageBuilder.notification( + SUBSCRIPTION_NOTIFICATION_TOPIC_URI, + SOURCE) + .build(UPayload.pack(subscriptionChange)); + + notificationListener.getValue().onReceive(notificationMessage); + + // THEN the unexpected message handler is invoked + verify(unexpectedMessageHandler).accept(notificationMessage); + } + + @Test + void testHandleSubscriptionChangeNotificationIgnoresErroneousHandler() { + // GIVEN a Subscriber + var subscriber = new InMemorySubscriber(transport, subscriptionClient, notifier); + verify(notifier).registerNotificationListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + notificationListener.capture()); + + // with a subscription change handler registered for a topic + subscriber.addSubscriptionChangeHandler(TOPIC, Optional.of(subscriptionChangeHandler)); + + // WHEN the handler for an incoming subscription update notification throws an exception + doThrow(new RuntimeException("boom")) + .when(subscriptionChangeHandler).handleSubscriptionChange( + eq(TOPIC), any(SubscriptionStatus.class)); + var subscriptionChange = Update.newBuilder().setTopic(TOPIC).build(); + var notificationMessage = UMessageBuilder.notification( + SUBSCRIPTION_NOTIFICATION_TOPIC_URI, + SOURCE) + .build(UPayload.pack(subscriptionChange)); + + // THEN the exception is caught and ignored + assertDoesNotThrow(() -> notificationListener.getValue().onReceive(notificationMessage)); + } + + @Test + void testRegisterSubscriptionChangeHandlerSucceeds() { + // GIVEN a USubscription client that succeeds to register for notifications + when(subscriptionClient.registerForNotifications(any(NotificationsRequest.class))) + .thenReturn(CompletableFuture.completedFuture(NotificationsResponse.getDefaultInstance())); + // AND a subscriber using the client + var subscriber = new InMemorySubscriber(transport, subscriptionClient, notifier); + verify(notifier).registerNotificationListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + notificationListener.capture()); + + // WHEN a subscription change handler is registered for a topic + subscriber.registerSubscriptionChangeHandler(TOPIC, subscriptionChangeHandler) + .toCompletableFuture().join(); + verify(subscriptionClient).registerForNotifications(argThat(req -> req.getTopic().equals(TOPIC))); + + // THEN the handler is getting invoked whenever a subscription change + // for the topic of interest is received + var subscriptionChange = Update.newBuilder() + .setTopic(TOPIC) + .setStatus(SubscriptionStatus.newBuilder().setState(State.UNSUBSCRIBED)) + .build(); + var notificationMessage = UMessageBuilder.notification( + SUBSCRIPTION_NOTIFICATION_TOPIC_URI, + SOURCE) + .build(UPayload.pack(subscriptionChange)); + notificationListener.getValue().onReceive(notificationMessage); + verify(subscriptionChangeHandler).handleSubscriptionChange(TOPIC, subscriptionChange.getStatus()); + } + + @Test + void testUnregisterSubscriptionChangeHandlerSucceeds() { + // GIVEN a USubscription client that succeeds to unregister for notifications + when(subscriptionClient.unregisterForNotifications(any(NotificationsRequest.class))) + .thenReturn(CompletableFuture.completedFuture(NotificationsResponse.getDefaultInstance())); + // AND a subscriber using the client + var subscriber = new InMemorySubscriber(transport, subscriptionClient, notifier); + verify(notifier).registerNotificationListener( + eq(SUBSCRIPTION_NOTIFICATION_TOPIC_URI), + notificationListener.capture()); + // that has a handler registered for subscription changes + subscriber.addSubscriptionChangeHandler(TOPIC, Optional.of(subscriptionChangeHandler)); + + // WHEN the handler is getting unregistered + subscriber.unregisterSubscriptionChangeHandler(TOPIC) + .toCompletableFuture().join(); + verify(subscriptionClient).unregisterForNotifications(argThat(req -> req.getTopic().equals(TOPIC))); + + // THEN the handler is no longer getting invoked when a subscription change + // for the topic of interest is received + var subscriptionChange = Update.newBuilder() + .setTopic(TOPIC) + .setStatus(SubscriptionStatus.newBuilder().setState(State.UNSUBSCRIBED)) + .build(); + var notificationMessage = UMessageBuilder.notification( + SUBSCRIPTION_NOTIFICATION_TOPIC_URI, + SOURCE) + .build(UPayload.pack(subscriptionChange)); + notificationListener.getValue().onReceive(notificationMessage); + verify(subscriptionChangeHandler, never()).handleSubscriptionChange(eq(TOPIC), any(SubscriptionStatus.class)); + } +} diff --git a/src/test/java/org/eclipse/uprotocol/communication/RpcMapperTest.java b/src/test/java/org/eclipse/uprotocol/communication/RpcMapperTest.java index 69556600..a00e77e0 100644 --- a/src/test/java/org/eclipse/uprotocol/communication/RpcMapperTest.java +++ b/src/test/java/org/eclipse/uprotocol/communication/RpcMapperTest.java @@ -18,7 +18,7 @@ public class RpcMapperTest { - + @Test @DisplayName("Test RpcMapper mapResponse using RpcClient interface invokeMethod API") public void testMapResponse() { diff --git a/src/test/java/org/eclipse/uprotocol/communication/SimpleNotifierTest.java b/src/test/java/org/eclipse/uprotocol/communication/SimpleNotifierTest.java index d6d3dd96..640db6d4 100644 --- a/src/test/java/org/eclipse/uprotocol/communication/SimpleNotifierTest.java +++ b/src/test/java/org/eclipse/uprotocol/communication/SimpleNotifierTest.java @@ -12,107 +12,119 @@ */ package org.eclipse.uprotocol.communication; +import static org.junit.Assert.assertThrows; 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 java.util.concurrent.CompletionStage; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import org.eclipse.uprotocol.transport.UListener; import org.eclipse.uprotocol.v1.UCode; import org.eclipse.uprotocol.v1.UMessage; -import org.eclipse.uprotocol.v1.UStatus; import org.eclipse.uprotocol.v1.UUri; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -public class SimpleNotifierTest { +class SimpleNotifierTest extends CommunicationLayerClientTestBase { + + private Notifier notifier; + private CallOptions options; + + @BeforeEach + void createNotifier() { + notifier = new SimpleNotifier(transport, uriProvider); + options = CallOptions.DEFAULT; + } + + private void assertNotificationAttributes(UMessage message) { + assertEquals(TOPIC_URI, message.getAttributes().getSource()); + assertEquals(DESTINATION_URI, message.getAttributes().getSink()); + assertEquals(options.priority(), message.getAttributes().getPriority()); + assertEquals(options.timeout(), message.getAttributes().getTtl()); + assertFalse(message.getAttributes().hasToken()); + } + @Test @DisplayName("Test sending a simple notification") - public void testSendNotification() { - Notifier notifier = new SimpleNotifier(new TestUTransport()); - CompletionStage result = notifier.notify(createTopic(), createDestinationUri()); - assertEquals(result.toCompletableFuture().join().getCode(), UCode.OK); + void testSendNotification() { + notifier.notify(TOPIC_URI.getResourceId(), DESTINATION_URI).toCompletableFuture().join(); + verify(transport).send(requestMessage.capture()); + assertNotificationAttributes(requestMessage.getValue()); } @Test @DisplayName("Test sending a simple notification passing CallOptions") - public void testSendNotificationWithOptions() { - Notifier notifier = new SimpleNotifier(new TestUTransport()); - CompletionStage result = notifier.notify(createTopic(), createDestinationUri(), CallOptions.DEFAULT); - assertEquals(result.toCompletableFuture().join().getCode(), UCode.OK); + void testSendNotificationWithOptions() { + notifier.notify(TOPIC_URI.getResourceId(), DESTINATION_URI, options).toCompletableFuture().join(); + verify(transport).send(requestMessage.capture()); + assertNotificationAttributes(requestMessage.getValue()); } - @Test @DisplayName("Test sending a simple notification passing a google.protobuf.Message payload") - public void testSendNotificationWithPayload() { - UUri uri = UUri.newBuilder().setAuthorityName("Hartley").build(); - Notifier notifier = new SimpleNotifier(new TestUTransport()); - CompletionStage result = notifier.notify(createTopic(), createDestinationUri(), - UPayload.pack(uri)); - assertEquals(result.toCompletableFuture().join().getCode(), UCode.OK); + void testSendNotificationWithPayload() { + final var payload = UPayload.pack(UUri.newBuilder().setAuthorityName("Hartley").build()); + notifier.notify(TOPIC_URI.getResourceId(), DESTINATION_URI, payload).toCompletableFuture().join(); + verify(transport).send(requestMessage.capture()); + assertNotificationAttributes(requestMessage.getValue()); + assertEquals(payload.data(), requestMessage.getValue().getPayload()); } @Test @DisplayName("Test sending a simple notification passing a google.protobuf.Any payload and CallOptions") - public void testSendNotificationWithAnyPayloadAndOptions() { - UUri uri = UUri.newBuilder().setAuthorityName("Hartley").build(); - Notifier notifier = new SimpleNotifier(new TestUTransport()); - CompletionStage result = notifier.notify(createTopic(), createDestinationUri(), - CallOptions.DEFAULT, UPayload.packToAny(uri)); - assertEquals(result.toCompletableFuture().join().getCode(), UCode.OK); + void testSendNotificationWithAnyPayloadAndOptions() { + final var payload = UPayload.pack(UUri.newBuilder().setAuthorityName("Hartley").build()); + notifier.notify(TOPIC_URI.getResourceId(), DESTINATION_URI, options, payload).toCompletableFuture().join(); + verify(transport).send(requestMessage.capture()); + assertNotificationAttributes(requestMessage.getValue()); + assertEquals(payload.data(), requestMessage.getValue().getPayload()); } + @Test + void testSendNotificationWithInvalidTopic() { + var exception = assertThrows( + CompletionException.class, + () -> + notifier.notify(0x5000, TOPIC_URI).toCompletableFuture().join() + ); + assertEquals(UCode.INVALID_ARGUMENT, ((UStatusException) exception.getCause()).getCode()); + } @Test @DisplayName("Test registering and unregistering a listener for a notification topic") - public void testRegisterListener() { - UListener listener = new UListener() { - @Override - public void onReceive(UMessage message) { - assertNotNull(message); - } - }; - - Notifier notifier = new SimpleNotifier(new TestUTransport()); - CompletionStage result = notifier.registerNotificationListener(createTopic(), listener); - assertEquals(result.toCompletableFuture().join().getCode(), UCode.OK); - - result = notifier.unregisterNotificationListener(createTopic(), listener); - assertEquals(result.toCompletableFuture().join().getCode(), UCode.OK); + void testRegisterListener() { + final var listener = mock(UListener.class); + notifier.registerNotificationListener(TOPIC_URI, listener).toCompletableFuture().join(); + verify(transport).registerListener( + TOPIC_URI, + TRANSPORT_SOURCE, + listener); + notifier.unregisterNotificationListener(TOPIC_URI, listener).toCompletableFuture().join(); + verify(transport).unregisterListener( + TOPIC_URI, + TRANSPORT_SOURCE, + listener); } - @Test @DisplayName("Test unregistering a listener that was not registered") - public void testUnregisterListenerNotRegistered() { - UListener listener = new UListener() { - @Override - public void onReceive(UMessage message) { - assertNotNull(message); - } - }; - Notifier notifier = new SimpleNotifier(new TestUTransport()); - CompletionStage result = notifier.unregisterNotificationListener(createTopic(), listener); - assertFalse(result.toCompletableFuture().isCompletedExceptionally()); - assertEquals(result.toCompletableFuture().join().getCode(), UCode.NOT_FOUND); - } - - - private UUri createTopic() { - return UUri.newBuilder() - .setAuthorityName("hartley") - .setUeId(3) - .setUeVersionMajor(1) - .setResourceId(0x8000) - .build(); - } - - - private UUri createDestinationUri() { - return UUri.newBuilder() - .setUeId(4) - .setUeVersionMajor(1) - .build(); + void testUnregisterListenerNotRegistered() { + final var listener = mock(UListener.class); + when(transport.unregisterListener(TOPIC_URI, TRANSPORT_SOURCE, listener)) + .thenReturn(CompletableFuture.failedFuture( + new UStatusException(UCode.NOT_FOUND, "no such listener"))); + final var exception = assertThrows(CompletionException.class, () -> { + notifier.unregisterNotificationListener(TOPIC_URI, listener).toCompletableFuture().join(); + }); + verify(transport).unregisterListener( + TOPIC_URI, + TRANSPORT_SOURCE, + listener); + assertEquals(UCode.NOT_FOUND, ((UStatusException) exception.getCause()).getCode()); } } diff --git a/src/test/java/org/eclipse/uprotocol/communication/SimplePublisherTest.java b/src/test/java/org/eclipse/uprotocol/communication/SimplePublisherTest.java index 5aa03b0b..fddb0bf6 100644 --- a/src/test/java/org/eclipse/uprotocol/communication/SimplePublisherTest.java +++ b/src/test/java/org/eclipse/uprotocol/communication/SimplePublisherTest.java @@ -13,58 +13,82 @@ */ package org.eclipse.uprotocol.communication; +import static org.junit.Assert.assertThrows; import static org.junit.jupiter.api.Assertions.assertEquals; -import java.util.concurrent.CompletionStage; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.CompletionException; import org.eclipse.uprotocol.v1.UCode; -import org.eclipse.uprotocol.v1.UStatus; +import org.eclipse.uprotocol.v1.UMessage; import org.eclipse.uprotocol.v1.UUri; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -public class SimplePublisherTest { +class SimplePublisherTest extends CommunicationLayerClientTestBase { + private Publisher publisher; + private CallOptions options; + + @BeforeEach + void createPublisher() { + publisher = new SimplePublisher(transport, uriProvider); + options = CallOptions.DEFAULT; + } + + private void assertEventAttributes(UMessage message) { + assertEquals(TOPIC_URI, message.getAttributes().getSource()); + assertFalse(message.getAttributes().hasSink()); + assertEquals(options.priority(), message.getAttributes().getPriority()); + assertEquals(options.timeout(), message.getAttributes().getTtl()); + assertFalse(message.getAttributes().hasToken()); + } + @Test @DisplayName("Test sending a simple publish message without a payload") - public void testSendPublish() { - Publisher publisher = new SimplePublisher(new TestUTransport()); - CompletionStage result = publisher.publish(createTopic()); - assertEquals(result.toCompletableFuture().join().getCode(), UCode.OK); + void testSendPublish() { + publisher.publish(TOPIC_URI.getResourceId()).toCompletableFuture().join(); + verify(transport).send(requestMessage.capture()); + assertEventAttributes(requestMessage.getValue()); } @Test @DisplayName("Test sending a simple publish message with CallOptions and no payload") - public void testSendPublishWithOptions() { - Publisher publisher = new SimplePublisher(new TestUTransport()); - CompletionStage result = publisher.publish(createTopic(), CallOptions.DEFAULT); - assertEquals(result.toCompletableFuture().join().getCode(), UCode.OK); + void testSendPublishWithOptions() { + publisher.publish(TOPIC_URI.getResourceId(), options).toCompletableFuture().join(); + verify(transport).send(requestMessage.capture()); + assertEventAttributes(requestMessage.getValue()); } @Test @DisplayName("Test sending a simple publish message with a stuffed UPayload that was build with packToAny()") - public void testSendPublishWithStuffedPayload() { - UUri uri = UUri.newBuilder().setAuthorityName("Hartley").build(); - Publisher publisher = new SimplePublisher(new TestUTransport()); - CompletionStage result = publisher.publish(createTopic(), UPayload.packToAny(uri)); - assertEquals(result.toCompletableFuture().join().getCode(), UCode.OK); + void testSendPublishWithStuffedPayload() { + final var payload = UPayload.pack(UUri.newBuilder().setAuthorityName("Hartley").build()); + publisher.publish(TOPIC_URI.getResourceId(), payload).toCompletableFuture().join(); + verify(transport).send(requestMessage.capture()); + assertEventAttributes(requestMessage.getValue()); + assertEquals(payload.data(), requestMessage.getValue().getPayload()); } @Test @DisplayName("Test sending a simple publish message with CallOptions and a stuffed UPayload") - public void testSendPublishWithPayloadAndOptions() { - UUri uri = UUri.newBuilder().setAuthorityName("Hartley").build(); - Publisher publisher = new SimplePublisher(new TestUTransport()); - CompletionStage result = publisher.publish(createTopic(), CallOptions.DEFAULT, UPayload.pack(uri)); - assertEquals(result.toCompletableFuture().join().getCode(), UCode.OK); + void testSendPublishWithPayloadAndOptions() { + final var payload = UPayload.pack(UUri.newBuilder().setAuthorityName("Hartley").build()); + publisher.publish(TOPIC_URI.getResourceId(), options, payload).toCompletableFuture().join(); + verify(transport).send(requestMessage.capture()); + assertEventAttributes(requestMessage.getValue()); + assertEquals(payload.data(), requestMessage.getValue().getPayload()); } - - private UUri createTopic() { - return UUri.newBuilder() - .setAuthorityName("hartley") - .setUeId(3) - .setUeVersionMajor(1) - .setResourceId(0x8000) - .build(); + + @Test + void testSendingPublishWithInvalidTopic() { + var exception = assertThrows( + CompletionException.class, + () -> + publisher.publish(0x5000).toCompletableFuture().join() + ); + assertEquals(UCode.INVALID_ARGUMENT, ((UStatusException) exception.getCause()).getCode()); } } - diff --git a/src/test/java/org/eclipse/uprotocol/communication/TestUTransport.java b/src/test/java/org/eclipse/uprotocol/communication/TestUTransport.java deleted file mode 100644 index 410c8c90..00000000 --- a/src/test/java/org/eclipse/uprotocol/communication/TestUTransport.java +++ /dev/null @@ -1,230 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Apache License Version 2.0 which is available at - * https://www.apache.org/licenses/LICENSE-2.0 - * - * SPDX-License-Identifier: Apache-2.0 - */ -package org.eclipse.uprotocol.communication; - -import java.util.Iterator; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.Executors; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.CompletableFuture; - -import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionRequest; -import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionResponse; -import org.eclipse.uprotocol.core.usubscription.v3.SubscriptionStatus; -import org.eclipse.uprotocol.core.usubscription.v3.UnsubscribeResponse; -import org.eclipse.uprotocol.transport.UListener; -import org.eclipse.uprotocol.transport.UTransport; -import org.eclipse.uprotocol.transport.builder.UMessageBuilder; -import org.eclipse.uprotocol.transport.validate.UAttributesValidator; -import org.eclipse.uprotocol.v1.UCode; -import org.eclipse.uprotocol.v1.UMessage; -import org.eclipse.uprotocol.v1.UMessageType; -import org.eclipse.uprotocol.v1.UStatus; -import org.eclipse.uprotocol.v1.UUri; -import org.eclipse.uprotocol.validation.ValidationException; - -import com.google.protobuf.InvalidProtocolBufferException; - -/** - * TestUTransport is a test implementation of the UTransport interface - * that can only hold a single listener for testing. - */ -public class TestUTransport implements UTransport { - - private List listeners = new CopyOnWriteArrayList<>(); - private final UUri mSource; - - protected List getListeners() { - return listeners; - } - - public UMessage buildResponse(UMessage request) { - // If the request is a subscribe or unsubscribe request, return the appropriate response - if (request.getAttributes().getSink().getUeId() == 0) { - if (request.getAttributes().getSink().getResourceId() == 1) { - try { - SubscriptionRequest subscriptionRequest = SubscriptionRequest.parseFrom(request.getPayload()); - SubscriptionResponse subResponse = SubscriptionResponse.newBuilder() - .setTopic(subscriptionRequest.getTopic()) - .setStatus(SubscriptionStatus.newBuilder() - .setState(SubscriptionStatus.State.SUBSCRIBED).build()) - .build(); - return UMessageBuilder.response(request.getAttributes()).build(UPayload.pack(subResponse)); - } catch (InvalidProtocolBufferException e) { - return UMessageBuilder.response(request.getAttributes()).build( - UPayload.pack(UnsubscribeResponse.newBuilder().build())); - } - } else { - return UMessageBuilder.response(request.getAttributes()).build( - UPayload.pack(UnsubscribeResponse.newBuilder().build())); - } - } - return UMessageBuilder.response(request.getAttributes()) - .build(UPayload.pack(request.getPayload(), request.getAttributes().getPayloadFormat())); - } - - public TestUTransport() { - this(UUri.newBuilder() - .setAuthorityName("Hartley").setUeId(4).setUeVersionMajor(1).build()); - } - - public TestUTransport(UUri source) { - mSource = source; - } - - @Override - public CompletionStage send(UMessage message) { - if (message == null) { - return CompletableFuture.completedFuture(UStatus.newBuilder() - .setCode(UCode.INVALID_ARGUMENT) - .setMessage("Message cannot be null") - .build()); - } - UAttributesValidator validator = UAttributesValidator.getValidator(message.getAttributes()); - try { - validator.validate(message.getAttributes()); - } catch (ValidationException e) { - return CompletableFuture.completedFuture(UStatus.newBuilder() - .setCode(UCode.INVALID_ARGUMENT) - .setMessage("Invalid message attributes") - .build()); - } - - if (message.getAttributes().getType() == UMessageType.UMESSAGE_TYPE_REQUEST) { - UMessage response = buildResponse(message); - Executors.newSingleThreadExecutor().execute(new Runnable() { - @Override - public void run() { - for (Iterator it = listeners.iterator(); it.hasNext();) { - UListener listener = it.next(); - listener.onReceive(response); - } - } - }); - } - - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - } - - /* - * Register a listener based on the source and sink URIs. - */ - @Override - public CompletionStage registerListener(UUri source, UUri sink, UListener listener) { - listeners.add(listener); - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - } - - @Override - public CompletionStage unregisterListener(UUri source, UUri sink, UListener listener) { - final UStatus result = UStatus.newBuilder().setCode(listeners.contains(listener) ? - UCode.OK : UCode.NOT_FOUND).build(); - if (listeners.contains(listener)) { - listeners.remove(listener); - } - return CompletableFuture.completedFuture(result); - } - - @Override - public UUri getSource() { - return mSource; - } - - @Override - public void close() { - listeners.clear(); - } -} - - -/** - * Timeout uTransport simply does not send a reply - */ -class TimeoutUTransport extends TestUTransport { - @Override - public CompletionStage send(UMessage message) { - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - } -}; - -class ErrorUTransport extends TestUTransport { - @Override - public CompletionStage send(UMessage message) { - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.FAILED_PRECONDITION).build()); - } - - @Override - public CompletionStage registerListener(UUri source, UUri sink, UListener listener) { - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.FAILED_PRECONDITION).build()); - } - - @Override - public CompletionStage unregisterListener(UUri source, UUri sink, UListener listener) { - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.FAILED_PRECONDITION).build()); - } -}; - -/** - * Test UTransport that will set the commstatus for an error - */ -class CommStatusTransport extends TestUTransport { - @Override - public UMessage buildResponse(UMessage request) { - UStatus status = UStatus.newBuilder() - .setCode(UCode.FAILED_PRECONDITION) - .setMessage("CommStatus Error") - .build(); - return UMessageBuilder.response(request.getAttributes()) - .withCommStatus(status.getCode()) - .build(UPayload.pack(status)); - } -}; - -/** - * Test UTransport that will set the commstatus for a success response - */ -class CommStatusOkTransport extends TestUTransport { - @Override - public UMessage buildResponse(UMessage request) { - UStatus status = UStatus.newBuilder() - .setCode(UCode.OK) - .setMessage("No Communication Error") - .build(); - return UMessageBuilder.response(request.getAttributes()) - .withCommStatus(status.getCode()) - .build(UPayload.pack(status)); - } -} - -class EchoUTransport extends TestUTransport { - @Override - public UMessage buildResponse(UMessage request) { - return request; - } - - @Override - public CompletionStage send(UMessage message) { - UMessage response = buildResponse(message); - Executors.newSingleThreadExecutor().execute(new Runnable() { - @Override - public void run() { - for (Iterator it = getListeners().iterator(); it.hasNext();) { - UListener listener = it.next(); - listener.onReceive(response); - } - } - }); - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - } -}; diff --git a/src/test/java/org/eclipse/uprotocol/communication/UClientTest.java b/src/test/java/org/eclipse/uprotocol/communication/UClientTest.java index cd31def3..28c59418 100644 --- a/src/test/java/org/eclipse/uprotocol/communication/UClientTest.java +++ b/src/test/java/org/eclipse/uprotocol/communication/UClientTest.java @@ -12,81 +12,131 @@ */ package org.eclipse.uprotocol.communication; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.uprotocol.transport.StaticUriProvider; import org.eclipse.uprotocol.transport.UListener; -import org.eclipse.uprotocol.v1.UMessage; +import org.eclipse.uprotocol.transport.UTransport; +import org.eclipse.uprotocol.uri.factory.UriFactory; import org.eclipse.uprotocol.v1.UUri; -import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -public class UClientTest { - - // Main functionality is tested in the various individual implementations - @Test - @DisplayName("Test happy path for all APIs") - public void test() { - UClient client = UClient.create(new TestUTransport()); - UListener listener = new UListener() { - @Override - public void onReceive(UMessage message) { - assertNotNull(message); - } - }; - - assertDoesNotThrow(() -> - client.notify(createTopic(), createDestinationUri()).toCompletableFuture().get()); - - assertDoesNotThrow(() -> - client.publish(createTopic()).toCompletableFuture().get()); - - assertDoesNotThrow(() -> - client.invokeMethod(createMethodUri(), null, null).toCompletableFuture().get()); - - assertDoesNotThrow(() -> - client.registerNotificationListener(createTopic(), listener).toCompletableFuture().get()); - - assertDoesNotThrow(() -> - client.unregisterNotificationListener(createTopic(), listener).toCompletableFuture().get()); - - RequestHandler handler = mock(RequestHandler.class); - - assertDoesNotThrow(() -> - client.registerRequestHandler(createMethodUri(), handler).toCompletableFuture().get()); - - assertDoesNotThrow(() -> - client.unregisterRequestHandler(createMethodUri(), handler).toCompletableFuture().get()); +class UClientTest { + private static final UUri TRANSPORT_SOURCE = UUri.newBuilder() + .setAuthorityName("my-vehicle") + .setUeId(0xa1) + .setUeVersionMajor(0x01) + .setResourceId(0x0000) + .build(); + private static final UUri TOPIC_URI = UUri.newBuilder(TRANSPORT_SOURCE) + .setResourceId(0x8000) + .build(); + private static final UUri DESTINATION_URI = UUri.newBuilder() + .setAuthorityName("other-vehicle") + .setUeId(0x2bbbb) + .setUeVersionMajor(0x02) + .build(); + private static final UUri METHOD_URI = UUri.newBuilder(DESTINATION_URI) + .setResourceId(3) + .build(); - client.close(); + private RpcClient rpcClient; + private RpcServer rpcServer; + private Publisher publisher; + private Notifier notifier; + + @BeforeEach + void setUp() { + rpcClient = mock(RpcClient.class); + rpcServer = mock(RpcServer.class); + publisher = mock(Publisher.class); + notifier = mock(Notifier.class); } - - - private UUri createTopic() { - return UUri.newBuilder() - .setAuthorityName("Hartley") - .setUeId(4) - .setUeVersionMajor(1) - .setResourceId(0x8000) - .build(); + @Test + void testFactoryMethod() { + var transport = mock(UTransport.class); + when(transport.registerListener(any(UUri.class), any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + var uriProvider = StaticUriProvider.of(TRANSPORT_SOURCE); + UClient.create(transport, uriProvider); + verify(transport).registerListener(any(UUri.class), eq(TRANSPORT_SOURCE), any(UListener.class)); } + @Test + void testPublisher() { + when(publisher.publish(anyInt(), any(CallOptions.class), any(UPayload.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + var client = new UClient(rpcClient, rpcServer, publisher, notifier); + client.publish(TOPIC_URI.getResourceId()).toCompletableFuture().join(); + verify(publisher).publish(eq(TOPIC_URI.getResourceId()), any(CallOptions.class), any(UPayload.class)); + } - private UUri createDestinationUri() { - return UUri.newBuilder() - .setUeId(4) - .setUeVersionMajor(1) - .build(); + @Test + void testNotifier() { + when(notifier.notify(anyInt(), any(UUri.class), any(CallOptions.class), any(UPayload.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + var client = new UClient(rpcClient, rpcServer, publisher, notifier); + client.notify(TOPIC_URI.getResourceId(), DESTINATION_URI).toCompletableFuture().join(); + verify(notifier).notify( + eq(TOPIC_URI.getResourceId()), + eq(DESTINATION_URI), + any(CallOptions.class), + any(UPayload.class)); + + when(notifier.registerNotificationListener(any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + when(notifier.unregisterNotificationListener(any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + final var listener = mock(UListener.class); + client = new UClient(rpcClient, rpcServer, publisher, notifier); + client.registerNotificationListener(TOPIC_URI, listener).toCompletableFuture().join(); + verify(notifier).registerNotificationListener(TOPIC_URI, listener); + client.unregisterNotificationListener(TOPIC_URI, listener).toCompletableFuture().join(); + verify(notifier).unregisterNotificationListener(TOPIC_URI, listener); } + @Test + void testRpcClient() { + when(rpcClient.invokeMethod(any(UUri.class), any(UPayload.class), any(CallOptions.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + var client = new UClient(rpcClient, rpcServer, publisher, notifier); + client.invokeMethod(METHOD_URI, UPayload.EMPTY, CallOptions.DEFAULT).toCompletableFuture().join(); + verify(rpcClient).invokeMethod(eq(METHOD_URI), eq(UPayload.EMPTY), eq(CallOptions.DEFAULT)); + } - private UUri createMethodUri() { - return UUri.newBuilder() - .setAuthorityName("Hartley") - .setUeId(4) - .setUeVersionMajor(1) - .setResourceId(3).build(); + @Test + void testRpcServer() { + when(rpcServer.registerRequestHandler(any(UUri.class), anyInt(), any(RequestHandler.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + when(rpcServer.unregisterRequestHandler(any(UUri.class), anyInt(), any(RequestHandler.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + final var handler = mock(RequestHandler.class); + var client = new UClient(rpcClient, rpcServer, publisher, notifier); + client.registerRequestHandler( + UriFactory.ANY, + METHOD_URI.getResourceId(), + handler).toCompletableFuture().join(); + verify(rpcServer).registerRequestHandler( + eq(UriFactory.ANY), + eq(METHOD_URI.getResourceId()), + eq(handler)); + + client.unregisterRequestHandler( + UriFactory.ANY, + METHOD_URI.getResourceId(), + handler).toCompletableFuture().join(); + verify(rpcServer).unregisterRequestHandler( + eq(UriFactory.ANY), + eq(METHOD_URI.getResourceId()), + eq(handler)); } } diff --git a/src/test/java/org/eclipse/uprotocol/communication/UPayloadTest.java b/src/test/java/org/eclipse/uprotocol/communication/UPayloadTest.java index 4850a468..ef5d8303 100644 --- a/src/test/java/org/eclipse/uprotocol/communication/UPayloadTest.java +++ b/src/test/java/org/eclipse/uprotocol/communication/UPayloadTest.java @@ -13,9 +13,13 @@ package org.eclipse.uprotocol.communication; import java.util.Optional; +import java.util.stream.Stream; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; 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.assertTrue; import org.eclipse.uprotocol.v1.UMessage; @@ -23,6 +27,14 @@ import org.eclipse.uprotocol.v1.UUri; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import com.google.protobuf.Message; +import com.google.protobuf.StringValue; public class UPayloadTest { @@ -106,6 +118,70 @@ public void testUnpackToUnpackAMessageOfTheWrongType() { assertEquals(unpacked, Optional.empty()); } + static Stream unpackOrDefaultInstanceArgsProvider() { + var stringValue = StringValue.of("hello"); + var protobuf = stringValue.toByteString(); + + // Claims to have a string of length 20 but only provides 4 bytes + var invalidStringValueProtobuf = ByteString.fromHex("0A1441424344"); + + return Stream.of( + // ByteString, UPayloadFormat, Class, Exception + Arguments.of(null, null, null, NullPointerException.class), + Arguments.of(null, UPayloadFormat.UPAYLOAD_FORMAT_PROTOBUF, StringValue.class, NullPointerException.class), + Arguments.of(protobuf, null, StringValue.class, NullPointerException.class), + Arguments.of(protobuf, UPayloadFormat.UPAYLOAD_FORMAT_PROTOBUF, null, NullPointerException.class), + Arguments.of(protobuf, UPayloadFormat.UPAYLOAD_FORMAT_JSON, StringValue.class, UStatusException.class), + Arguments.of(protobuf, UPayloadFormat.UPAYLOAD_FORMAT_RAW, StringValue.class, UStatusException.class), + Arguments.of(protobuf, UPayloadFormat.UPAYLOAD_FORMAT_SHM, StringValue.class, UStatusException.class), + Arguments.of(protobuf, UPayloadFormat.UPAYLOAD_FORMAT_SOMEIP, StringValue.class, UStatusException.class), + Arguments.of( + protobuf, + UPayloadFormat.UPAYLOAD_FORMAT_SOMEIP_TLV, + StringValue.class, + UStatusException.class), + Arguments.of(ByteString.EMPTY, UPayloadFormat.UPAYLOAD_FORMAT_PROTOBUF, StringValue.class, null), + Arguments.of(protobuf, UPayloadFormat.UPAYLOAD_FORMAT_PROTOBUF, StringValue.class, null), + // unpacking a protobuf that does not match the expected type fails + Arguments.of( + invalidStringValueProtobuf, + UPayloadFormat.UPAYLOAD_FORMAT_PROTOBUF, + StringValue.class, + UStatusException.class), + // unpacking a protobuf to a different type succeeds because of missing type information + // from the protobuf + Arguments.of(protobuf, UPayloadFormat.UPAYLOAD_FORMAT_PROTOBUF, UUri.class, null), + // unpacking a protobuf to a different type fails because of the type information + // contained in the Any wrapper + Arguments.of( + Any.pack(stringValue).toByteString(), + UPayloadFormat.UPAYLOAD_FORMAT_PROTOBUF_WRAPPED_IN_ANY, + UUri.class, + UStatusException.class), + Arguments.of( + Any.pack(stringValue).toByteString(), + UPayloadFormat.UPAYLOAD_FORMAT_PROTOBUF_WRAPPED_IN_ANY, + StringValue.class, + null) + ); + } + + @ParameterizedTest(name = "Test unpackOrDefaultInstance: {index} => {arguments}") + @MethodSource("unpackOrDefaultInstanceArgsProvider") + void testUnpackOrDefaultInstanceSucceedsForSimpleProtobuf( + ByteString protobuf, + UPayloadFormat format, + Class expectedClass, + Class expectedException) { + if (expectedException != null) { + assertThrows(expectedException, () -> { + UPayload.unpackOrDefaultInstance(protobuf, format, expectedClass); + }); + } else { + var unpacked = UPayload.unpackOrDefaultInstance(protobuf, format, expectedClass); + assertInstanceOf(expectedClass, unpacked); + } + } @Test @DisplayName("Test equals when they are equal") @@ -133,7 +209,7 @@ public void testEqualsWhenTheyAreNotEqual() { public void testEqualsWhenObjectIsNull() { UUri uri = UUri.newBuilder().setAuthorityName("Hartley").build(); UPayload payload = UPayload.packToAny(uri); - assertFalse(payload.equals(null)); + assertNotNull(payload); } diff --git a/src/test/java/org/eclipse/uprotocol/transport/UTransportTest.java b/src/test/java/org/eclipse/uprotocol/transport/UTransportTest.java index 63586c3e..32c1a705 100644 --- a/src/test/java/org/eclipse/uprotocol/transport/UTransportTest.java +++ b/src/test/java/org/eclipse/uprotocol/transport/UTransportTest.java @@ -12,193 +12,61 @@ */ package org.eclipse.uprotocol.transport; -import java.util.concurrent.CompletionStage; import java.util.concurrent.CompletableFuture; -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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.eclipse.uprotocol.transport.builder.UMessageBuilder; -import org.eclipse.uprotocol.v1.UCode; -import org.eclipse.uprotocol.v1.UMessage; -import org.eclipse.uprotocol.v1.UStatus; +import org.mockito.Mockito; +import org.eclipse.uprotocol.uri.factory.UriFactory; import org.eclipse.uprotocol.v1.UUri; /** * Tests implementing and using uTransport API. */ -public class UTransportTest { - @Test - @DisplayName("Test happy path send message") - public void testHappySendMessage() { - UTransport transport = new HappyUTransport(); - UUri uri = UUri.newBuilder().setUeId(1).setUeVersionMajor(1).setResourceId(0x8000).build(); - - CompletionStage result = transport.send(UMessageBuilder.publish(uri).build()); - assertFalse(result.toCompletableFuture().isCompletedExceptionally()); - assertEquals(result.toCompletableFuture().join().getCode(), UCode.OK); - } - - @Test - @DisplayName("Test happy path register listener") - public void testHappyRegisterListener() { - UTransport transport = new HappyUTransport(); - assertFalse(transport.registerListener( - UUri.getDefaultInstance(), - new MyListener()).toCompletableFuture().isCompletedExceptionally()); +class UTransportTest { + private static final UUri SOURCE_FILTER = UUri.newBuilder() + .setAuthorityName("my-vehicle") + .setUeId(0x1a54) + .setUeVersionMajor(0x02) + .setResourceId(0xFFFF) + .build(); + + private UTransport transport; + private UListener listener; + + @BeforeEach + void setUp() { + transport = mock(UTransport.class); + Mockito.lenient().when(transport.registerListener(any(UUri.class), any(UListener.class))) + .thenCallRealMethod(); + Mockito.lenient().when(transport.unregisterListener(any(UUri.class), any(UListener.class))) + .thenCallRealMethod(); + listener = mock(UListener.class); } @Test - @DisplayName("Test happy path unregister listener") - public void testHappyRegisterUnlistener() { - UTransport transport = new HappyUTransport(); - assertFalse(transport.unregisterListener( - UUri.getDefaultInstance(), - new MyListener()).toCompletableFuture().isCompletedExceptionally()); - } + @DisplayName("Test default implementation of registerListener") + void testRegisterListener() { + when(transport.registerListener(any(UUri.class), any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.completedFuture(null)); - @Test - @DisplayName("Test unhappy path send message") - public void testUnhappySendMessage() { - UTransport transport = new SadUTransport(); - assertEquals(transport.send(null).toCompletableFuture().join().getCode(), UCode.INTERNAL); + transport.registerListener(SOURCE_FILTER, listener).toCompletableFuture().join(); + verify(transport).registerListener(eq(SOURCE_FILTER), eq(UriFactory.ANY), eq(listener)); } @Test - @DisplayName("Test unhappy path register listener") - public void testUnhappyRegisterListener() { - UTransport transport = new SadUTransport(); - CompletionStage result = transport.registerListener(UUri.getDefaultInstance(), - new MyListener()); - - assertEquals(result.toCompletableFuture().join().getCode(), UCode.INTERNAL); - } - - @Test - @DisplayName("Test unhappy path unregister listener") - public void testUnhappyRegisterUnlistener() { - UTransport transport = new SadUTransport(); - - CompletionStage result = transport.unregisterListener(UUri.getDefaultInstance(), - new MyListener()); - - assertEquals(result.toCompletableFuture().join().getCode(), UCode.INTERNAL); - } - - @Test - @DisplayName("Test happy path calling open() API") - public void testHappyOpen() { - UTransport transport = new HappyUTransport(); - assertEquals(transport.open().toCompletableFuture().join().getCode(), UCode.OK); - } - - @Test - @DisplayName("Test default oepn() and close() APIs") - public void testDefaultOpenClose() { - UTransport transport = new UTransport() { - @Override - public CompletionStage send(UMessage message) { - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - } - - @Override - public CompletionStage registerListener(UUri source, UUri sink, UListener listener) { - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - } - - @Override - public CompletionStage unregisterListener(UUri source, UUri sink, UListener listener) { - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - } - - @Override - public UUri getSource() { - return UUri.getDefaultInstance(); - } - }; - - assertDoesNotThrow(() -> transport.close()); - } - - - class MyListener implements UListener { - @Override - public void onReceive(UMessage message) { - // empty by intention - } - } - - private class HappyUTransport implements UTransport { - @Override - public CompletionStage send(UMessage message) { - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - } - - - @Override - public CompletionStage registerListener(UUri source, UUri sink, UListener listener) { - listener.onReceive(null); - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - } - - @Override - public CompletionStage unregisterListener(UUri source, UUri sink, UListener listener) { - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.OK).build()); - } - - - @Override - public UUri getSource() { - return UUri.getDefaultInstance(); - } - - @Override - public void close() { - } - } - - private class SadUTransport implements UTransport { - @Override - public CompletionStage send(UMessage message) { - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.INTERNAL).build()); - } - - @Override - public CompletionStage registerListener(UUri source, UUri sink, UListener listener) { - listener.onReceive(null); - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.INTERNAL).build()); - } - - @Override - public CompletionStage unregisterListener(UUri source, UUri sink, UListener listener) { - return CompletableFuture.completedFuture(UStatus.newBuilder().setCode(UCode.INTERNAL).build()); - } - - @Override - public UUri getSource() { - return UUri.getDefaultInstance(); - } - - @Override - public void close() { - } - } - - @Test - @DisplayName("Test happy path registerlistener with source filter only") - public void testHappyRegisterListenerSourceFilter() { - UTransport transport = new HappyUTransport(); - CompletionStage result = transport.registerListener(UUri.getDefaultInstance(), new MyListener()); - assertEquals(result.toCompletableFuture().join().getCode(), UCode.OK); - } - - @Test - @DisplayName("Test happy path unregisterlistener with source filter only") - public void testHappyUnregisterListenerSourceFilter() { - UTransport transport = new HappyUTransport(); - CompletionStage result = transport.unregisterListener(UUri.getDefaultInstance(), new MyListener()); - assertEquals(result.toCompletableFuture().join().getCode(), UCode.OK); + @DisplayName("Test happy path unregister listener") + void testUnregisterListener() { + when(transport.unregisterListener(any(UUri.class), any(UUri.class), any(UListener.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + transport.unregisterListener(SOURCE_FILTER, listener).toCompletableFuture().join(); + verify(transport).unregisterListener(eq(SOURCE_FILTER), eq(UriFactory.ANY), eq(listener)); } } diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties new file mode 100644 index 00000000..a6a8ce4a --- /dev/null +++ b/src/test/resources/junit-platform.properties @@ -0,0 +1,6 @@ +junit.platform.output.capture.stdout=true +junit.platform.output.capture.stderr=true + +# System properties for logging +systemProperty.logback.configurationFile=src/test/resources/logback-test.xml +systemProperty.java.util.logging.manager=org.slf4j.bridge.SLF4JBridgeHandler diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 00000000..f26aace1 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,12 @@ + + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + + +