diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java index 1385f3a4e6..d94db790cc 100644 --- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java @@ -47,18 +47,18 @@ import org.eclipse.milo.opcua.sdk.server.namespaces.OpcUaNamespace; import org.eclipse.milo.opcua.sdk.server.namespaces.ServerNamespace; import org.eclipse.milo.opcua.sdk.server.nodes.factories.EventFactory; +import org.eclipse.milo.opcua.sdk.server.servicesets.AttributeServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.DiscoveryServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.MethodServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.MonitoredItemServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.NodeManagementServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.QueryServiceSet; import org.eclipse.milo.opcua.sdk.server.servicesets.Service; +import org.eclipse.milo.opcua.sdk.server.servicesets.SessionServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.SubscriptionServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.ViewServiceSet; import org.eclipse.milo.opcua.sdk.server.servicesets.impl.AccessController; import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultAccessController; -import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultAttributeServiceSet; -import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultDiscoveryServiceSet; -import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultMethodServiceSet; -import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultMonitoredItemServiceSet; -import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultNodeManagementServiceSet; -import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultQueryServiceSet; -import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultSessionServiceSet; -import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultSubscriptionServiceSet; -import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultViewServiceSet; import org.eclipse.milo.opcua.sdk.server.subscriptions.Subscription; import org.eclipse.milo.opcua.sdk.server.typetree.DataTypeTreeBuilder; import org.eclipse.milo.opcua.sdk.server.typetree.ObjectTypeTreeBuilder; @@ -189,6 +189,22 @@ public class OpcUaServer extends AbstractServiceHandler { private final ServerApplicationContext applicationContext; public OpcUaServer(OpcUaServerConfig config, OpcServerTransportFactory transportFactory) { + this(config, transportFactory, new ServiceSets() {}); + } + + /** + * Create an OpcUaServer using the service set implementations supplied by {@code serviceSets}. + * + * @param config the {@link OpcUaServerConfig}. + * @param transportFactory the {@link OpcServerTransportFactory}. + * @param serviceSets the {@link ServiceSets} supplying the service set implementations this + * server uses. + */ + public OpcUaServer( + OpcUaServerConfig config, + OpcServerTransportFactory transportFactory, + ServiceSets serviceSets) { + this.config = config; this.transportFactory = transportFactory; @@ -255,19 +271,31 @@ public ServerTable getServerTable() { .map(e -> EndpointUtil.getPath(e.getEndpointUrl())) .distinct(); + DiscoveryServiceSet discoveryServiceSet = serviceSets.createDiscoveryServiceSet(this); + AttributeServiceSet attributeServiceSet = serviceSets.createAttributeServiceSet(this); + MethodServiceSet methodServiceSet = serviceSets.createMethodServiceSet(this); + MonitoredItemServiceSet monitoredItemServiceSet = + serviceSets.createMonitoredItemServiceSet(this); + NodeManagementServiceSet nodeManagementServiceSet = + serviceSets.createNodeManagementServiceSet(this); + QueryServiceSet queryServiceSet = serviceSets.createQueryServiceSet(this); + SessionServiceSet sessionServiceSet = serviceSets.createSessionServiceSet(this); + SubscriptionServiceSet subscriptionServiceSet = serviceSets.createSubscriptionServiceSet(this); + ViewServiceSet viewServiceSet = serviceSets.createViewServiceSet(this); + paths.forEach( path -> { - addServiceSet(path, new DefaultDiscoveryServiceSet(OpcUaServer.this)); + addServiceSet(path, discoveryServiceSet); if (!path.endsWith("/discovery")) { - addServiceSet(path, new DefaultAttributeServiceSet(OpcUaServer.this)); - addServiceSet(path, new DefaultMethodServiceSet(OpcUaServer.this)); - addServiceSet(path, new DefaultMonitoredItemServiceSet(OpcUaServer.this)); - addServiceSet(path, new DefaultNodeManagementServiceSet(OpcUaServer.this)); - addServiceSet(path, new DefaultQueryServiceSet()); - addServiceSet(path, new DefaultSessionServiceSet(OpcUaServer.this)); - addServiceSet(path, new DefaultSubscriptionServiceSet(OpcUaServer.this)); - addServiceSet(path, new DefaultViewServiceSet(OpcUaServer.this)); + addServiceSet(path, attributeServiceSet); + addServiceSet(path, methodServiceSet); + addServiceSet(path, monitoredItemServiceSet); + addServiceSet(path, nodeManagementServiceSet); + addServiceSet(path, queryServiceSet); + addServiceSet(path, sessionServiceSet); + addServiceSet(path, subscriptionServiceSet); + addServiceSet(path, viewServiceSet); } }); diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/ServiceSets.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/ServiceSets.java new file mode 100644 index 0000000000..3378a99320 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/ServiceSets.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server; + +import org.eclipse.milo.opcua.sdk.server.servicesets.AttributeServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.DiscoveryServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.MethodServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.MonitoredItemServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.NodeManagementServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.QueryServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.SessionServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.SubscriptionServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.ViewServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultAttributeServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultDiscoveryServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultMethodServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultMonitoredItemServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultNodeManagementServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultQueryServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultSessionServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultSubscriptionServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultViewServiceSet; + +/** + * Factories for the service set implementations an {@link OpcUaServer} uses. + * + *

Each factory method is invoked once while the server is constructed, and the returned instance + * is registered on every applicable endpoint path: the {@link DiscoveryServiceSet} on all paths, + * the other service sets on all paths not suffixed with "/discovery". + * + *

All methods have defaults that return the standard implementations, so an implementation only + * overrides the service sets it replaces: + * + *

{@code
+ * var server = new OpcUaServer(config, transportFactory, new ServiceSets() {
+ *     @Override
+ *     public DiscoveryServiceSet createDiscoveryServiceSet(OpcUaServer server) {
+ *         return new DefaultDiscoveryServiceSet(server, additionalServersSupplier);
+ *     }
+ * });
+ * }
+ */ +public interface ServiceSets { + + default AttributeServiceSet createAttributeServiceSet(OpcUaServer server) { + return new DefaultAttributeServiceSet(server); + } + + default DiscoveryServiceSet createDiscoveryServiceSet(OpcUaServer server) { + return new DefaultDiscoveryServiceSet(server); + } + + default MethodServiceSet createMethodServiceSet(OpcUaServer server) { + return new DefaultMethodServiceSet(server); + } + + default MonitoredItemServiceSet createMonitoredItemServiceSet(OpcUaServer server) { + return new DefaultMonitoredItemServiceSet(server); + } + + default NodeManagementServiceSet createNodeManagementServiceSet(OpcUaServer server) { + return new DefaultNodeManagementServiceSet(server); + } + + default QueryServiceSet createQueryServiceSet(OpcUaServer server) { + return new DefaultQueryServiceSet(); + } + + default SessionServiceSet createSessionServiceSet(OpcUaServer server) { + return new DefaultSessionServiceSet(server); + } + + default SubscriptionServiceSet createSubscriptionServiceSet(OpcUaServer server) { + return new DefaultSubscriptionServiceSet(server); + } + + default ViewServiceSet createViewServiceSet(OpcUaServer server) { + return new DefaultViewServiceSet(server); + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultDiscoveryServiceSet.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultDiscoveryServiceSet.java index a480df6b39..614eed4c39 100644 --- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultDiscoveryServiceSet.java +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultDiscoveryServiceSet.java @@ -13,9 +13,11 @@ import static java.util.stream.Collectors.toList; import static org.eclipse.milo.opcua.sdk.server.servicesets.AbstractServiceSet.createResponseHeader; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.function.Supplier; import org.eclipse.milo.opcua.sdk.server.EndpointConfig; import org.eclipse.milo.opcua.sdk.server.OpcUaServer; import org.eclipse.milo.opcua.sdk.server.servicesets.DiscoveryServiceSet; @@ -44,9 +46,32 @@ public class DefaultDiscoveryServiceSet implements DiscoveryServiceSet { private final Logger logger = LoggerFactory.getLogger(getClass()); private final OpcUaServer server; + private final Supplier> additionalServers; public DefaultDiscoveryServiceSet(OpcUaServer server) { + this(server, List::of); + } + + /** + * Create a DefaultDiscoveryServiceSet that includes additional servers in the results of the + * FindServers service. + * + *

The supplier is invoked each time the FindServers service is invoked, and may return + * different results over time. It must not include the ApplicationDescription of {@code server} + * itself; that is always included. + * + *

This is intended for use by servers that know of other servers they should advertise, e.g. + * the other members of a non-transparent redundant server set. + * + * @param server the {@link OpcUaServer} this service set belongs to. + * @param additionalServers a supplier of additional {@link ApplicationDescription}s to include in + * the results of the FindServers service. + */ + public DefaultDiscoveryServiceSet( + OpcUaServer server, Supplier> additionalServers) { + this.server = server; + this.additionalServers = additionalServers; } @Override @@ -94,8 +119,9 @@ public FindServersResponse onFindServers( ? List.of(request.getServerUris()) : Collections.emptyList(); - List applicationDescriptions = - List.of(getFilteredApplicationDescription(request.getEndpointUrl())); + List applicationDescriptions = new ArrayList<>(); + applicationDescriptions.add(getFilteredApplicationDescription(request.getEndpointUrl())); + applicationDescriptions.addAll(additionalServers.get()); applicationDescriptions = applicationDescriptions.stream().filter(ad -> filterServerUris(ad, serverUris)).toList(); diff --git a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerServiceSetsTest.java b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerServiceSetsTest.java new file mode 100644 index 0000000000..224ba14f5a --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerServiceSetsTest.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server; + +import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.eclipse.milo.opcua.sdk.server.servicesets.AttributeServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.DiscoveryServiceSet; +import org.eclipse.milo.opcua.sdk.server.servicesets.Service; +import org.eclipse.milo.opcua.sdk.server.servicesets.impl.DefaultDiscoveryServiceSet; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.security.DefaultCertificateManager; +import org.eclipse.milo.opcua.stack.core.security.MemoryCertificateQuarantine; +import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy; +import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime; +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.enumerated.ApplicationType; +import org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode; +import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn; +import org.eclipse.milo.opcua.stack.core.types.structured.ApplicationDescription; +import org.eclipse.milo.opcua.stack.core.types.structured.FindServersRequest; +import org.eclipse.milo.opcua.stack.core.types.structured.FindServersResponse; +import org.eclipse.milo.opcua.stack.core.types.structured.HistoryReadRequest; +import org.eclipse.milo.opcua.stack.core.types.structured.HistoryReadResponse; +import org.eclipse.milo.opcua.stack.core.types.structured.HistoryUpdateRequest; +import org.eclipse.milo.opcua.stack.core.types.structured.HistoryUpdateResponse; +import org.eclipse.milo.opcua.stack.core.types.structured.ReadRequest; +import org.eclipse.milo.opcua.stack.core.types.structured.ReadResponse; +import org.eclipse.milo.opcua.stack.core.types.structured.ReadValueId; +import org.eclipse.milo.opcua.stack.core.types.structured.RequestHeader; +import org.eclipse.milo.opcua.stack.core.types.structured.WriteRequest; +import org.eclipse.milo.opcua.stack.core.types.structured.WriteResponse; +import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext; +import org.junit.jupiter.api.Test; + +class OpcUaServerServiceSetsTest { + + private static final ApplicationDescription ADDITIONAL_SERVER = + new ApplicationDescription( + "urn:eclipse:milo:test:additional", + "urn:eclipse:milo:test", + LocalizedText.english("additional server"), + ApplicationType.Server, + null, + null, + new String[] {"opc.tcp://additional-host:4840/milo/discovery"}); + + @Test + void customDiscoveryServiceSetIsRegisteredOnEveryEndpointPath() throws Exception { + OpcUaServer server = + createServer( + new ServiceSets() { + @Override + public DiscoveryServiceSet createDiscoveryServiceSet(OpcUaServer server) { + return new DefaultDiscoveryServiceSet(server, () -> List.of(ADDITIONAL_SERVER)); + } + }); + + for (String path : List.of("/milo", "/milo/discovery")) { + AbstractServiceHandler.ServiceHandler handler = + server.getServiceHandler(path, Service.DISCOVERY_FIND_SERVERS); + assertNotNull(handler, "expected a FindServers handler at path " + path); + + FindServersResponse response = + (FindServersResponse) + handler.handle( + mock(ServiceRequestContext.class), + findServersRequest("opc.tcp://localhost:4840" + path)); + + ApplicationDescription[] servers = response.getServers(); + assertEquals(2, servers.length, "expected the custom service set at path " + path); + assertEquals(ADDITIONAL_SERVER, servers[1]); + } + } + + @Test + void customNonDiscoveryServiceSetIsRegisteredOnNonDiscoveryPathsOnly() throws Exception { + ReadResponse cannedResponse = new ReadResponse(null, null, null); + + OpcUaServer server = + createServer( + new ServiceSets() { + @Override + public AttributeServiceSet createAttributeServiceSet(OpcUaServer server) { + return new StubAttributeServiceSet(cannedResponse); + } + }); + + AbstractServiceHandler.ServiceHandler handler = + server.getServiceHandler("/milo", Service.ATTRIBUTE_READ); + assertNotNull(handler); + + Object response = handler.handle(mock(ServiceRequestContext.class), readRequest()); + assertSame(cannedResponse, response); + + assertNull( + server.getServiceHandler("/milo/discovery", Service.ATTRIBUTE_READ), + "non-discovery service sets must not be registered on a /discovery path"); + } + + @Test + void eachFactoryMethodIsInvokedOnceRegardlessOfPathCount() { + AtomicInteger discoveryCount = new AtomicInteger(); + AtomicInteger attributeCount = new AtomicInteger(); + + createServer( + new ServiceSets() { + @Override + public DiscoveryServiceSet createDiscoveryServiceSet(OpcUaServer server) { + discoveryCount.incrementAndGet(); + return ServiceSets.super.createDiscoveryServiceSet(server); + } + + @Override + public AttributeServiceSet createAttributeServiceSet(OpcUaServer server) { + attributeCount.incrementAndGet(); + return ServiceSets.super.createAttributeServiceSet(server); + } + }); + + assertEquals(1, discoveryCount.get()); + assertEquals(1, attributeCount.get()); + } + + @Test + void defaultConstructorRegistersDefaultServiceSets() { + OpcUaServer server = new OpcUaServer(serverConfig(), transportProfile -> null); + + for (String path : List.of("/milo", "/milo/discovery")) { + assertNotNull(server.getServiceHandler(path, Service.DISCOVERY_FIND_SERVERS)); + } + assertNotNull(server.getServiceHandler("/milo", Service.ATTRIBUTE_READ)); + assertNull(server.getServiceHandler("/milo/discovery", Service.ATTRIBUTE_READ)); + } + + private static OpcUaServer createServer(ServiceSets serviceSets) { + return new OpcUaServer(serverConfig(), transportProfile -> null, serviceSets); + } + + private static OpcUaServerConfig serverConfig() { + Set endpoints = new LinkedHashSet<>(); + endpoints.add(endpoint("/milo")); + endpoints.add(endpoint("/milo/discovery")); + + return OpcUaServerConfig.builder() + .setApplicationUri("urn:eclipse:milo:test:server") + .setApplicationName(LocalizedText.english("test server")) + .setProductUri("urn:eclipse:milo:test") + .setCertificateManager(new DefaultCertificateManager(new MemoryCertificateQuarantine())) + .setEndpoints(endpoints) + .build(); + } + + private static EndpointConfig endpoint(String path) { + return EndpointConfig.newBuilder() + .setBindPort(4840) + .setHostname("localhost") + .setPath(path) + .setSecurityPolicy(SecurityPolicy.None) + .setSecurityMode(MessageSecurityMode.None) + .addTokenPolicy(OpcUaServerConfig.USER_TOKEN_POLICY_ANONYMOUS) + .build(); + } + + private static RequestHeader requestHeader() { + return new RequestHeader( + NodeId.NULL_VALUE, DateTime.now(), uint(1), uint(0), null, uint(0), null); + } + + private static FindServersRequest findServersRequest(String endpointUrl) { + return new FindServersRequest(requestHeader(), endpointUrl, null, null); + } + + private static ReadRequest readRequest() { + return new ReadRequest(requestHeader(), 0.0, TimestampsToReturn.Both, new ReadValueId[0]); + } + + private static class StubAttributeServiceSet implements AttributeServiceSet { + + private final ReadResponse readResponse; + + private StubAttributeServiceSet(ReadResponse readResponse) { + this.readResponse = readResponse; + } + + @Override + public ReadResponse onRead(ServiceRequestContext context, ReadRequest request) { + return readResponse; + } + + @Override + public HistoryReadResponse onHistoryRead( + ServiceRequestContext context, HistoryReadRequest request) throws UaException { + throw new UaException(StatusCodes.Bad_ServiceUnsupported); + } + + @Override + public WriteResponse onWrite(ServiceRequestContext context, WriteRequest request) + throws UaException { + throw new UaException(StatusCodes.Bad_ServiceUnsupported); + } + + @Override + public HistoryUpdateResponse onHistoryUpdate( + ServiceRequestContext context, HistoryUpdateRequest request) throws UaException { + throw new UaException(StatusCodes.Bad_ServiceUnsupported); + } + } +} diff --git a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultDiscoveryServiceSetTest.java b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultDiscoveryServiceSetTest.java new file mode 100644 index 0000000000..89fdc9a04e --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultDiscoveryServiceSetTest.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.servicesets.impl; + +import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.eclipse.milo.opcua.sdk.server.EndpointConfig; +import org.eclipse.milo.opcua.sdk.server.OpcUaServer; +import org.eclipse.milo.opcua.sdk.server.OpcUaServerConfig; +import org.eclipse.milo.opcua.stack.core.security.DefaultCertificateManager; +import org.eclipse.milo.opcua.stack.core.security.MemoryCertificateQuarantine; +import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy; +import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime; +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.enumerated.ApplicationType; +import org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode; +import org.eclipse.milo.opcua.stack.core.types.structured.ApplicationDescription; +import org.eclipse.milo.opcua.stack.core.types.structured.FindServersRequest; +import org.eclipse.milo.opcua.stack.core.types.structured.FindServersResponse; +import org.eclipse.milo.opcua.stack.core.types.structured.RequestHeader; +import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext; +import org.junit.jupiter.api.Test; + +class DefaultDiscoveryServiceSetTest { + + private static final String LOCAL_APPLICATION_URI = "urn:eclipse:milo:test:server"; + private static final String PEER_APPLICATION_URI = "urn:eclipse:milo:test:peer"; + + private static final ApplicationDescription PEER_DESCRIPTION = + new ApplicationDescription( + PEER_APPLICATION_URI, + "urn:eclipse:milo:test", + LocalizedText.english("peer server"), + ApplicationType.Server, + null, + null, + new String[] {"opc.tcp://peer-host:4840/milo/discovery"}); + + @Test + void emptyAdditionalServersMatchesDefaultBehavior() { + OpcUaServer server = createServer(); + + FindServersRequest request = findServersRequest("opc.tcp://localhost:4840/milo", null); + + FindServersResponse defaultResponse = + new DefaultDiscoveryServiceSet(server) + .onFindServers(mock(ServiceRequestContext.class), request); + FindServersResponse response = + new DefaultDiscoveryServiceSet(server, List::of) + .onFindServers(mock(ServiceRequestContext.class), request); + + assertArrayEquals(defaultResponse.getServers(), response.getServers()); + } + + @Test + void additionalServersAreIncludedInFindServersResults() { + OpcUaServer server = createServer(); + + DefaultDiscoveryServiceSet serviceSet = + new DefaultDiscoveryServiceSet(server, () -> List.of(PEER_DESCRIPTION)); + + FindServersResponse response = + serviceSet.onFindServers( + mock(ServiceRequestContext.class), + findServersRequest("opc.tcp://localhost:4840/milo", null)); + + ApplicationDescription[] servers = response.getServers(); + assertEquals(2, servers.length); + assertEquals(LOCAL_APPLICATION_URI, servers[0].getApplicationUri()); + assertEquals(PEER_DESCRIPTION, servers[1]); + } + + @Test + void serverUrisFilterSelectsOnlyAdditionalServer() { + OpcUaServer server = createServer(); + + DefaultDiscoveryServiceSet serviceSet = + new DefaultDiscoveryServiceSet(server, () -> List.of(PEER_DESCRIPTION)); + + FindServersResponse response = + serviceSet.onFindServers( + mock(ServiceRequestContext.class), + findServersRequest( + "opc.tcp://localhost:4840/milo", new String[] {PEER_APPLICATION_URI})); + + assertArrayEquals(new ApplicationDescription[] {PEER_DESCRIPTION}, response.getServers()); + } + + @Test + void serverUrisFilterSelectsOnlyLocalServer() { + OpcUaServer server = createServer(); + + DefaultDiscoveryServiceSet serviceSet = + new DefaultDiscoveryServiceSet(server, () -> List.of(PEER_DESCRIPTION)); + + FindServersResponse response = + serviceSet.onFindServers( + mock(ServiceRequestContext.class), + findServersRequest( + "opc.tcp://localhost:4840/milo", new String[] {LOCAL_APPLICATION_URI})); + + ApplicationDescription[] servers = response.getServers(); + assertEquals(1, servers.length); + assertEquals(LOCAL_APPLICATION_URI, servers[0].getApplicationUri()); + } + + @Test + void serverUrisFilterWithUnknownUriReturnsNoServers() { + OpcUaServer server = createServer(); + + DefaultDiscoveryServiceSet serviceSet = + new DefaultDiscoveryServiceSet(server, () -> List.of(PEER_DESCRIPTION)); + + FindServersResponse response = + serviceSet.onFindServers( + mock(ServiceRequestContext.class), + findServersRequest("opc.tcp://localhost:4840/milo", new String[] {"urn:unknown"})); + + assertEquals(0, response.getServers().length); + } + + private static OpcUaServer createServer() { + Set endpoints = new LinkedHashSet<>(); + endpoints.add(endpoint("/milo")); + endpoints.add(endpoint("/milo/discovery")); + + OpcUaServerConfig config = + OpcUaServerConfig.builder() + .setApplicationUri(LOCAL_APPLICATION_URI) + .setApplicationName(LocalizedText.english("test server")) + .setProductUri("urn:eclipse:milo:test") + .setCertificateManager(new DefaultCertificateManager(new MemoryCertificateQuarantine())) + .setEndpoints(endpoints) + .build(); + + return new OpcUaServer(config, transportProfile -> null); + } + + private static EndpointConfig endpoint(String path) { + return EndpointConfig.newBuilder() + .setBindPort(4840) + .setHostname("localhost") + .setPath(path) + .setSecurityPolicy(SecurityPolicy.None) + .setSecurityMode(MessageSecurityMode.None) + .addTokenPolicy(OpcUaServerConfig.USER_TOKEN_POLICY_ANONYMOUS) + .build(); + } + + private static FindServersRequest findServersRequest(String endpointUrl, String[] serverUris) { + RequestHeader requestHeader = + new RequestHeader(NodeId.NULL_VALUE, DateTime.now(), uint(1), uint(0), null, uint(0), null); + + return new FindServersRequest(requestHeader, endpointUrl, null, serverUris); + } +}