You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2021/12/25 15:09:16 UTC

[camel] branch main updated: CAMEL-17319: Camel Milo: Browsing functionality (#6526)

This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new acc3024  CAMEL-17319: Camel Milo: Browsing functionality (#6526)
acc3024 is described below

commit acc3024827c69a33297b4a385e9ff4cf0348b6b7
Author: Andreas Klug <an...@de.bosch.com>
AuthorDate: Sat Dec 25 16:08:47 2021 +0100

    CAMEL-17319: Camel Milo: Browsing functionality (#6526)
    
    * camel-milo: Adding Milo browsing functionality
    
    * CAMEL-17319: Add support for milo browsing functionality
    
    * CAMEL-17319: Add support for milo browsing - changes based on review comments
    
    * CAMEL-17319: Add support for milo browsing - changes based on review comments (affecting existing client component)
    
    Co-authored-by: Klug Andreas (CI/XDM1) <kg...@bosch.com>
---
 components/camel-milo/pom.xml                      |  16 -
 .../org/apache/camel/component/milo-browse         |   2 +
 .../src/main/docs/milo-browse-component.adoc       |  93 +++++
 .../org/apache/camel/component/milo/NodeIds.java   |   3 +
 .../component/milo/browse/MiloBrowseComponent.java | 100 ++++++
 .../component/milo/browse/MiloBrowseEndpoint.java  | 247 +++++++++++++
 .../component/milo/browse/MiloBrowseProducer.java  | 149 ++++++++
 .../milo/client/MiloClientConnection.java          |  11 +
 .../component/milo/client/MiloClientProducer.java  |   4 +-
 .../milo/client/internal/SubscriptionManager.java  | 223 +++++++++++-
 .../component/milo/AbstractMiloServerTest.java     |  20 ++
 .../component/milo/browse/BrowseServerTest.java    | 381 +++++++++++++++++++++
 .../component/milo/call/MockCamelNamespace.java    |   1 -
 .../src/test/resources/log4j2.properties           |   6 +
 14 files changed, 1228 insertions(+), 28 deletions(-)

diff --git a/components/camel-milo/pom.xml b/components/camel-milo/pom.xml
index 2a7adb3..0f69b4c 100644
--- a/components/camel-milo/pom.xml
+++ b/components/camel-milo/pom.xml
@@ -80,20 +80,4 @@
         </dependency>
     </dependencies>
 
-    <build>
-        <plugins>
-            <plugin>
-                <artifactId>maven-surefire-plugin</artifactId>
-                <configuration>
-                    <childDelegation>false</childDelegation>
-                    <useFile>true</useFile>
-                    <forkCount>1</forkCount>
-                    <!-- required due to issue eclipse/milo#23 -->
-                    <reuseForks>false</reuseForks>
-                    <forkedProcessTimeoutInSeconds>300</forkedProcessTimeoutInSeconds>
-                </configuration>
-            </plugin>
-        </plugins>
-    </build>
-
 </project>
diff --git a/components/camel-milo/src/generated/resources/META-INF/services/org/apache/camel/component/milo-browse b/components/camel-milo/src/generated/resources/META-INF/services/org/apache/camel/component/milo-browse
new file mode 100644
index 0000000..c8d6d89
--- /dev/null
+++ b/components/camel-milo/src/generated/resources/META-INF/services/org/apache/camel/component/milo-browse
@@ -0,0 +1,2 @@
+# Generated by camel build tools - do NOT edit this file!
+class=org.apache.camel.component.milo.browse.MiloBrowseComponent
diff --git a/components/camel-milo/src/main/docs/milo-browse-component.adoc b/components/camel-milo/src/main/docs/milo-browse-component.adoc
new file mode 100644
index 0000000..9bf4ddc
--- /dev/null
+++ b/components/camel-milo/src/main/docs/milo-browse-component.adoc
@@ -0,0 +1,93 @@
+= OPC UA Browser Component
+:doctitle: OPC UA Browser
+:shortname: milo-browse
+:artifactid: camel-milo
+:description: Connect to OPC UA servers using the binary protocol for browsing the node tree.
+:since: 3.15
+:supportlevel: Preview
+:component-header: Only producer is supported
+include::{cq-version}@camel-quarkus:ROOT:partial$reference/components/milo-browse.adoc[opts=optional]
+
+*Since Camel {since}*
+
+*{component-header}*
+
+The Milo Client component provides access to OPC UA servers using the
+http://eclipse.org/milo[Eclipse Milo™] implementation.
+
+*Java 11+*: This component requires Java 11+ at runtime.
+
+Maven users will need to add the following dependency to their `pom.xml`
+for this component:
+
+[source,xml]
+------------------------------------------------------------
+<dependency>
+    <groupId>org.apache.camel</groupId>
+    <artifactId>camel-milo</artifactId>
+    <version>x.x.x</version>
+    <!-- use the same version as your Camel core version -->
+</dependency>
+------------------------------------------------------------
+
+
+== URI format
+
+The URI syntax of the endpoint is:
+
+------------------------
+milo-browse:opc.tcp://[user:password@]host:port/path/to/service?node=RAW(nsu=urn:foo:bar;s=item-1)
+------------------------
+
+Please refer to the milo-client component for further details about the construction of the URI.
+
+// component-configure options: START
+
+// component-configure options: END
+
+// component options: START
+include::partial$component-configure-options.adoc[]
+include::partial$component-endpoint-options.adoc[]
+// component options: END
+
+// endpoint options: START
+
+// endpoint options: END
+
+
+=== Client
+
+The browse component shares the same base options like the Camel Milo Client component, e. g. concerning topics like discovery,
+security policies, the construction of node ids, etc.
+
+Please refer to the documentation of the Camel Milo Client component for further details.
+
+=== Browsing
+
+The main use of this component is to be able to determine the nodes values to be retrieved or to be written by first browsing
+the node tree of the OPC-UA server, e. g. to avoid hard-coding a significant number of node ids within the configuration of
+Camel routes. The component is designed to work in conjunction with the Camel Milo Client component as illustrated in the
+following example:
+
+[source,java]
+----
+from("direct:start")
+
+    // Browse sub tree
+    .setHeader("CamelMiloNodeIds", constant(Arrays.asList("ns=1;s=folder-id")))
+    .enrich("milo-browse:opc.tcp://localhost:4334", (oldExchange, newExchange) -> newExchange)
+
+    // Filter specific ids
+    .filter(...)
+
+        // Retrieve the values for the nodes of interest
+        .enrich("milo-client:opc.tcp://localhost:4334", (oldExchange, newExchange) -> newExchange)
+
+----
+
+=== Recursion
+
+Dependent to the OPC-UA server there it might be required to browse a hierarchy of nodes.
+Be aware that this is potentially a very expensive operation.
+
+include::{page-component-version}@camel-spring-boot::page$milo-starter.adoc[]
diff --git a/components/camel-milo/src/main/java/org/apache/camel/component/milo/NodeIds.java b/components/camel-milo/src/main/java/org/apache/camel/component/milo/NodeIds.java
index b8ae81a..f964cbd 100644
--- a/components/camel-milo/src/main/java/org/apache/camel/component/milo/NodeIds.java
+++ b/components/camel-milo/src/main/java/org/apache/camel/component/milo/NodeIds.java
@@ -33,6 +33,9 @@ import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.
  * Helper class to work with node IDs
  */
 public final class NodeIds {
+
+    public static final String HEADER_NODE_IDS = "CamelMiloNodeIds";
+
     private NodeIds() {
     }
 
diff --git a/components/camel-milo/src/main/java/org/apache/camel/component/milo/browse/MiloBrowseComponent.java b/components/camel-milo/src/main/java/org/apache/camel/component/milo/browse/MiloBrowseComponent.java
new file mode 100644
index 0000000..2e32eb3
--- /dev/null
+++ b/components/camel-milo/src/main/java/org/apache/camel/component/milo/browse/MiloBrowseComponent.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.milo.browse;
+
+import java.util.Map;
+
+import org.apache.camel.Endpoint;
+import org.apache.camel.component.milo.client.MiloClientCachingConnectionManager;
+import org.apache.camel.component.milo.client.MiloClientConfiguration;
+import org.apache.camel.component.milo.client.MiloClientConnectionManager;
+import org.apache.camel.spi.Metadata;
+import org.apache.camel.spi.annotations.Component;
+import org.apache.camel.support.DefaultComponent;
+
+@Component("milo-browse")
+public class MiloBrowseComponent extends DefaultComponent {
+
+    @Metadata
+    private MiloClientConfiguration configuration = new MiloClientConfiguration();
+
+    @Metadata(autowired = true, label = "client", description = "Instance for managing client connections")
+    private MiloClientConnectionManager miloClientConnectionManager = new MiloClientCachingConnectionManager();
+
+    @Override
+    protected Endpoint createEndpoint(final String uri, final String remaining, final Map<String, Object> parameters)
+            throws Exception {
+
+        final MiloClientConfiguration browseConfiguration = new MiloClientConfiguration(getConfiguration());
+        browseConfiguration.setEndpointUri(remaining);
+
+        final MiloBrowseEndpoint endpoint
+                = new MiloBrowseEndpoint(uri, this, browseConfiguration.getEndpointUri(), getMiloClientConnectionManager());
+        endpoint.setConfiguration(browseConfiguration);
+        setProperties(endpoint, parameters);
+
+        return endpoint;
+
+    }
+
+    public MiloClientConfiguration getConfiguration() {
+        return configuration;
+    }
+
+    /**
+     * All default options for client configurations
+     */
+    public void setConfiguration(final MiloClientConfiguration configuration) {
+        this.configuration = configuration;
+    }
+
+    /**
+     * Default application name
+     */
+    public void setApplicationName(final String applicationName) {
+        this.configuration.setApplicationName(applicationName);
+    }
+
+    /**
+     * Default application URI
+     */
+    public void setApplicationUri(final String applicationUri) {
+        this.configuration.setApplicationUri(applicationUri);
+    }
+
+    /**
+     * Default product URI
+     */
+    public void setProductUri(final String productUri) {
+        this.configuration.setProductUri(productUri);
+    }
+
+    /**
+     * Default reconnect timeout
+     */
+    public void setReconnectTimeout(final Long reconnectTimeout) {
+        this.configuration.setRequestTimeout(reconnectTimeout);
+    }
+
+    public MiloClientConnectionManager getMiloClientConnectionManager() {
+        return miloClientConnectionManager;
+    }
+
+    public void setMiloClientConnectionManager(MiloClientConnectionManager miloClientConnectionManager) {
+        this.miloClientConnectionManager = miloClientConnectionManager;
+    }
+}
diff --git a/components/camel-milo/src/main/java/org/apache/camel/component/milo/browse/MiloBrowseEndpoint.java b/components/camel-milo/src/main/java/org/apache/camel/component/milo/browse/MiloBrowseEndpoint.java
new file mode 100644
index 0000000..6a3c95a
--- /dev/null
+++ b/components/camel-milo/src/main/java/org/apache/camel/component/milo/browse/MiloBrowseEndpoint.java
@@ -0,0 +1,247 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.milo.browse;
+
+import java.util.Objects;
+
+import org.apache.camel.Category;
+import org.apache.camel.Consumer;
+import org.apache.camel.Processor;
+import org.apache.camel.Producer;
+import org.apache.camel.component.milo.client.MiloClientConfiguration;
+import org.apache.camel.component.milo.client.MiloClientConnection;
+import org.apache.camel.component.milo.client.MiloClientConnectionManager;
+import org.apache.camel.spi.Metadata;
+import org.apache.camel.spi.UriEndpoint;
+import org.apache.camel.spi.UriParam;
+import org.apache.camel.spi.UriPath;
+import org.apache.camel.support.DefaultEndpoint;
+import org.eclipse.milo.opcua.stack.core.Identifiers;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.BrowseDirection;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.NodeClass;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Connect to OPC UA servers using the binary protocol for browsing the node tree.
+ */
+@UriEndpoint(firstVersion = "3.15.0", scheme = "milo-browse", syntax = "milo-browse:endpointUri", title = "OPC UA Browser",
+             category = { Category.IOT }, producerOnly = true)
+public class MiloBrowseEndpoint extends DefaultEndpoint {
+
+    private static final Logger LOG = LoggerFactory.getLogger(MiloBrowseEndpoint.class);
+
+    private final MiloClientConnectionManager connectionManager;
+
+    /**
+     * The OPC UA server endpoint
+     */
+    @UriPath
+    @Metadata(required = true)
+    private final String endpointUri;
+
+    /**
+     * The node definition (see Node ID)
+     */
+    @UriParam(defaultValue = "ns=0;id=84", defaultValueNote = "Root folder as per OPC-UA spec")
+    private String node = Identifiers.RootFolder.toParseableString();
+
+    /**
+     * The direction to browse (forward, inverse, ...)
+     */
+    @UriParam(defaultValue = "Forward",
+              enums = "Forward,Inverse,Both",
+              defaultValueNote = "The direction to browse; see org.eclipse.milo.opcua.stack.core.types.enumerated.BrowseDirection")
+    private BrowseDirection direction = BrowseDirection.Forward;
+
+    /**
+     * Whether to include sub-types for browsing; only applicable for non-recursive browsing
+     */
+    @UriParam(defaultValue = "true")
+    private boolean includeSubTypes = true;
+
+    /**
+     * The mask indicating the node classes of interest in browsing
+     */
+    @UriParam(defaultValue = "Variable,Object,DataType",
+              defaultValueNote = "Comma-separated node class list; see org.eclipse.milo.opcua.stack.core.types.enumerated.NodeClass")
+    private String nodeClasses = NodeClass.Variable + "," + NodeClass.Object + "," + NodeClass.DataType;
+
+    private int nodeClassMask = NodeClass.Variable.getValue() | NodeClass.Object.getValue() | NodeClass.DataType.getValue();
+
+    /**
+     * Whether to browse recursively into sub-types, ignores includeSubTypes setting as it's implied to be set to true
+     */
+    @UriParam(defaultValue = "false",
+              defaultValueNote = "Whether to recursively browse sub-types: true|false")
+    private boolean recursive;
+
+    /**
+     * When browsing recursively into sub-types, what's the maximum search depth for diving into the tree
+     */
+    @UriParam(defaultValue = "3", defaultValueNote = "Maximum depth for browsing recursively (only if recursive = true)")
+    private int depth = 3;
+
+    /**
+     * Filter out node ids to limit browsing
+     */
+    @UriParam(defaultValue = "None", defaultValueNote = "Regular filter expression matching node ids")
+    private String filter;
+
+    /**
+     * The maximum number node ids requested per server call
+     */
+    @UriParam(defaultValue = "10",
+              defaultValueNote = "Maximum number of node ids requested per browse call (applies to browsing sub-types only; only if recursive = true)")
+    private int maxNodeIdsPerRequest = 10;
+
+    /**
+     * The client configuration
+     */
+    @UriParam
+    private MiloClientConfiguration configuration;
+
+    public MiloBrowseEndpoint(final String uri, final MiloBrowseComponent component, final String endpointUri,
+                              final MiloClientConnectionManager connectionManager) {
+        super(uri, component);
+
+        Objects.requireNonNull(component);
+        Objects.requireNonNull(endpointUri);
+        Objects.requireNonNull(connectionManager);
+
+        this.endpointUri = endpointUri;
+        this.connectionManager = connectionManager;
+    }
+
+    public void setConfiguration(MiloClientConfiguration configuration) {
+        this.configuration = configuration;
+    }
+
+    public MiloClientConfiguration getConfiguration() {
+        return configuration;
+    }
+
+    @Override
+    public Producer createProducer() throws Exception {
+        return new MiloBrowseProducer(this);
+    }
+
+    @Override
+    public Consumer createConsumer(final Processor processor) throws Exception {
+        throw new UnsupportedOperationException(MiloBrowseEndpoint.class.getName() + " doesn't support a consumer");
+    }
+
+    public MiloClientConnection createConnection() {
+        return this.connectionManager.createConnection(configuration, null);
+    }
+
+    public void releaseConnection(MiloClientConnection connection) {
+        this.connectionManager.releaseConnection(connection);
+    }
+
+    public void setNode(final String node) {
+        this.node = node;
+    }
+
+    public String getNode() {
+        return node;
+    }
+
+    NodeId getNodeId() {
+        return getNodeId(this.node);
+    }
+
+    NodeId getNodeId(String nodeId) {
+        if (nodeId != null) {
+            return NodeId.parse(nodeId);
+        } else {
+            return null;
+        }
+    }
+
+    public BrowseDirection getDirection() {
+        return direction;
+    }
+
+    public boolean isIncludeSubTypes() {
+        return includeSubTypes;
+    }
+
+    public void setIncludeSubTypes(boolean includeSubTypes) {
+        this.includeSubTypes = includeSubTypes;
+    }
+
+    public String getNodeClasses() {
+        return nodeClasses;
+    }
+
+    public void setNodeClasses(String nodeClasses) {
+        this.nodeClasses = nodeClasses;
+        final String[] nodeClassArray = nodeClasses.split(",");
+        int mask = 0;
+        try {
+            for (String nodeClass : nodeClassArray) {
+                mask |= NodeClass.valueOf(nodeClass).getValue();
+            }
+        } catch (IllegalArgumentException e) {
+            throw new IllegalArgumentException("Invalid node class specified: " + nodeClasses, e);
+        }
+        LOG.debug("Node class list conversion {} -> {}", nodeClasses, mask);
+        nodeClassMask = mask;
+    }
+
+    public int getNodeClassMask() {
+        return nodeClassMask;
+    }
+
+    public void setDirection(BrowseDirection direction) {
+        this.direction = direction;
+    }
+
+    public boolean isRecursive() {
+        return recursive;
+    }
+
+    public void setRecursive(boolean recursive) {
+        this.recursive = recursive;
+    }
+
+    public int getDepth() {
+        return depth;
+    }
+
+    public void setDepth(int depth) {
+        this.depth = depth;
+    }
+
+    public String getFilter() {
+        return filter;
+    }
+
+    public void setFilter(String filter) {
+        this.filter = filter;
+    }
+
+    public int getMaxNodeIdsPerRequest() {
+        return maxNodeIdsPerRequest;
+    }
+
+    public void setMaxNodeIdsPerRequest(int maxNodeIdsPerRequest) {
+        this.maxNodeIdsPerRequest = maxNodeIdsPerRequest;
+    }
+}
diff --git a/components/camel-milo/src/main/java/org/apache/camel/component/milo/browse/MiloBrowseProducer.java b/components/camel-milo/src/main/java/org/apache/camel/component/milo/browse/MiloBrowseProducer.java
new file mode 100644
index 0000000..bfbbf4d
--- /dev/null
+++ b/components/camel-milo/src/main/java/org/apache/camel/component/milo/browse/MiloBrowseProducer.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.milo.browse;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.camel.AsyncCallback;
+import org.apache.camel.Exchange;
+import org.apache.camel.Message;
+import org.apache.camel.component.milo.client.MiloClientConnection;
+import org.apache.camel.support.DefaultAsyncProducer;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.structured.BrowseResult;
+import org.eclipse.milo.opcua.stack.core.types.structured.ReferenceDescription;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static org.apache.camel.component.milo.NodeIds.HEADER_NODE_IDS;
+
+public class MiloBrowseProducer extends DefaultAsyncProducer {
+
+    private static final Logger LOG = LoggerFactory.getLogger(MiloBrowseProducer.class);
+
+    private MiloClientConnection connection;
+
+    public MiloBrowseProducer(final MiloBrowseEndpoint endpoint) {
+
+        super(endpoint);
+    }
+
+    @Override
+    public MiloBrowseEndpoint getEndpoint() {
+        return (MiloBrowseEndpoint) super.getEndpoint();
+    }
+
+    @Override
+    protected void doStart() throws Exception {
+        super.doStart();
+        this.connection = getEndpoint().createConnection();
+    }
+
+    @Override
+    protected void doStop() throws Exception {
+        if (null != this.connection) {
+            getEndpoint().releaseConnection(connection);
+        }
+        super.doStop();
+    }
+
+    private ExpandedNodeId tryParse(String nodeString) {
+        final Optional<NodeId> nodeId = NodeId.parseSafe(nodeString);
+        return nodeId.map(NodeId::expanded).orElseGet(() -> ExpandedNodeId.parse(nodeString));
+    }
+
+    @Override
+    public boolean process(Exchange exchange, AsyncCallback async) {
+
+        final Message message = exchange.getMessage();
+        final List<ExpandedNodeId> expandedNodeIds = new ArrayList<>();
+
+        if (message.getHeaders().containsKey(HEADER_NODE_IDS)) {
+
+            final List<?> nodes
+                    = message.getHeader(HEADER_NODE_IDS, Collections.singletonList(getEndpoint().getNode()), List.class);
+            message.removeHeader(HEADER_NODE_IDS);
+            if (null == nodes) {
+
+                LOG.warn("Browse nodes: No node ids specified");
+                async.done(true);
+                return true;
+            }
+
+            for (final Object node : nodes) {
+                expandedNodeIds.add(tryParse(node.toString()));
+            }
+        } else {
+
+            expandedNodeIds.add(tryParse(this.getEndpoint().getNode()));
+        }
+
+        final MiloBrowseEndpoint endpoint = this.getEndpoint();
+        final int depth = endpoint.isRecursive() ? endpoint.getDepth() : -1;
+        final boolean subTypes = endpoint.isIncludeSubTypes() || endpoint.isRecursive();
+
+        final CompletableFuture<?> future = this.connection
+                .browse(expandedNodeIds, endpoint.getDirection(), endpoint.getNodeClassMask(), depth, endpoint.getFilter(),
+                        subTypes, endpoint.getMaxNodeIdsPerRequest())
+
+                .thenApply(browseResults -> {
+
+                    final List<String> expandedNodes = browseResults.values().stream()
+                            .map(BrowseResult::getReferences)
+                            .flatMap(Stream::of)
+                            .map(ReferenceDescription::getNodeId)
+                            .map(ExpandedNodeId::toParseableString)
+                            .collect(Collectors.toList());
+
+                    // For convenience, to be used with the milo-client producer
+                    exchange.getMessage().setHeader(HEADER_NODE_IDS, expandedNodes);
+
+                    exchange.getMessage().setBody(browseResults);
+
+                    return browseResults;
+                })
+
+                .whenComplete((actual, error) -> {
+
+                    final String expandedNodeIdsString = expandedNodeIds.stream()
+                            .map(ExpandedNodeId::toParseableString)
+                            .collect(Collectors.joining(", "));
+
+                    if (actual != null) {
+
+                        LOG.debug("Browse node(s) {} -> {} result(s)", expandedNodeIdsString, actual.size());
+
+                    } else {
+
+                        LOG.error("Browse node(s) {} -> failed: {}", expandedNodeIdsString, error.getMessage());
+                        exchange.setException(error);
+                    }
+
+                    async.done(false);
+                });
+
+        return false;
+    }
+
+}
diff --git a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientConnection.java b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientConnection.java
index 9837a78..9bd1b93 100644
--- a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientConnection.java
+++ b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientConnection.java
@@ -17,6 +17,7 @@
 package org.apache.camel.component.milo.client;
 
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 import java.util.function.Consumer;
 
@@ -28,6 +29,8 @@ import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
 import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
 import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
 import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.BrowseDirection;
+import org.eclipse.milo.opcua.stack.core.types.structured.BrowseResult;
 import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult;
 
 import static java.util.Objects.requireNonNull;
@@ -156,4 +159,12 @@ public class MiloClientConnection implements AutoCloseable {
         return new DataValue(new Variant(value), StatusCode.GOOD, null, null);
     }
 
+    public CompletableFuture<Map<ExpandedNodeId, BrowseResult>> browse(
+            final List<ExpandedNodeId> expandedNodeIds, final BrowseDirection direction, final int nodeClasses,
+            final int maxDepth, String filter, boolean includeSubTypes, int maxNodesPerRequest) {
+        checkInit();
+
+        return this.manager.browse(expandedNodeIds, direction, nodeClasses, maxDepth, filter, includeSubTypes,
+                maxNodesPerRequest);
+    }
 }
diff --git a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientProducer.java b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientProducer.java
index 9212a0ea..2b2e82c 100644
--- a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientProducer.java
+++ b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/MiloClientProducer.java
@@ -27,11 +27,10 @@ import org.apache.camel.support.DefaultAsyncProducer;
 import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
 
 import static java.lang.Boolean.TRUE;
+import static org.apache.camel.component.milo.NodeIds.HEADER_NODE_IDS;
 
 public class MiloClientProducer extends DefaultAsyncProducer {
 
-    private static final String HEADER_NODE_IDS = "CamelMiloNodeIds";
-
     private MiloClientConnection connection;
 
     private final ExpandedNodeId nodeId;
@@ -94,6 +93,7 @@ public class MiloClientProducer extends DefaultAsyncProducer {
             future.whenComplete((v, ex) -> async.done(false));
             return false;
         } else {
+            async.done(true);
             return true;
         }
     }
diff --git a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/internal/SubscriptionManager.java b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/internal/SubscriptionManager.java
index dceca11..0627485 100644
--- a/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/internal/SubscriptionManager.java
+++ b/components/camel-milo/src/main/java/org/apache/camel/component/milo/client/internal/SubscriptionManager.java
@@ -19,6 +19,7 @@ package org.apache.camel.component.milo.client.internal;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.LinkedList;
@@ -34,10 +35,12 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.function.Consumer;
 import java.util.function.Predicate;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
 import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.component.milo.client.MiloClientConfiguration;
 import org.apache.camel.component.milo.client.MonitorFilterConfiguration;
@@ -55,6 +58,7 @@ import org.eclipse.milo.opcua.stack.core.AttributeId;
 import org.eclipse.milo.opcua.stack.core.Identifiers;
 import org.eclipse.milo.opcua.stack.core.StatusCodes;
 import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ByteString;
 import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
 import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
 import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
@@ -66,8 +70,12 @@ import org.eclipse.milo.opcua.stack.core.types.builtin.Variant;
 import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
 import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UShort;
 import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.BrowseDirection;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.BrowseResultMask;
 import org.eclipse.milo.opcua.stack.core.types.enumerated.MonitoringMode;
 import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn;
+import org.eclipse.milo.opcua.stack.core.types.structured.BrowseDescription;
+import org.eclipse.milo.opcua.stack.core.types.structured.BrowseResult;
 import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodRequest;
 import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult;
 import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription;
@@ -75,11 +83,14 @@ import org.eclipse.milo.opcua.stack.core.types.structured.MonitoredItemCreateReq
 import org.eclipse.milo.opcua.stack.core.types.structured.MonitoringFilter;
 import org.eclipse.milo.opcua.stack.core.types.structured.MonitoringParameters;
 import org.eclipse.milo.opcua.stack.core.types.structured.ReadValueId;
+import org.eclipse.milo.opcua.stack.core.types.structured.ReferenceDescription;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import static java.lang.String.format;
 import static java.util.concurrent.CompletableFuture.completedFuture;
 import static org.apache.camel.component.milo.NodeIds.toNodeId;
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
 
 public class SubscriptionManager {
 
@@ -93,7 +104,7 @@ public class SubscriptionManager {
             LOG.info("Transfer failed {} : {}", subscription.getSubscriptionId(), statusCode);
 
             // we simply tear it down and build it up again
-            handleConnectionFailue(new RuntimeCamelException("Subscription failed to reconnect"));
+            handleConnectionFailure(new RuntimeCamelException("Subscription failed to reconnect"));
         }
 
         @Override
@@ -234,7 +245,7 @@ public class SubscriptionManager {
             try {
                 putSubscriptions(subscriptions);
             } catch (final Exception e) {
-                handleConnectionFailue(e);
+                handleConnectionFailure(e);
             }
         }
 
@@ -368,6 +379,180 @@ public class SubscriptionManager {
                 return this.client.readValues(0, TimestampsToReturn.Server, nodeIds);
             });
         }
+
+        private BrowseResult filter(final BrowseResult browseResult, final Pattern pattern) {
+
+            final ReferenceDescription[] references = browseResult.getReferences();
+
+            if (null == references || null == pattern) {
+                return browseResult;
+            }
+
+            final List<ReferenceDescription> filteredReferences = new ArrayList<>();
+            for (final ReferenceDescription reference : references) {
+                final String id = reference.getNodeId().toParseableString();
+                if (!(pattern.matcher(id).matches())) {
+                    LOG.trace("Node {} excluded by filter", id);
+                    continue;
+                }
+                filteredReferences.add(reference);
+            }
+
+            return new BrowseResult(
+                    browseResult.getStatusCode(), browseResult.getContinuationPoint(),
+                    filteredReferences.toArray(new ReferenceDescription[0]));
+        }
+
+        private CompletableFuture<Map<ExpandedNodeId, BrowseResult>> flatten(
+                List<CompletableFuture<Map<ExpandedNodeId, BrowseResult>>> browseResults) {
+            return CompletableFuture.allOf(browseResults.toArray(new CompletableFuture[0]))
+                    .thenApply(__ -> browseResults
+                            .stream()
+                            .map(CompletableFuture::join)
+                            .map(Map::entrySet)
+                            .flatMap(Set::stream)
+                            .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (e1, e2) -> e2)));
+        }
+
+        // Browse at continuation point if any
+        public CompletableFuture<BrowseResult> browse(BrowseResult previousBrowseResult) {
+
+            final ByteString continuationPoint = previousBrowseResult.getContinuationPoint();
+
+            if (previousBrowseResult.getStatusCode().isGood() && continuationPoint.isNotNull()) {
+
+                return this.client.browseNext(false, continuationPoint)
+
+                        .thenCompose(browseResult -> {
+
+                            final ReferenceDescription[] previousReferences = previousBrowseResult.getReferences();
+                            final ReferenceDescription[] references = browseResult.getReferences();
+
+                            if (null == references) {
+
+                                LOG.info("Browse continuation point -> no references");
+                                return completedFuture(previousBrowseResult);
+                            } else if (null == previousReferences) {
+
+                                LOG.info("Browse continuation point -> previous references not obtained");
+                                return completedFuture(browseResult);
+                            }
+
+                            final ReferenceDescription[] combined
+                                    = Arrays.copyOf(previousReferences, previousReferences.length + references.length);
+                            System.arraycopy(references, 0, combined, previousReferences.length, references.length);
+
+                            LOG.debug("Browse continuation point -> {}: {} reference(s); total: {} reference(s)",
+                                    browseResult.getStatusCode(), references.length, combined.length);
+
+                            return browse(new BrowseResult(
+                                    browseResult.getStatusCode(), browseResult.getContinuationPoint(), combined));
+                        });
+
+            } else {
+
+                return completedFuture(previousBrowseResult);
+            }
+        }
+
+        // Browse a single node, retrieve additional results, filter node ids and eventually browse deeper into the tree
+        public CompletableFuture<Map<ExpandedNodeId, BrowseResult>> browse(
+                BrowseDescription browseDescription, BrowseResult browseResult, int depth, int maxDepth, Pattern pattern,
+                int maxNodesPerRequest) {
+
+            return browse(browseResult)
+
+                    .thenCompose(preliminary -> completedFuture(filter(preliminary, pattern)))
+
+                    .thenCompose(filtered -> {
+
+                        final ExpandedNodeId expandedNodeId = browseDescription.getNodeId().expanded();
+                        final Map<ExpandedNodeId, BrowseResult> root = Collections.singletonMap(expandedNodeId, filtered);
+                        final CompletableFuture<Map<ExpandedNodeId, BrowseResult>> finalFuture = completedFuture(root);
+                        final ReferenceDescription[] references = filtered.getReferences();
+
+                        if (depth >= maxDepth || null == references) {
+                            return finalFuture;
+                        }
+
+                        final List<CompletableFuture<Map<ExpandedNodeId, BrowseResult>>> futures = new ArrayList<>();
+
+                        // Save current node
+                        futures.add(finalFuture);
+
+                        final List<ExpandedNodeId> nodeIds = Stream.of(references)
+                                .map(ReferenceDescription::getNodeId).collect(Collectors.toList());
+
+                        final List<List<ExpandedNodeId>> lists = Lists.partition(nodeIds, maxNodesPerRequest);
+                        for (final List<ExpandedNodeId> list : lists) {
+                            futures.add(browse(list, browseDescription.getBrowseDirection(),
+                                    browseDescription.getNodeClassMask().intValue(), depth + 1, maxDepth, pattern,
+                                    browseDescription.getIncludeSubtypes(), maxNodesPerRequest));
+                        }
+
+                        return flatten(futures);
+                    });
+        }
+
+        // Browse according to a list of browse descriptions
+        public CompletableFuture<Map<ExpandedNodeId, BrowseResult>> browse(
+                List<BrowseDescription> browseDescriptions,
+                int depth, int maxDepth, Pattern pattern, int maxNodesPerRequest) {
+
+            return this.client.browse(browseDescriptions)
+
+                    .thenCompose(partials -> {
+
+                        // Fail a bit more gracefully in case of missing results
+                        if (partials.size() != browseDescriptions.size()) {
+
+                            final CompletableFuture<Map<ExpandedNodeId, BrowseResult>> failedFuture = new CompletableFuture<>();
+                            failedFuture.completeExceptionally(new IllegalArgumentException(
+                                    format("Invalid number of browse results: %s, expected %s",
+                                            partials.size(), browseDescriptions.size())));
+                            return failedFuture;
+
+                            /* @TODO: Replace with Java 9 functionality like follows
+                            return CompletableFuture.failedFuture(new IllegalArgumentException(
+                                    format("Invalid number of browse results: %s, expected %s", partials.size(),
+                                            browseDescriptions.size()))); */
+                        }
+
+                        final List<CompletableFuture<Map<ExpandedNodeId, BrowseResult>>> futures = new ArrayList<>();
+
+                        for (int i = 0; i < partials.size(); i++) {
+
+                            futures.add(browse(browseDescriptions.get(i), partials.get(i), depth, maxDepth, pattern,
+                                    maxNodesPerRequest));
+                        }
+
+                        return flatten(futures);
+                    });
+        }
+
+        // Wrapper for looking up nodes and instantiating initial browse descriptions according to the configuration provided
+        @SuppressWarnings("unchecked")
+        public CompletableFuture<Map<ExpandedNodeId, BrowseResult>> browse(
+                List<ExpandedNodeId> expandedNodeIds, BrowseDirection direction, int nodeClasses, int depth, int maxDepth,
+                Pattern pattern, boolean includeSubTypes, int maxNodesPerRequest) {
+
+            final CompletableFuture<NodeId>[] futures = expandedNodeIds.stream().map(this::lookupNamespace)
+                    .toArray(CompletableFuture[]::new);
+
+            return CompletableFuture.allOf(futures)
+
+                    .thenCompose(__ -> {
+
+                        final List<NodeId> nodeIds = Stream.of(futures).map(CompletableFuture::join)
+                                .collect(Collectors.toList());
+
+                        return completedFuture(nodeIds.stream().map(nodeId -> new BrowseDescription(
+                                nodeId, direction, Identifiers.References, includeSubTypes, uint(nodeClasses),
+                                uint(BrowseResultMask.All.getValue()))).collect(Collectors.toList()));
+                    })
+
+                    .thenCompose(descriptions -> browse(descriptions, depth, maxDepth, pattern, maxNodesPerRequest));
+        }
     }
 
     private final MiloClientConfiguration configuration;
@@ -389,7 +574,7 @@ public class SubscriptionManager {
         connect();
     }
 
-    private synchronized void handleConnectionFailue(final Throwable e) {
+    private synchronized void handleConnectionFailure(final Throwable e) {
         if (this.connected != null) {
             this.connected.dispose();
             this.connected = null;
@@ -456,7 +641,7 @@ public class SubscriptionManager {
 
     private Connected performConnect() throws Exception {
 
-        // eval enpoint
+        // eval endpoint
 
         String discoveryUri = getEndpointDiscoveryUri();
 
@@ -473,7 +658,7 @@ public class SubscriptionManager {
 
         final EndpointDescription endpoint = DiscoveryClient.getEndpoints(discoveryUri).thenApply(endpoints -> {
             if (LOG.isDebugEnabled()) {
-                LOG.debug("Found enpoints:");
+                LOG.debug("Found endpoints:");
                 for (final EndpointDescription ep : endpoints) {
                     LOG.debug("\t{}", ep);
                 }
@@ -649,7 +834,7 @@ public class SubscriptionManager {
             try {
                 worker.work(this.connected);
             } catch (final Exception e) {
-                handleConnectionFailue(e);
+                handleConnectionFailure(e);
             }
         }
     }
@@ -697,7 +882,7 @@ public class SubscriptionManager {
                 // handle outside the lock, running using
                 // handleAsync
                 if (e != null) {
-                    handleConnectionFailue(e);
+                    handleConnectionFailure(e);
                 }
                 return null;
             }, this.executor);
@@ -714,7 +899,7 @@ public class SubscriptionManager {
                 // handle outside the lock, running using
                 // handleAsync
                 if (e != null) {
-                    handleConnectionFailue(e);
+                    handleConnectionFailure(e);
                 }
                 return null;
             }, this.executor);
@@ -731,11 +916,31 @@ public class SubscriptionManager {
                 // handle outside the lock, running using
                 // handleAsync
                 if (e != null) {
-                    handleConnectionFailue(e);
+                    handleConnectionFailure(e);
                 }
                 return nodes;
             }, this.executor);
         }
     }
 
+    public CompletableFuture<Map<ExpandedNodeId, BrowseResult>> browse(
+            List<ExpandedNodeId> expandedNodeIds, BrowseDirection direction, int nodeClasses, int maxDepth, String filter,
+            boolean includeSubTypes, int maxNodesPerRequest) {
+        synchronized (this) {
+            if (this.connected == null) {
+                return newNotConnectedResult();
+            }
+
+            return this.connected.browse(expandedNodeIds, direction, nodeClasses, 1, maxDepth,
+                    null != filter ? Pattern.compile(filter) : null, includeSubTypes, maxNodesPerRequest)
+                    .handleAsync((browseResult, e) -> {
+                        // handle outside the lock, running using
+                        // handleAsync
+                        if (e != null) {
+                            handleConnectionFailure(e);
+                        }
+                        return browseResult;
+                    }, this.executor);
+        }
+    }
 }
diff --git a/components/camel-milo/src/test/java/org/apache/camel/component/milo/AbstractMiloServerTest.java b/components/camel-milo/src/test/java/org/apache/camel/component/milo/AbstractMiloServerTest.java
index 7e54cfa..c4677ba 100644
--- a/components/camel-milo/src/test/java/org/apache/camel/component/milo/AbstractMiloServerTest.java
+++ b/components/camel-milo/src/test/java/org/apache/camel/component/milo/AbstractMiloServerTest.java
@@ -21,6 +21,8 @@ import java.security.GeneralSecurityException;
 import java.util.function.Consumer;
 
 import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.Predicate;
 import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.component.milo.server.MiloServerComponent;
 import org.apache.camel.component.mock.AssertionClause;
@@ -28,6 +30,9 @@ import org.apache.camel.test.AvailablePortFinder;
 import org.apache.camel.test.junit5.CamelTestSupport;
 import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy;
 import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
+import org.opentest4j.AssertionFailedError;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -37,6 +42,8 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue;
 
 public abstract class AbstractMiloServerTest extends CamelTestSupport {
 
+    private static final Logger LOG = LoggerFactory.getLogger(AbstractMiloServerTest.class);
+
     private int serverPort;
 
     @Override
@@ -153,4 +160,17 @@ public abstract class AbstractMiloServerTest extends CamelTestSupport {
         return false;
     }
 
+    protected Predicate assertPredicate(Consumer<Exchange> consumer) {
+
+        return exchange -> {
+            try {
+                consumer.accept(exchange);
+                return true;
+            } catch (AssertionFailedError error) {
+                LOG.error("Assertion error: " + error.getMessage(), error);
+                return false;
+            }
+        };
+    }
+
 }
diff --git a/components/camel-milo/src/test/java/org/apache/camel/component/milo/browse/BrowseServerTest.java b/components/camel-milo/src/test/java/org/apache/camel/component/milo/browse/BrowseServerTest.java
new file mode 100644
index 0000000..f4ea89e
--- /dev/null
+++ b/components/camel-milo/src/test/java/org/apache/camel/component/milo/browse/BrowseServerTest.java
@@ -0,0 +1,381 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.milo.browse;
+
+import java.nio.CharBuffer;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.EndpointInject;
+import org.apache.camel.Exchange;
+import org.apache.camel.Message;
+import org.apache.camel.Produce;
+import org.apache.camel.ProducerTemplate;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.ExchangeBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.milo.AbstractMiloServerTest;
+import org.apache.camel.component.milo.NodeIds;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.eclipse.milo.opcua.stack.core.Identifiers;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId;
+import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
+import org.eclipse.milo.opcua.stack.core.types.structured.BrowseResult;
+import org.eclipse.milo.opcua.stack.core.types.structured.ReferenceDescription;
+import org.junit.jupiter.api.Test;
+
+import static org.apache.camel.component.mock.MockEndpoint.assertIsSatisfied;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+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.assertTrue;
+
+/**
+ * Unit tests for browsing
+ */
+public class BrowseServerTest extends AbstractMiloServerTest {
+
+    private static final String DIRECT_START_1 = "direct:start1";
+    private static final String DIRECT_START_2 = "direct:start2";
+    private static final String DIRECT_START_3 = "direct:start3";
+    private static final String DIRECT_START_4 = "direct:start4";
+    private static final String DIRECT_START_5 = "direct:start5";
+    private static final String DIRECT_START_6 = "direct:start6";
+    private static final String DIRECT_START_7 = "direct:start7";
+    private static final String DIRECT_START_8 = "direct:start8";
+    private static final String DIRECT_START_9 = "direct:start9";
+
+    private static final String MOCK_TEST_1 = "mock:test1";
+    private static final String MOCK_TEST_2 = "mock:test2";
+    private static final String MOCK_TEST_3 = "mock:test3";
+    private static final String MOCK_TEST_4 = "mock:test4";
+    private static final String MOCK_TEST_5 = "mock:test5";
+    private static final String MOCK_TEST_6 = "mock:test6";
+    private static final String MOCK_TEST_7 = "mock:test7";
+    private static final String MOCK_TEST_8 = "mock:test8";
+    private static final String MOCK_TEST_9 = "mock:test9";
+
+    private static final String MILO_BROWSE_BASE
+            = "milo-browse:opc.tcp://foo:bar@127.0.0.1:@@port@@";
+
+    private static final String MILO_BROWSE_ROOT
+            = MILO_BROWSE_BASE + "?overrideHost=true&allowedSecurityPolicies=None";
+
+    private static final String MILO_BROWSE_WITHOUT_SUB_TYPES
+            = MILO_BROWSE_ROOT + "&includeSubTypes=false";
+
+    private static final String MILO_BROWSE_ROOT_RECURSIVE_2
+            = MILO_BROWSE_ROOT + "&recursive=true&depth=2";
+
+    private static final String MILO_BROWSE_ROOT_RECURSIVE_2_ONE_ID_PER_REQ
+            = MILO_BROWSE_ROOT + "&recursive=true&depth=2&maxNodeIdsPerRequest=1";
+
+    private static final String MILO_BROWSE_ROOT_RECURSIVE_FILTER
+            = MILO_BROWSE_ROOT + "&recursive=true&depth=2&filter=.*i=8[6,8].*";
+
+    private static final String MILO_BROWSE_INVERSE
+            = MILO_BROWSE_ROOT + "&direction=Inverse";
+
+    private static final String MILO_BROWSE_TYPES_ONLY
+            = MILO_BROWSE_ROOT + "&nodeClasses=Object,Variable,DataType&recursive=true&depth=5";
+
+    private static final String MILO_BROWSE_NO_TYPES
+            = MILO_BROWSE_ROOT + "&nodeClasses=Variable&recursive=true&depth=5";
+
+    private static final String MILO_BROWSE_NODE_VIA_ENDPOINT
+            = MILO_BROWSE_ROOT + "&node=RAW(ns=0;i=86)";
+
+    @EndpointInject(MOCK_TEST_1)
+    protected MockEndpoint mock1;
+
+    @EndpointInject(MOCK_TEST_2)
+    protected MockEndpoint mock2;
+
+    @EndpointInject(MOCK_TEST_3)
+    protected MockEndpoint mock3;
+
+    @EndpointInject(MOCK_TEST_4)
+    protected MockEndpoint mock4;
+
+    @EndpointInject(MOCK_TEST_5)
+    protected MockEndpoint mock5;
+
+    @EndpointInject(MOCK_TEST_6)
+    protected MockEndpoint mock6;
+
+    @EndpointInject(MOCK_TEST_7)
+    protected MockEndpoint mock7;
+
+    @EndpointInject(MOCK_TEST_8)
+    protected MockEndpoint mock8;
+
+    @EndpointInject(MOCK_TEST_9)
+    protected MockEndpoint mock9;
+
+    @Produce(DIRECT_START_1)
+    protected ProducerTemplate producer1;
+
+    @Produce(DIRECT_START_2)
+    protected ProducerTemplate producer2;
+
+    @Produce(DIRECT_START_3)
+    protected ProducerTemplate producer3;
+
+    @Produce(DIRECT_START_4)
+    protected ProducerTemplate producer4;
+
+    @Produce(DIRECT_START_5)
+    protected ProducerTemplate producer5;
+
+    @Produce(DIRECT_START_6)
+    protected ProducerTemplate producer6;
+
+    @Produce(DIRECT_START_7)
+    protected ProducerTemplate producer7;
+
+    @Produce(DIRECT_START_8)
+    protected ProducerTemplate producer8;
+
+    @Produce(DIRECT_START_9)
+    protected ProducerTemplate producer9;
+
+    @Override
+    protected RoutesBuilder createRouteBuilder() throws Exception {
+        return new RouteBuilder() {
+            @Override
+            public void configure() throws Exception {
+                from(DIRECT_START_1).enrich(resolve(MILO_BROWSE_ROOT)).to(MOCK_TEST_1);
+                from(DIRECT_START_2).enrich(resolve(MILO_BROWSE_WITHOUT_SUB_TYPES)).to(MOCK_TEST_2);
+                from(DIRECT_START_3).enrich(resolve(MILO_BROWSE_ROOT_RECURSIVE_2)).to(MOCK_TEST_3);
+                from(DIRECT_START_4).enrich(resolve(MILO_BROWSE_ROOT_RECURSIVE_FILTER)).to(MOCK_TEST_4);
+                from(DIRECT_START_5).enrich(resolve(MILO_BROWSE_INVERSE)).to(MOCK_TEST_5);
+                from(DIRECT_START_6).enrich(resolve(MILO_BROWSE_TYPES_ONLY)).to(MOCK_TEST_6);
+                from(DIRECT_START_7).enrich(resolve(MILO_BROWSE_NO_TYPES)).to(MOCK_TEST_7);
+                from(DIRECT_START_8).enrich(resolve(MILO_BROWSE_NODE_VIA_ENDPOINT)).to(MOCK_TEST_8);
+                from(DIRECT_START_9).enrich(resolve(MILO_BROWSE_ROOT_RECURSIVE_2_ONE_ID_PER_REQ)).to(MOCK_TEST_9);
+            }
+        };
+    }
+
+    private void assertBrowseResult(final BrowseResult browseResult, final String... expectedDisplayNames) {
+
+        assertNotNull(browseResult);
+
+        assertTrue(browseResult.getStatusCode().isGood());
+        assertFalse(browseResult.getStatusCode().isBad());
+
+        final ReferenceDescription[] references = browseResult.getReferences();
+
+        if (null == expectedDisplayNames || expectedDisplayNames.length == 0) {
+
+            assertTrue(references == null || references.length == 0);
+        } else {
+
+            assertNotNull(references);
+
+            final String[] displayNames = Arrays.stream(references).map(ReferenceDescription::getDisplayName)
+                    .map(LocalizedText::getText).toArray(String[]::new);
+
+            assertArrayEquals(expectedDisplayNames, displayNames);
+        }
+    }
+
+    private void assertBrowseResult(
+            final Map<ExpandedNodeId, BrowseResult> browseResults, final String... expectedDisplayNames) {
+
+        assertEquals(1, browseResults.values().size());
+        assertBrowseResult(browseResults.values().toArray(new BrowseResult[0])[0], expectedDisplayNames);
+    }
+
+    @SuppressWarnings("unchecked")
+    private void assertBrowseResult(final Message message, final String... expectedDisplayNames) {
+
+        final Map<ExpandedNodeId, BrowseResult> browseResults = (Map<ExpandedNodeId, BrowseResult>) message.getBody(Map.class);
+        assertNotNull(browseResults);
+
+        assertBrowseResult(browseResults, expectedDisplayNames);
+    }
+
+    @SuppressWarnings("unchecked")
+    private void assertBrowseResults(final Message message, final int expectedNumberOfResults, final int expectedNumberOfIds) {
+
+        final Map<ExpandedNodeId, BrowseResult> browseResults = (Map<ExpandedNodeId, BrowseResult>) message.getBody(Map.class);
+        assertNotNull(browseResults);
+
+        final List<?> nodes = message.getHeader(NodeIds.HEADER_NODE_IDS, List.class);
+        assertNotNull(nodes);
+
+        assertEquals(expectedNumberOfResults, browseResults.keySet().size());
+        assertEquals(expectedNumberOfIds, expectedNumberOfIds);
+    }
+
+    @SuppressWarnings("unused") // For debugging tests
+    private void visualizeTree(
+            final Map<ExpandedNodeId, BrowseResult> browseResults, final ExpandedNodeId expandedNodeId,
+            final String displayName, final StringBuilder builder, int depth) {
+
+        BrowseResult browseResult = browseResults.get(expandedNodeId);
+        if (null == browseResult) {
+            return;
+        }
+        String indent = CharBuffer.allocate(depth * 3).toString().replace('\0', ' ');
+        builder.append(indent).append(expandedNodeId.toParseableString()).append(" (").append(displayName).append(")")
+                .append(System.lineSeparator());
+        if (null != browseResult.getReferences()) {
+            for (final ReferenceDescription referenceDescription : browseResult.getReferences()) {
+                visualizeTree(browseResults, referenceDescription.getNodeId(), referenceDescription.getDisplayName().getText(),
+                        builder, depth + 1);
+            }
+        }
+
+    }
+
+    // Test default behaviour (browsing root node)
+    @Test
+    public void testBrowseRoot() throws Exception {
+
+        mock1.reset();
+        mock1.setExpectedCount(1);
+        mock1.expectedMessagesMatches(assertPredicate(e -> assertBrowseResult(e.getMessage(),
+                "Objects", "Types", "Views")));
+        producer1.send(ExchangeBuilder.anExchange(context).build());
+        assertIsSatisfied(5, TimeUnit.SECONDS, mock1);
+    }
+
+    // Test explicit node specification (types folder) via header field
+    @Test
+    public void testBrowseTypesHeader() throws Exception {
+
+        mock1.reset();
+        mock1.setExpectedCount(1);
+        mock1.expectedMessagesMatches(assertPredicate(e -> assertBrowseResult(e.getMessage(),
+                "ObjectTypes", "VariableTypes", "DataTypes", "ReferenceTypes", "EventTypes")));
+        producer1.send(ExchangeBuilder.anExchange(context)
+                .withHeader(NodeIds.HEADER_NODE_IDS, Collections.singletonList(Identifiers.TypesFolder.toParseableString()))
+                .build());
+        assertIsSatisfied(5, TimeUnit.SECONDS, mock1);
+    }
+
+    // Test explicit node specification (types folder) via endpoint parameter
+    @Test
+    public void testBrowseTypesEndpoint() throws Exception {
+
+        mock8.reset();
+        mock8.setExpectedCount(1);
+        mock8.expectedMessagesMatches(assertPredicate(e -> assertBrowseResult(e.getMessage(),
+                "ObjectTypes", "VariableTypes", "DataTypes", "ReferenceTypes", "EventTypes")));
+        producer8.send(ExchangeBuilder.anExchange(context).build());
+        assertIsSatisfied(5, TimeUnit.SECONDS, mock8);
+    }
+
+    // Test that reference array is empty, if indicated that sub types are not to be included (non-recursive browse only)
+    @Test
+    public void testBrowseWithoutSubTypes() throws Exception {
+
+        mock2.reset();
+        mock2.setExpectedCount(1);
+        mock2.expectedMessagesMatches(assertPredicate(e -> assertBrowseResult(e.getMessage())));
+        producer2.send(ExchangeBuilder.anExchange(context).build());
+        assertIsSatisfied(5, TimeUnit.SECONDS, mock2);
+    }
+
+    // Test recursive browse option with maximum depth of two
+    @Test
+    public void testBrowseRecursive() throws Exception {
+
+        mock3.reset();
+        mock3.setExpectedCount(1);
+        mock3.expectedMessagesMatches(assertPredicate(e -> assertBrowseResults(e.getMessage(), 4, 10)));
+        producer3.send(ExchangeBuilder.anExchange(context).build());
+        assertIsSatisfied(5, TimeUnit.SECONDS, mock3);
+    }
+
+    // Test recursive browse option with maximum depth of two; just one node per request should result in the same results
+    @Test
+    public void testBrowseRecursiveOneNodePerRequest() throws Exception {
+
+        mock9.reset();
+        mock9.setExpectedCount(1);
+        mock9.expectedMessagesMatches(assertPredicate(e -> assertBrowseResults(e.getMessage(), 4, 10)));
+        producer9.send(ExchangeBuilder.anExchange(context).build());
+        assertIsSatisfied(5, TimeUnit.SECONDS, mock9);
+    }
+
+    // Test filter option while browsing recursively (it's expected to work on both levels, base node as well as sub nodes)
+    @Test
+    public void testBrowseRecursiveFilter() throws Exception {
+
+        mock4.reset();
+        mock4.setExpectedCount(1);
+        mock4.expectedMessagesMatches(assertPredicate(e -> assertBrowseResults(e.getMessage(), 2, 2)));
+        producer4.send(ExchangeBuilder.anExchange(context).build());
+        assertIsSatisfied(5, TimeUnit.SECONDS, mock4);
+    }
+
+    // Test direction option while browsing (back to root, from types folder)
+    @Test
+    public void testBrowseInverse() throws Exception {
+
+        mock5.reset();
+        mock5.setExpectedCount(1);
+        mock5.expectedMessagesMatches(assertPredicate(e -> assertBrowseResult(e.getMessage(), "Root")));
+        producer5.send(ExchangeBuilder.anExchange(context)
+                .withHeader(NodeIds.HEADER_NODE_IDS, Collections.singletonList(Identifiers.TypesFolder.toParseableString()))
+                .build());
+        assertIsSatisfied(5, TimeUnit.SECONDS, mock5);
+    }
+
+    // Test empty answer when browsing invalid node
+    @Test
+    public void testBrowseInvalid() throws Exception {
+        mock1.reset();
+        mock1.setExpectedCount(0);
+        final Exchange exchange = producer1.send(ExchangeBuilder.anExchange(context)
+                .withHeader(NodeIds.HEADER_NODE_IDS, Collections.singletonList("invalidNodeId"))
+                .build());
+        assertIsSatisfied(5, TimeUnit.SECONDS, mock1);
+        assertNotNull(exchange.getException());
+    }
+
+    // Test node classes option, searching for types
+    @Test
+    public void testBrowseTypesClass() throws Exception {
+        mock6.reset();
+        mock6.setExpectedCount(1);
+        mock6.expectedMessagesMatches(assertPredicate(e -> assertBrowseResults(e.getMessage(), 9, 9)));
+        producer6.send(ExchangeBuilder.anExchange(context)
+                .withHeader(NodeIds.HEADER_NODE_IDS, Collections.singletonList(Identifiers.String.toParseableString()))
+                .build());
+        assertIsSatisfied(5, TimeUnit.SECONDS, mock6);
+    }
+
+    // Test node classes option, not searching for types
+    @Test
+    public void testBrowseNoTypesClass() throws Exception {
+        mock7.reset();
+        mock7.setExpectedCount(1);
+        mock7.expectedMessagesMatches(assertPredicate(e -> assertBrowseResults(e.getMessage(), 1, 0)));
+        producer7.send(ExchangeBuilder.anExchange(context)
+                .withHeader(NodeIds.HEADER_NODE_IDS, Collections.singletonList(Identifiers.String.toParseableString()))
+                .build());
+        assertIsSatisfied(5, TimeUnit.SECONDS, mock7);
+    }
+}
diff --git a/components/camel-milo/src/test/java/org/apache/camel/component/milo/call/MockCamelNamespace.java b/components/camel-milo/src/test/java/org/apache/camel/component/milo/call/MockCamelNamespace.java
index ebb58e6..70bbe26 100644
--- a/components/camel-milo/src/test/java/org/apache/camel/component/milo/call/MockCamelNamespace.java
+++ b/components/camel-milo/src/test/java/org/apache/camel/component/milo/call/MockCamelNamespace.java
@@ -112,7 +112,6 @@ public class MockCamelNamespace extends ManagedNamespace {
                 methodNode.getNodeId(),
                 Identifiers.HasComponent,
                 folderNode.getNodeId().expanded(),
-                folderNode.getNodeClass(),
                 false));
     }
 
diff --git a/components/camel-milo/src/test/resources/log4j2.properties b/components/camel-milo/src/test/resources/log4j2.properties
index 68f30b8..dcb705d 100644
--- a/components/camel-milo/src/test/resources/log4j2.properties
+++ b/components/camel-milo/src/test/resources/log4j2.properties
@@ -26,3 +26,9 @@ appender.out.layout.type = PatternLayout
 appender.out.layout.pattern = %d [%-15.15t] %-5p %-30.30c{1} - %m%n
 rootLogger.level = INFO
 rootLogger.appenderRef.file.ref = file
+
+logger.internal.name = org.apache.camel.component.milo.client.internal
+logger.internal.level = INFO
+
+logger.browse.name = org.apache.camel.component.milo.browse
+logger.browse.level = INFO