Repository: eclipse/leshan
Pull request #858 (2020-06-26 16:29:55)

Add Bootstrap Discover Request support.
This PR add support for Bootstrap discover request.

This is fully implemented at client side.  
At server side this allow to customize `BootstrapHandler` behavior to send `BootstrapDiscoverRequest` if needed but the API should maybe be improved. I don't know really how for now ...  
This is how this looks like : 
```java
LeshanBootstrapServerBuilder builder = new LeshanBootstrapServerBuilder();
builder.setBootstrapHandlerFactory(new BootstrapHandlerFactory() {
    @Override
    public BootstrapHandler create(BootstrapConfigStore store, LwM2mBootstrapRequestSender sender,
            BootstrapSessionManager sessionManager) {
        return new DefaultBootstrapHandler(store, sender, sessionManager) {
            @Override
            protected void startBootstrap(final BootstrapSession session, final BootstrapConfig cfg) {
                send(session, request, new SafeResponseCallback<BootstrapDiscoverResponse>(session) {
                    @Override
                    public void safeOnResponse(BootstrapDiscoverResponse response) {
                        // Do what you want depending on response value.

                       // To continue bootstrap session
                       // (if you don't use anonymous class this is probably better to call super.startBootstrap())
                        delete(session, cfg, new ArrayList<>(cfg.toDelete));
                    }
                }, new SafeErrorCallback(session) {
                    @Override
                    public void safeOnError(Exception e) {
                        // do what you want on failure, here we just stop the session.
                        stopSession(session, BootstrapFailureCause.INTERNAL_SERVER_ERROR);
                    }
                });
            }
        };
    }
});
```


Patch:
diff --git a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/RootResource.java b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/RootResource.java
index d705d188..9fc87006 100644
--- a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/RootResource.java
+++ b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/RootResource.java
@@ -23,13 +23,17 @@ import java.util.List;
 import org.eclipse.californium.core.CoapResource;
 import org.eclipse.californium.core.CoapServer;
 import org.eclipse.californium.core.coap.CoAP.ResponseCode;
+import org.eclipse.californium.core.coap.MediaTypeRegistry;
 import org.eclipse.californium.core.network.Endpoint;
 import org.eclipse.californium.core.server.resources.CoapExchange;
 import org.eclipse.leshan.client.bootstrap.BootstrapHandler;
 import org.eclipse.leshan.client.engine.RegistrationEngine;
 import org.eclipse.leshan.client.servers.ServerIdentity;
+import org.eclipse.leshan.core.Link;
 import org.eclipse.leshan.core.request.BootstrapDeleteRequest;
+import org.eclipse.leshan.core.request.BootstrapDiscoverRequest;
 import org.eclipse.leshan.core.response.BootstrapDeleteResponse;
+import org.eclipse.leshan.core.response.BootstrapDiscoverResponse;
 import org.eclipse.leshan.core.util.StringUtils;
 
 /**
@@ -50,7 +54,21 @@ public class RootResource extends LwM2mClientCoapResource {
 
     @Override
     public void handleGET(CoapExchange exchange) {
-        exchange.respond(ResponseCode.NOT_FOUND);
+        ServerIdentity identity = getServerOrRejectRequest(exchange);
+        if (identity == null)
+            return;
+
+        String URI = exchange.getRequestOptions().getUriPathString();
+
+        // Manage Bootstrap Discover Request
+        BootstrapDiscoverResponse response = bootstrapHandler.discover(identity, new BootstrapDiscoverRequest(URI));
+        if (response.getCode().isError()) {
+            exchange.respond(toCoapResponseCode(response.getCode()), response.getErrorMessage());
+        } else {
+            exchange.respond(toCoapResponseCode(response.getCode()), Link.serialize(response.getObjectLinks()),
+                    MediaTypeRegistry.APPLICATION_LINK_FORMAT);
+        }
+        return;
     }
 
     @Override
diff --git a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ObjectResource.java b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ObjectResource.java
index 318f9130..f60d433b 100644
--- a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ObjectResource.java
+++ b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ObjectResource.java
@@ -30,6 +30,7 @@ import org.eclipse.californium.core.server.resources.Resource;
 import org.eclipse.leshan.client.californium.LwM2mClientCoapResource;
 import org.eclipse.leshan.client.engine.RegistrationEngine;
 import org.eclipse.leshan.client.resource.LwM2mObjectEnabler;
+import org.eclipse.leshan.client.resource.LwM2mObjectEnabler2;
 import org.eclipse.leshan.client.resource.listener.ObjectListener;
 import org.eclipse.leshan.client.servers.ServerIdentity;
 import org.eclipse.leshan.core.Link;
@@ -45,6 +46,7 @@ import org.eclipse.leshan.core.node.codec.CodecException;
 import org.eclipse.leshan.core.node.codec.LwM2mNodeDecoder;
 import org.eclipse.leshan.core.node.codec.LwM2mNodeEncoder;
 import org.eclipse.leshan.core.request.BootstrapDeleteRequest;
+import org.eclipse.leshan.core.request.BootstrapDiscoverRequest;
 import org.eclipse.leshan.core.request.BootstrapWriteRequest;
 import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.CreateRequest;
@@ -58,6 +60,7 @@ import org.eclipse.leshan.core.request.WriteAttributesRequest;
 import org.eclipse.leshan.core.request.WriteRequest;
 import org.eclipse.leshan.core.request.WriteRequest.Mode;
 import org.eclipse.leshan.core.response.BootstrapDeleteResponse;
+import org.eclipse.leshan.core.response.BootstrapDiscoverResponse;
 import org.eclipse.leshan.core.response.BootstrapWriteResponse;
 import org.eclipse.leshan.core.response.CreateResponse;
 import org.eclipse.leshan.core.response.DeleteResponse;
@@ -67,12 +70,16 @@ import org.eclipse.leshan.core.response.ObserveResponse;
 import org.eclipse.leshan.core.response.ReadResponse;
 import org.eclipse.leshan.core.response.WriteAttributesResponse;
 import org.eclipse.leshan.core.response.WriteResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * A CoAP {@link Resource} in charge of handling requests targeting a lwM2M Object.
  */
 public class ObjectResource extends LwM2mClientCoapResource implements ObjectListener {
 
+    private static final Logger LOG = LoggerFactory.getLogger(ObjectResource.class);
+
     protected final LwM2mObjectEnabler nodeEnabler;
     protected final LwM2mNodeEncoder encoder;
     protected final LwM2mNodeDecoder decoder;
@@ -95,8 +102,27 @@ public class ObjectResource extends LwM2mClientCoapResource implements ObjectLis
 
         String URI = exchange.getRequestOptions().getUriPathString();
 
-        // Manage Discover Request
         if (exchange.getRequestOptions().getAccept() == MediaTypeRegistry.APPLICATION_LINK_FORMAT) {
+            // Manage Bootstrap Discover Request
+            if (identity.isLwm2mBootstrapServer()) {
+                if (nodeEnabler instanceof LwM2mObjectEnabler2) {
+                    BootstrapDiscoverResponse response = ((LwM2mObjectEnabler2) nodeEnabler).discover(identity,
+                            new BootstrapDiscoverRequest(URI));
+                    if (response.getCode().isError()) {
+                        exchange.respond(toCoapResponseCode(response.getCode()), response.getErrorMessage());
+                    } else {
+                        exchange.respond(toCoapResponseCode(response.getCode()),
+                                Link.serialize(response.getObjectLinks()), MediaTypeRegistry.APPLICATION_LINK_FORMAT);
+                    }
+                    return;
+                }
+            } else {
+                LOG.warn(
+                        "Your LwM2mObjectEnabler2 {} does not support Bootstrap discover request, you should consider to make it implements LwM2mObjectEnabler2",
+                        nodeEnabler.getClass().getName());
+                // continue with classic discover for backward compatibility.
+            }
+            // Manage Discover Request
             DiscoverResponse response = nodeEnabler.discover(identity, new DiscoverRequest(URI));
             if (response.getCode().isError()) {
                 exchange.respond(toCoapResponseCode(response.getCode()), response.getErrorMessage());
@@ -104,6 +130,7 @@ public class ObjectResource extends LwM2mClientCoapResource implements ObjectLis
                 exchange.respond(toCoapResponseCode(response.getCode()), Link.serialize(response.getObjectLinks()),
                         MediaTypeRegistry.APPLICATION_LINK_FORMAT);
             }
+            return;
         } else {
             // handle content format for Read and Observe Request
             ContentFormat requestedContentFormat = null;
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/bootstrap/BootstrapHandler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/bootstrap/BootstrapHandler.java
index eae4010c..6d01f192 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/bootstrap/BootstrapHandler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/bootstrap/BootstrapHandler.java
@@ -22,9 +22,14 @@ import java.util.concurrent.TimeUnit;
 
 import org.eclipse.leshan.client.resource.LwM2mObjectEnabler;
 import org.eclipse.leshan.client.servers.ServerIdentity;
+import org.eclipse.leshan.client.util.LinkFormatHelper;
+import org.eclipse.leshan.core.Link;
+import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.request.BootstrapDeleteRequest;
+import org.eclipse.leshan.core.request.BootstrapDiscoverRequest;
 import org.eclipse.leshan.core.request.BootstrapFinishRequest;
 import org.eclipse.leshan.core.response.BootstrapDeleteResponse;
+import org.eclipse.leshan.core.response.BootstrapDiscoverResponse;
 import org.eclipse.leshan.core.response.BootstrapFinishResponse;
 import org.eclipse.leshan.core.response.SendableResponse;
 
@@ -103,4 +108,21 @@ public class BootstrapHandler {
         bootstrappingLatch = null;
         bootstrapping = false;
     }
+
+    /**
+     * @since 1.1
+     */
+    public BootstrapDiscoverResponse discover(ServerIdentity identity, BootstrapDiscoverRequest request) {
+        if (!identity.isLwm2mBootstrapServer()) {
+            return BootstrapDiscoverResponse.badRequest("not a bootstrap server");
+        }
+
+        LwM2mPath path = request.getPath();
+        if (path.isRoot()) {
+            // Manage discover on object
+            Link[] ObjectLinks = LinkFormatHelper.getBootstrapClientDescription(objects.values());
+            return BootstrapDiscoverResponse.success(ObjectLinks);
+        }
+        return BootstrapDiscoverResponse.badRequest("invalid path");
+    }
 }
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/BaseObjectEnabler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/BaseObjectEnabler.java
index 065b0cb4..a26738d9 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/BaseObjectEnabler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/BaseObjectEnabler.java
@@ -39,6 +39,7 @@ import org.eclipse.leshan.core.node.LwM2mObjectInstance;
 import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.node.LwM2mResource;
 import org.eclipse.leshan.core.request.BootstrapDeleteRequest;
+import org.eclipse.leshan.core.request.BootstrapDiscoverRequest;
 import org.eclipse.leshan.core.request.BootstrapWriteRequest;
 import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.CreateRequest;
@@ -51,6 +52,7 @@ import org.eclipse.leshan.core.request.ReadRequest;
 import org.eclipse.leshan.core.request.WriteAttributesRequest;
 import org.eclipse.leshan.core.request.WriteRequest;
 import org.eclipse.leshan.core.response.BootstrapDeleteResponse;
+import org.eclipse.leshan.core.response.BootstrapDiscoverResponse;
 import org.eclipse.leshan.core.response.BootstrapWriteResponse;
 import org.eclipse.leshan.core.response.CreateResponse;
 import org.eclipse.leshan.core.response.DeleteResponse;
@@ -62,10 +64,10 @@ import org.eclipse.leshan.core.response.WriteAttributesResponse;
 import org.eclipse.leshan.core.response.WriteResponse;
 
 /**
- * A abstract implementation of {@link LwM2mObjectEnabler}. It could be use as base for any {@link LwM2mObjectEnabler}
+ * A abstract implementation of {@link LwM2mObjectEnabler2}. It could be use as base for any {@link LwM2mObjectEnabler2}
  * implementation.
  */
-public abstract class BaseObjectEnabler implements LwM2mObjectEnabler {
+public abstract class BaseObjectEnabler implements LwM2mObjectEnabler2 {
 
     protected final int id;
     protected final TransactionalObjectListener transactionalListener;
@@ -290,6 +292,7 @@ public abstract class BaseObjectEnabler implements LwM2mObjectEnabler {
         return doDelete(identity, request);
     }
 
+    // TODO fix in 2.0 this should be protected not public
     public BootstrapDeleteResponse doDelete(ServerIdentity identity, BootstrapDeleteRequest request) {
         // This should be a not implemented error, but this is not defined in the spec.
         return BootstrapDeleteResponse.internalServerError("not implemented");
@@ -392,6 +395,27 @@ public abstract class BaseObjectEnabler implements LwM2mObjectEnabler {
         return DiscoverResponse.badRequest(null);
     }
 
+    @Override
+    public synchronized BootstrapDiscoverResponse discover(ServerIdentity identity, BootstrapDiscoverRequest request) {
+
+        if (!identity.isLwm2mBootstrapServer()) {
+            return BootstrapDiscoverResponse.badRequest("not a bootstrap server");
+        }
+
+        return doDiscover(identity, request);
+    }
+
+    protected BootstrapDiscoverResponse doDiscover(ServerIdentity identity, BootstrapDiscoverRequest request) {
+
+        LwM2mPath path = request.getPath();
+        if (path.isObject()) {
+            // Manage discover on object
+            Link[] ObjectLinks = LinkFormatHelper.getBootstrapObjectDescription(this);
+            return BootstrapDiscoverResponse.success(ObjectLinks);
+        }
+        return BootstrapDiscoverResponse.badRequest("invalid path");
+    }
+
     @Override
     public synchronized ObserveResponse observe(ServerIdentity identity, ObserveRequest request) {
         LwM2mPath path = request.getPath();
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectEnabler2.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectEnabler2.java
new file mode 100644
index 00000000..07e33375
--- /dev/null
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectEnabler2.java
@@ -0,0 +1,32 @@
+/*******************************************************************************
+ * Copyright (c) 2015 Sierra Wireless and others.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ *
+ * Contributors:
+ *     Sierra Wireless - initial API and implementation
+ *     Achim Kraus (Bosch Software Innovations GmbH) - use ServerIdentity
+ *******************************************************************************/
+package org.eclipse.leshan.client.resource;
+
+import org.eclipse.leshan.client.servers.ServerIdentity;
+import org.eclipse.leshan.core.request.BootstrapDiscoverRequest;
+import org.eclipse.leshan.core.response.BootstrapDiscoverResponse;
+
+/**
+ * A new version of {@link LwM2mObjectEnabler} which support bootstrap discover.
+ * 
+ * @since 1.1
+ */
+public interface LwM2mObjectEnabler2 extends LwM2mObjectEnabler {
+
+    BootstrapDiscoverResponse discover(ServerIdentity identity, BootstrapDiscoverRequest request);
+
+}
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/servers/ServersInfoExtractor.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/servers/ServersInfoExtractor.java
index a183e6d4..dca257d2 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/servers/ServersInfoExtractor.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/servers/ServersInfoExtractor.java
@@ -169,10 +169,24 @@ public class ServersInfoExtractor {
         }
     }
 
-    public static Long getServerId(LwM2mObjectEnabler serverEnabler, int instanceId) {
-        ReadResponse response = serverEnabler.read(ServerIdentity.SYSTEM,
-                new ReadRequest(SERVER, instanceId, SRV_SERVER_ID));
-        if (response.isSuccess()) {
+    public static Boolean isBootstrapServer(LwM2mObjectEnabler objectEnabler, int instanceId) {
+        ReadResponse response = objectEnabler.read(ServerIdentity.SYSTEM,
+                new ReadRequest(SECURITY, instanceId, SEC_BOOTSTRAP));
+        if (response != null && response.isSuccess()) {
+            return (Boolean) ((LwM2mResource) response.getContent()).getValue();
+        } else {
+            return null;
+        }
+    }
+
+    public static Long getServerId(LwM2mObjectEnabler objectEnabler, int instanceId) {
+        ReadResponse response = null;
+        if (objectEnabler.getId() == SERVER) {
+            response = objectEnabler.read(ServerIdentity.SYSTEM, new ReadRequest(SERVER, instanceId, SRV_SERVER_ID));
+        } else if (objectEnabler.getId() == SECURITY) {
+            response = objectEnabler.read(ServerIdentity.SYSTEM, new ReadRequest(SECURITY, instanceId, SEC_SERVER_ID));
+        }
+        if (response != null && response.isSuccess()) {
             return (Long) ((LwM2mResource) response.getContent()).getValue();
         } else {
             return null;
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/util/LinkFormatHelper.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/util/LinkFormatHelper.java
index e9ce384a..6f16d357 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/util/LinkFormatHelper.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/util/LinkFormatHelper.java
@@ -25,6 +25,7 @@ import java.util.List;
 import java.util.Map;
 
 import org.eclipse.leshan.client.resource.LwM2mObjectEnabler;
+import org.eclipse.leshan.client.servers.ServersInfoExtractor;
 import org.eclipse.leshan.core.Link;
 import org.eclipse.leshan.core.LwM2mId;
 import org.eclipse.leshan.core.model.LwM2mModel;
@@ -82,6 +83,18 @@ public final class LinkFormatHelper {
         return links.toArray(new Link[] {});
     }
 
+    public static Link[] getBootstrapClientDescription(Collection<LwM2mObjectEnabler> objectEnablers) {
+        List<Link> links = new ArrayList<>();
+        Map<String, String> rootAttributes = new HashMap<>();
+        rootAttributes.put("lwm2m", ObjectModel.DEFAULT_VERSION);
+        links.add(new Link("/", rootAttributes));
+
+        for (LwM2mObjectEnabler objectEnabler : objectEnablers) {
+            links.addAll(getBootstrapObjectDescriptionWithoutRoot(objectEnabler));
+        }
+        return links.toArray(new Link[] {});
+    }
+
     public static Link[] getObjectDescription(LwM2mObjectEnabler objectEnabler, String root) {
         List<Link> links = new ArrayList<>();
 
@@ -101,6 +114,64 @@ public final class LinkFormatHelper {
         return links.toArray(new Link[] {});
     }
 
+    public static Link[] getBootstrapObjectDescription(LwM2mObjectEnabler objectEnabler) {
+        List<Link> links = new ArrayList<>();
+        Map<String, String> rootAttributes = new HashMap<>();
+        rootAttributes.put("lwm2m", ObjectModel.DEFAULT_VERSION);
+        links.add(new Link("/", rootAttributes));
+
+        links.addAll(getBootstrapObjectDescriptionWithoutRoot(objectEnabler));
+
+        return links.toArray(new Link[] {});
+    }
+
+    private static List<Link> getBootstrapObjectDescriptionWithoutRoot(LwM2mObjectEnabler objectEnabler) {
+        List<Link> links = new ArrayList<>();
+
+        // create link for "object"
+        Link objectLink;
+        String objectURL = getPath("/", Integer.toString(objectEnabler.getId()));
+        String version = getVersion(objectEnabler.getObjectModel());
+        if (version != null) {
+            Map<String, String> objectAttributes = new HashMap<>();
+            objectAttributes.put("ver", version);
+            objectLink = new Link(objectURL, objectAttributes);
+        } else {
+            objectLink = new Link(objectURL);
+        }
+
+        // add object link if needed
+        List<Integer> availableInstanceIds = objectEnabler.getAvailableInstanceIds();
+        if (availableInstanceIds.isEmpty() || !objectLink.getAttributes().isEmpty()) {
+            links.add(objectLink);
+        }
+
+        // add instance link
+        for (Integer instanceId : objectEnabler.getAvailableInstanceIds()) {
+            String instanceURL = getPath("/", Integer.toString(objectEnabler.getId()), Integer.toString(instanceId));
+
+            // get short id
+            Long shortServerId = null;
+            if (objectEnabler.getId() == LwM2mId.SECURITY || objectEnabler.getId() == LwM2mId.SERVER) {
+                Boolean isBootstrapServer = objectEnabler.getId() == LwM2mId.SECURITY
+                        && ServersInfoExtractor.isBootstrapServer(objectEnabler, instanceId);
+                if (isBootstrapServer != null && !isBootstrapServer) {
+                    shortServerId = ServersInfoExtractor.getServerId(objectEnabler, instanceId);
+                }
+            }
+
+            // create link
+            if (shortServerId != null) {
+                Map<String, String> objectAttributes = new HashMap<>();
+                objectAttributes.put("ssid", shortServerId.toString());
+                links.add(new Link(instanceURL, objectAttributes));
+            } else {
+                links.add(new Link(instanceURL));
+            }
+        }
+        return links;
+    }
+
     public static Link[] getInstanceDescription(LwM2mObjectEnabler objectEnabler, int instanceId, String root) {
         List<Link> links = new ArrayList<>();
 
@@ -193,11 +264,20 @@ public final class LinkFormatHelper {
     }
 
     private static Map<String, String> getObjectAttributes(ObjectModel objectModel) {
-        if (StringUtils.isEmpty(objectModel.version) || ObjectModel.DEFAULT_VERSION.equals(objectModel.version)) {
+        String version = getVersion(objectModel);
+        if (version == null) {
             return null;
         }
+
         Map<String, String> attributes = new HashMap<>();
-        attributes.put("ver", objectModel.version);
+        attributes.put("ver", version);
         return attributes;
     }
+
+    private static String getVersion(ObjectModel objectModel) {
+        if (StringUtils.isEmpty(objectModel.version) || ObjectModel.DEFAULT_VERSION.equals(objectModel.version)) {
+            return null;
+        }
+        return objectModel.version;
+    }
 }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDiscoverRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDiscoverRequest.java
new file mode 100644
index 00000000..efe8ac53
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDiscoverRequest.java
@@ -0,0 +1,83 @@
+/*******************************************************************************
+ * Copyright (c) 2020 Sierra Wireless and others.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Sierra Wireless - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.leshan.core.request;
+
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.request.exception.InvalidRequestException;
+import org.eclipse.leshan.core.response.BootstrapDiscoverResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A Lightweight M2M request to let LWM2M Bootstrap server discovering which LwM2M Objects and Object Instances are
+ * supported by a certain LwM2M Client.
+ * 
+ * @since 1.1
+ */
+public class BootstrapDiscoverRequest extends AbstractDownlinkRequest<BootstrapDiscoverResponse> {
+
+    private static final Logger LOG = LoggerFactory.getLogger(BootstrapDiscoverRequest.class);
+
+    /**
+     * Creates a request for discovering all objects and instances supported by the client.
+     */
+    public BootstrapDiscoverRequest() {
+        this(LwM2mPath.ROOTPATH);
+    }
+
+    /**
+     * Creates a request for discovering instance of a given object.
+     *
+     * @param objectId the object type
+     */
+    public BootstrapDiscoverRequest(int objectId) {
+        this(new LwM2mPath(objectId));
+    }
+
+    /**
+     * Create a request for discovering
+     *
+     * @param path the path of the LWM2M node to discover
+     * @exception InvalidRequestException if the path is not valid.
+     */
+    public BootstrapDiscoverRequest(String path) throws InvalidRequestException {
+        this(newPath(path));
+    }
+
+    private BootstrapDiscoverRequest(LwM2mPath target) {
+        super(target);
+        if (!target.isRoot() && !target.isObject()) {
+            throw new InvalidRequestException("Invalid path %s : Discover request can only target root or object path",
+                    target);
+        }
+    }
+
+    @Override
+    public final String toString() {
+        return String.format("DiscoverRequest [%s]", getPath());
+    }
+
+    @Override
+    public void accept(DownlinkRequestVisitor visitor) {
+        if (visitor instanceof DownlinkRequestVisitor2) {
+            ((DownlinkRequestVisitor2) visitor).visit(this);
+        } else {
+            LOG.warn(
+                    "Your visitor {} does not support Bootstrap discover request, you should consider to make it implements DownlinkRequestVisitor2",
+                    this.getClass().getName());
+        }
+    }
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownlinkRequestVisitor2.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownlinkRequestVisitor2.java
new file mode 100644
index 00000000..30683f42
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownlinkRequestVisitor2.java
@@ -0,0 +1,26 @@
+/*******************************************************************************
+ * Copyright (c) 2020 Sierra Wireless and others.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Sierra Wireless - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.leshan.core.request;
+
+/**
+ * A new version of {@link DownlinkRequestVisitor} which supports {@link BootstrapDiscoverRequest}
+ * 
+ * @since 1.1
+ */
+public interface DownlinkRequestVisitor2 extends DownlinkRequestVisitor {
+
+    void visit(BootstrapDiscoverRequest request);
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/response/BootstrapDiscoverResponse.java b/leshan-core/src/main/java/org/eclipse/leshan/core/response/BootstrapDiscoverResponse.java
new file mode 100644
index 00000000..131fc43b
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/response/BootstrapDiscoverResponse.java
@@ -0,0 +1,98 @@
+/*******************************************************************************
+ * Copyright (c) 2013-2015 Sierra Wireless and others.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v20.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Sierra Wireless - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.leshan.core.response;
+
+import java.util.Arrays;
+
+import org.eclipse.leshan.core.Link;
+import org.eclipse.leshan.core.ResponseCode;
+import org.eclipse.leshan.core.request.exception.InvalidResponseException;
+
+/**
+ * @since 1.1
+ */
+public class BootstrapDiscoverResponse extends AbstractLwM2mResponse {
+
+    private final Link[] links;
+
+    public BootstrapDiscoverResponse(ResponseCode code, Link[] links, String errorMessage) {
+        this(code, links, errorMessage, null);
+    }
+
+    public BootstrapDiscoverResponse(ResponseCode code, Link[] links, String errorMessage, Object coapResponse) {
+        super(code, errorMessage, coapResponse);
+        if (ResponseCode.CONTENT.equals(code)) {
+            if (links == null)
+                throw new InvalidResponseException("links is mandatory for successful response");
+            this.links = Arrays.copyOf(links, links.length);
+        } else {
+            this.links = null;
+        }
+    }
+
+    /**
+     * Get the list of {@link Link} returned as response payload.
+     *
+     * @return the object links or <code>null</code> if the client returned an error response.
+     */
+    public Link[] getObjectLinks() {
+        return links != null ? links.clone() : null;
+    }
+
+    @Override
+    public boolean isSuccess() {
+        return getCode() == ResponseCode.CONTENT;
+    }
+
+    @Override
+    public boolean isValid() {
+        switch (code.getCode()) {
+        case ResponseCode.CONTENT_CODE:
+        case ResponseCode.BAD_REQUEST_CODE:
+        case ResponseCode.NOT_FOUND_CODE:
+        case ResponseCode.INTERNAL_SERVER_ERROR_CODE:
+            return true;
+        default:
+            return false;
+        }
+    }
+
+    @Override
+    public String toString() {
+        if (errorMessage != null)
+            return String.format("DiscoverResponse [code=%s, errormessage=%s]", code, errorMessage);
+        else
+            return String.format("DiscoverResponse [code=%s, content=%s]", code, Arrays.toString(links));
+    }
+
+    // Syntactic sugar static constructors :
+
+    public static BootstrapDiscoverResponse success(Link[] links) {
+        return new BootstrapDiscoverResponse(ResponseCode.CONTENT, links, null);
+    }
+
+    public static BootstrapDiscoverResponse badRequest(String errorMessage) {
+        return new BootstrapDiscoverResponse(ResponseCode.BAD_REQUEST, null, errorMessage);
+    }
+
+    public static BootstrapDiscoverResponse notFound() {
+        return new BootstrapDiscoverResponse(ResponseCode.NOT_FOUND, null, null);
+    }
+
+    public static BootstrapDiscoverResponse internalServerError(String errorMessage) {
+        return new BootstrapDiscoverResponse(ResponseCode.INTERNAL_SERVER_ERROR, null, errorMessage);
+    }
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/response/DiscoverResponse.java b/leshan-core/src/main/java/org/eclipse/leshan/core/response/DiscoverResponse.java
index d516fd12..0d7e2389 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/response/DiscoverResponse.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/response/DiscoverResponse.java
@@ -74,7 +74,7 @@ public class DiscoverResponse extends AbstractLwM2mResponse {
         if (errorMessage != null)
             return String.format("DiscoverResponse [code=%s, errormessage=%s]", code, errorMessage);
         else
-            return String.format("DiscoverResponse [code=%s, location=%s]", code, Arrays.toString(links));
+            return String.format("DiscoverResponse [code=%s, content=%s]", code, Arrays.toString(links));
     }
 
     // Syntactic sugar static constructors :
diff --git a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/CoapRequestBuilder.java b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/CoapRequestBuilder.java
index 85ee87ec..b8ce736b 100644
--- a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/CoapRequestBuilder.java
+++ b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/CoapRequestBuilder.java
@@ -29,6 +29,7 @@ import org.eclipse.leshan.core.node.LwM2mObjectInstance;
 import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.node.codec.LwM2mNodeEncoder;
 import org.eclipse.leshan.core.request.BootstrapDeleteRequest;
+import org.eclipse.leshan.core.request.BootstrapDiscoverRequest;
 import org.eclipse.leshan.core.request.BootstrapFinishRequest;
 import org.eclipse.leshan.core.request.BootstrapWriteRequest;
 import org.eclipse.leshan.core.request.CancelObservationRequest;
@@ -37,7 +38,7 @@ import org.eclipse.leshan.core.request.CreateRequest;
 import org.eclipse.leshan.core.request.DeleteRequest;
 import org.eclipse.leshan.core.request.DiscoverRequest;
 import org.eclipse.leshan.core.request.DownlinkRequest;
-import org.eclipse.leshan.core.request.DownlinkRequestVisitor;
+import org.eclipse.leshan.core.request.DownlinkRequestVisitor2;
 import org.eclipse.leshan.core.request.ExecuteRequest;
 import org.eclipse.leshan.core.request.Identity;
 import org.eclipse.leshan.core.request.ObserveRequest;
@@ -52,7 +53,7 @@ import org.eclipse.leshan.server.californium.observation.ObserveUtil;
  * <p>
  * Call <code>CoapRequestBuilder#visit(lwm2mRequest)</code>, then get the result using {@link #getRequest()}
  */
-public class CoapRequestBuilder implements DownlinkRequestVisitor {
+public class CoapRequestBuilder implements DownlinkRequestVisitor2 {
 
     private Request coapRequest;
 
@@ -173,6 +174,13 @@ public class CoapRequestBuilder implements DownlinkRequestVisitor {
         setTarget(coapRequest, request.getPath());
     }
 
+    @Override
+    public void visit(BootstrapDiscoverRequest request) {
+        coapRequest = Request.newGet();
+        setTarget(coapRequest, request.getPath());
+        coapRequest.getOptions().setAccept(MediaTypeRegistry.APPLICATION_LINK_FORMAT);
+    }
+
     @Override
     public void visit(BootstrapDeleteRequest request) {
         coapRequest = Request.newDelete();
diff --git a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/LwM2mResponseBuilder.java b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/LwM2mResponseBuilder.java
index 4af44b9d..0ba8c496 100644
--- a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/LwM2mResponseBuilder.java
+++ b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/request/LwM2mResponseBuilder.java
@@ -29,6 +29,7 @@ import org.eclipse.leshan.core.node.codec.CodecException;
 import org.eclipse.leshan.core.node.codec.LwM2mNodeDecoder;
 import org.eclipse.leshan.core.observation.Observation;
 import org.eclipse.leshan.core.request.BootstrapDeleteRequest;
+import org.eclipse.leshan.core.request.BootstrapDiscoverRequest;
 import org.eclipse.leshan.core.request.BootstrapFinishRequest;
 import org.eclipse.leshan.core.request.BootstrapWriteRequest;
 import org.eclipse.leshan.core.request.CancelObservationRequest;
@@ -36,7 +37,7 @@ import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.CreateRequest;
 import org.eclipse.leshan.core.request.DeleteRequest;
 import org.eclipse.leshan.core.request.DiscoverRequest;
-import org.eclipse.leshan.core.request.DownlinkRequestVisitor;
+import org.eclipse.leshan.core.request.DownlinkRequestVisitor2;
 import org.eclipse.leshan.core.request.ExecuteRequest;
 import org.eclipse.leshan.core.request.LwM2mRequest;
 import org.eclipse.leshan.core.request.ObserveRequest;
@@ -45,6 +46,7 @@ import org.eclipse.leshan.core.request.WriteAttributesRequest;
 import org.eclipse.leshan.core.request.WriteRequest;
 import org.eclipse.leshan.core.request.exception.InvalidResponseException;
 import org.eclipse.leshan.core.response.BootstrapDeleteResponse;
+import org.eclipse.leshan.core.response.BootstrapDiscoverResponse;
 import org.eclipse.leshan.core.response.BootstrapFinishResponse;
 import org.eclipse.leshan.core.response.BootstrapWriteResponse;
 import org.eclipse.leshan.core.response.CancelObservationResponse;
@@ -69,7 +71,7 @@ import org.slf4j.LoggerFactory;
  * 
  * @param <T> the type of the response to build.
  */
-public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRequestVisitor {
+public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRequestVisitor2 {
 
     private static final Logger LOG = LoggerFactory.getLogger(LwM2mResponseBuilder.class);
 
@@ -115,6 +117,9 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
             // handle success response:
             Link[] links;
             if (MediaTypeRegistry.APPLICATION_LINK_FORMAT != coapResponse.getOptions().getContentFormat()) {
+                // TODO change this behavior for the 2.0 :
+                // throw new InvalidResponseException("Client [%s] returned unexpected content format [%s] for [%s]",
+                // clientEndpoint, coapResponse.getOptions().getContentFormat(), request);
                 LOG.debug("Expected LWM2M Client [{}] to return application/link-format [{}] content but got [{}]",
                         clientEndpoint, MediaTypeRegistry.APPLICATION_LINK_FORMAT,
                         coapResponse.getOptions().getContentFormat());
@@ -253,6 +258,28 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
         }
     }
 
+    @Override
+    public void visit(BootstrapDiscoverRequest request) {
+        if (coapResponse.isError()) {
+            // handle error response:
+            lwM2mresponse = new BootstrapDiscoverResponse(toLwM2mResponseCode(coapResponse.getCode()), null,
+                    coapResponse.getPayloadString(), coapResponse);
+        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CONTENT) {
+            // handle success response:
+            Link[] links;
+            if (MediaTypeRegistry.APPLICATION_LINK_FORMAT != coapResponse.getOptions().getContentFormat()) {
+                throw new InvalidResponseException("Client [%s] returned unexpected content format [%s] for [%s]",
+                        clientEndpoint, coapResponse.getOptions().getContentFormat(), request);
+            } else {
+                links = Link.parse(coapResponse.getPayload());
+            }
+            lwM2mresponse = new BootstrapDiscoverResponse(ResponseCode.CONTENT, links, null, coapResponse);
+        } else {
+            // handle unexpected response:
+            handleUnexpectedResponseCode(clientEndpoint, request, coapResponse);
+        }
+    }
+
     @Override
     public void visit(BootstrapWriteRequest request) {
         if (coapResponse.isError()) {
diff --git a/pom.xml b/pom.xml
index ba4b74c2..8f05a8af 100644
--- a/pom.xml
+++ b/pom.xml
@@ -527,6 +527,17 @@ Contributors:
                                 <patch>equivalent</patch>
                             </versionIncreaseAllows>
                         </revapi.semver.ignore>
+                        <revapi.ignore>
+                            <item>
+                                <regex>true</regex>
+                                <code>java.class.externalClassExposedInAPI</code>
+                                <newArchive>org\.eclipse\.leshan:leshan.*:.*</newArchive>
+                                <justification>
+                                    Leshan sub-modules implement the Leshan API which makes them expose
+                                    Leshan-specific classes usually. 
+                                </justification>
+                            </item>
+                        </revapi.ignore>
                         <revapi.java>
                             <checks>
                                 <nonPublicPartOfAPI>


Test patch:
diff --git a/leshan-client-core/src/test/java/org/eclipse/leshan/client/util/LinkFormatHelperTest.java b/leshan-client-core/src/test/java/org/eclipse/leshan/client/util/LinkFormatHelperTest.java
index 40241adf..1225da3f 100644
--- a/leshan-client-core/src/test/java/org/eclipse/leshan/client/util/LinkFormatHelperTest.java
+++ b/leshan-client-core/src/test/java/org/eclipse/leshan/client/util/LinkFormatHelperTest.java
@@ -22,6 +22,8 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
+import org.eclipse.leshan.client.object.Security;
+import org.eclipse.leshan.client.object.Server;
 import org.eclipse.leshan.client.resource.BaseInstanceEnabler;
 import org.eclipse.leshan.client.resource.BaseInstanceEnablerFactory;
 import org.eclipse.leshan.client.resource.DummyInstanceEnabler;
@@ -31,6 +33,7 @@ import org.eclipse.leshan.client.resource.ObjectEnabler;
 import org.eclipse.leshan.core.Link;
 import org.eclipse.leshan.core.model.ObjectLoader;
 import org.eclipse.leshan.core.model.ObjectModel;
+import org.eclipse.leshan.core.request.BindingMode;
 import org.eclipse.leshan.core.request.ContentFormat;
 import org.junit.Test;
 
@@ -144,6 +147,128 @@ public class LinkFormatHelperTest {
         assertEquals("</>;rt=\"oma.lwm2m\",</6>;ver=2.0", strLinks);
     }
 
+    @Test
+    public void encode_bootstrap_object() {
+        Map<Integer, LwM2mInstanceEnabler> instancesMap = new HashMap<>();
+        instancesMap.put(0, new BaseInstanceEnabler());
+        ObjectEnabler objectEnabler = new ObjectEnabler(3, getObjectModel(3), instancesMap, null,
+                ContentFormat.DEFAULT);
+
+        Link[] links = LinkFormatHelper.getBootstrapObjectDescription(objectEnabler);
+        String strLinks = Link.serialize(links);
+
+        assertEquals("</>;lwm2m=1.0,</3/0>", strLinks);
+    }
+
+    @Test
+    public void encode_bootstrap_object_with_version_and_no_instance() {
+        Map<Integer, LwM2mInstanceEnabler> instancesMap = new HashMap<>();
+        ObjectEnabler objectEnabler = new ObjectEnabler(3, getVersionedObjectModel(3, "2.0"), instancesMap, null,
+                ContentFormat.DEFAULT);
+
+        Link[] links = LinkFormatHelper.getBootstrapObjectDescription(objectEnabler);
+        String strLinks = Link.serialize(links);
+
+        assertEquals("</>;lwm2m=1.0,</3>;ver=2.0", strLinks);
+    }
+
+    @Test
+    public void encode_bootstrap_object_with_version_and_instance() {
+        Map<Integer, LwM2mInstanceEnabler> instancesMap = new HashMap<>();
+        instancesMap.put(0, new BaseInstanceEnabler());
+        ObjectEnabler objectEnabler = new ObjectEnabler(3, getVersionedObjectModel(3, "2.0"), instancesMap, null,
+                ContentFormat.DEFAULT);
+
+        Link[] links = LinkFormatHelper.getBootstrapObjectDescription(objectEnabler);
+        String strLinks = Link.serialize(links);
+
+        assertEquals("</>;lwm2m=1.0,</3>;ver=2.0,</3/0>", strLinks);
+    }
+
+    @Test
+    public void encode_bootstrap_server_object() {
+        Map<Integer, LwM2mInstanceEnabler> instancesMap = new HashMap<>();
+        instancesMap.put(0, new Server(333, 120, BindingMode.UQ, false));
+        ObjectEnabler objectEnabler = new ObjectEnabler(1, getObjectModel(1), instancesMap, null,
+                ContentFormat.DEFAULT);
+
+        Link[] links = LinkFormatHelper.getBootstrapObjectDescription(objectEnabler);
+        String strLinks = Link.serialize(links);
+
+        assertEquals("</>;lwm2m=1.0,</1/0>;ssid=333", strLinks);
+    }
+
+    @Test
+    public void encode_bootstrap_server_object_with_version() {
+        Map<Integer, LwM2mInstanceEnabler> instancesMap = new HashMap<>();
+        instancesMap.put(0, new Server(333, 120, BindingMode.UQ, false));
+        ObjectEnabler objectEnabler = new ObjectEnabler(1, getVersionedObjectModel(1, "2.0"), instancesMap, null,
+                ContentFormat.DEFAULT);
+
+        Link[] links = LinkFormatHelper.getBootstrapObjectDescription(objectEnabler);
+        String strLinks = Link.serialize(links);
+
+        assertEquals("</>;lwm2m=1.0,</1>;ver=2.0,</1/0>;ssid=333", strLinks);
+    }
+
+    @Test
+    public void encode_bootstrap_security_object() {
+        Map<Integer, LwM2mInstanceEnabler> instancesMap = new HashMap<>();
+        instancesMap.put(0, Security.noSec("coap://localhost", 111));
+        instancesMap.put(1, Security.noSecBootstap("coap://localhost"));
+        instancesMap.put(2, Security.noSec("coap://localhost", 222));
+        instancesMap.put(3, Security.noSec("coap://localhost", 333));
+        ObjectEnabler objectEnabler = new ObjectEnabler(0, getObjectModel(0), instancesMap, null,
+                ContentFormat.DEFAULT);
+
+        Link[] links = LinkFormatHelper.getBootstrapObjectDescription(objectEnabler);
+        String strLinks = Link.serialize(links);
+
+        assertEquals("</>;lwm2m=1.0,</0/0>;ssid=111,</0/1>,</0/2>;ssid=222,</0/3>;ssid=333", strLinks);
+
+    }
+
+    @Test
+    public void encode_bootstrap_root() {
+        List<LwM2mObjectEnabler> objectEnablers = new ArrayList<>();
+
+        // object 0
+        Map<Integer, LwM2mInstanceEnabler> securityInstances = new HashMap<>();
+        securityInstances.put(0, Security.noSec("coap://localhost", 111));
+        securityInstances.put(1, Security.noSecBootstap("coap://localhost"));
+        securityInstances.put(2, Security.noSec("coap://localhost", 222));
+        securityInstances.put(3, Security.noSec("coap://localhost", 333));
+        ObjectEnabler securityObjectEnabler = new ObjectEnabler(0, getObjectModel(0), securityInstances, null,
+                ContentFormat.DEFAULT);
+        objectEnablers.add(securityObjectEnabler);
+
+        // object 1
+        Map<Integer, LwM2mInstanceEnabler> serverInstances = new HashMap<>();
+        serverInstances.put(0, new Server(333, 120, BindingMode.UQ, false));
+        ObjectEnabler serverObjectEnabler = new ObjectEnabler(1, getVersionedObjectModel(1, "2.0"), serverInstances,
+                null, ContentFormat.DEFAULT);
+        objectEnablers.add(serverObjectEnabler);
+
+        // object 2
+        ObjectEnabler aclObjectEnabler = new ObjectEnabler(2, getVersionedObjectModel(2, "2.0"),
+                new HashMap<Integer, LwM2mInstanceEnabler>(), null, ContentFormat.DEFAULT);
+        objectEnablers.add(aclObjectEnabler);
+
+        // object 3
+        Map<Integer, LwM2mInstanceEnabler> deviceInstances = new HashMap<>();
+        deviceInstances.put(0, new BaseInstanceEnabler());
+        ObjectEnabler deviceObjectEnabler = new ObjectEnabler(3, getObjectModel(3), deviceInstances, null,
+                ContentFormat.DEFAULT);
+        objectEnablers.add(deviceObjectEnabler);
+
+        Link[] links = LinkFormatHelper.getBootstrapClientDescription(objectEnablers);
+        String strLinks = Link.serialize(links);
+
+        assertEquals(
+                "</>;lwm2m=1.0,</0/0>;ssid=111,</0/1>,</0/2>;ssid=222,</0/3>;ssid=333,</1>;ver=2.0,</1/0>;ssid=333,</2>;ver=2.0,</3/0>",
+                strLinks);
+    }
+
     private ObjectModel getObjectModel(int id) {
         List<ObjectModel> objectModels = ObjectLoader.loadDefault();
         for (ObjectModel objectModel : objectModels) {
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/BootstrapIntegrationTestHelper.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/BootstrapIntegrationTestHelper.java
index f8e6b21a..0a82e843 100644
--- a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/BootstrapIntegrationTestHelper.java
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/BootstrapIntegrationTestHelper.java
@@ -31,6 +31,7 @@ import java.security.spec.ECPoint;
 import java.security.spec.ECPrivateKeySpec;
 import java.security.spec.ECPublicKeySpec;
 import java.security.spec.KeySpec;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
@@ -43,16 +44,24 @@ import org.eclipse.leshan.client.resource.DummyInstanceEnabler;
 import org.eclipse.leshan.client.resource.ObjectsInitializer;
 import org.eclipse.leshan.core.LwM2mId;
 import org.eclipse.leshan.core.SecurityMode;
+import org.eclipse.leshan.core.request.BootstrapDiscoverRequest;
 import org.eclipse.leshan.core.request.Identity;
+import org.eclipse.leshan.core.response.BootstrapDiscoverResponse;
 import org.eclipse.leshan.core.util.Hex;
 import org.eclipse.leshan.server.bootstrap.BootstrapConfig;
 import org.eclipse.leshan.server.bootstrap.BootstrapConfig.ACLConfig;
 import org.eclipse.leshan.server.bootstrap.BootstrapConfig.ServerConfig;
 import org.eclipse.leshan.server.bootstrap.BootstrapConfig.ServerSecurity;
 import org.eclipse.leshan.server.bootstrap.BootstrapConfigStore;
+import org.eclipse.leshan.server.bootstrap.BootstrapFailureCause;
+import org.eclipse.leshan.server.bootstrap.BootstrapHandler;
+import org.eclipse.leshan.server.bootstrap.BootstrapHandlerFactory;
 import org.eclipse.leshan.server.bootstrap.BootstrapSession;
 import org.eclipse.leshan.server.bootstrap.DefaultBootstrapSession;
 import org.eclipse.leshan.server.bootstrap.DefaultBootstrapSessionManager;
+import org.eclipse.leshan.server.bootstrap.BootstrapSessionManager;
+import org.eclipse.leshan.server.bootstrap.DefaultBootstrapHandler;
+import org.eclipse.leshan.server.bootstrap.LwM2mBootstrapRequestSender;
 import org.eclipse.leshan.server.californium.bootstrap.LeshanBootstrapServer;
 import org.eclipse.leshan.server.californium.bootstrap.LeshanBootstrapServerBuilder;
 import org.eclipse.leshan.server.security.BootstrapSecurityStore;
@@ -69,6 +78,7 @@ public class BootstrapIntegrationTestHelper extends SecureIntegrationTestHelper
     public final PublicKey bootstrapServerPublicKey;
     public final PrivateKey bootstrapServerPrivateKey;
     public volatile DefaultBootstrapSession lastBootstrapSession;
+    public volatile BootstrapDiscoverResponse lastDiscoverAnswer;
 
     public BootstrapIntegrationTestHelper() {
         super();
@@ -101,7 +111,8 @@ public class BootstrapIntegrationTestHelper extends SecureIntegrationTestHelper
         }
     }
 
-    public void createBootstrapServer(BootstrapSecurityStore securityStore, BootstrapConfigStore bootstrapStore) {
+    private LeshanBootstrapServerBuilder createBootstrapBuilder(BootstrapSecurityStore securityStore,
+            BootstrapConfigStore bootstrapStore) {
         if (bootstrapStore == null) {
             bootstrapStore = unsecuredBootstrapStore();
         }
@@ -124,6 +135,45 @@ public class BootstrapIntegrationTestHelper extends SecureIntegrationTestHelper
             }
         });
 
+        return builder;
+    }
+
+    public void createBootstrapServer(BootstrapSecurityStore securityStore, BootstrapConfigStore bootstrapStore) {
+        bootstrapServer = createBootstrapBuilder(securityStore, bootstrapStore).build();
+    }
+
+    public void createBootstrapServer(BootstrapSecurityStore securityStore, BootstrapConfigStore bootstrapStore,
+            final BootstrapDiscoverRequest request) {
+        LeshanBootstrapServerBuilder builder = createBootstrapBuilder(securityStore, bootstrapStore);
+
+        // start bootstrap session by a bootstrap discover request
+        builder.setBootstrapHandlerFactory(new BootstrapHandlerFactory() {
+
+            @Override
+            public BootstrapHandler create(BootstrapConfigStore store, LwM2mBootstrapRequestSender sender,
+                    BootstrapSessionManager sessionManager) {
+                return new DefaultBootstrapHandler(store, sender, sessionManager) {
+
+                    @Override
+                    protected void startBootstrap(final BootstrapSession session, final BootstrapConfig cfg) {
+                        send(session, request, new SafeResponseCallback<BootstrapDiscoverResponse>(session) {
+
+                            @Override
+                            public void safeOnResponse(BootstrapDiscoverResponse response) {
+                                lastDiscoverAnswer = response;
+                                delete(session, cfg, new ArrayList<>(cfg.toDelete));
+                            }
+                        }, new SafeErrorCallback(session) {
+                            @Override
+                            public void safeOnError(Exception e) {
+                                stopSession(session, BootstrapFailureCause.INTERNAL_SERVER_ERROR);
+                            }
+                        });
+                    }
+                };
+            }
+        });
+
         bootstrapServer = builder.build();
     }
 
@@ -135,7 +185,7 @@ public class BootstrapIntegrationTestHelper extends SecureIntegrationTestHelper
         // Create Security Object (with bootstrap server only)
         String bsUrl = "coap://" + bootstrapServer.getUnsecuredAddress().getHostString() + ":"
                 + bootstrapServer.getUnsecuredAddress().getPort();
-        return new Security(bsUrl, true, 3, new byte[0], new byte[0], new byte[0], 12345);
+        return new Security(bsUrl, true, 3, new byte[0], new byte[0], new byte[0], 0);
     }
 
     @Override
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/BootstrapTest.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/BootstrapTest.java
index 38fa7206..adab7821 100644
--- a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/BootstrapTest.java
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/BootstrapTest.java
@@ -27,10 +27,13 @@ import org.eclipse.leshan.client.resource.LwM2mObjectEnabler;
 import org.eclipse.leshan.client.resource.ObjectsInitializer;
 import org.eclipse.leshan.client.resource.SimpleInstanceEnabler;
 import org.eclipse.leshan.client.servers.ServerIdentity;
+import org.eclipse.leshan.core.Link;
 import org.eclipse.leshan.core.LwM2mId;
+import org.eclipse.leshan.core.ResponseCode;
 import org.eclipse.leshan.core.SecurityMode;
 import org.eclipse.leshan.core.node.LwM2mObject;
 import org.eclipse.leshan.core.node.LwM2mObjectInstance;
+import org.eclipse.leshan.core.request.BootstrapDiscoverRequest;
 import org.eclipse.leshan.core.request.ReadRequest;
 import org.eclipse.leshan.core.response.ReadResponse;
 import org.eclipse.leshan.server.security.NonUniqueSecurityInfoException;
@@ -107,6 +110,56 @@ public class BootstrapTest {
         assertEquals(additionalAttributes, helper.lastBootstrapSession.getBootstrapRequest().getAdditionalAttributes());
     }
 
+    @Test
+    public void bootstrapWithDiscoverOnRoot() {
+        // Create DM Server without security & start it
+        helper.createServer();
+        helper.server.start();
+
+        // Create and start bootstrap server
+        helper.createBootstrapServer(null, null, new BootstrapDiscoverRequest());
+        helper.bootstrapServer.start();
+
+        // Create Client and check it is not already registered
+        helper.createClient();
+        helper.assertClientNotRegisterered();
+
+        // Start it and wait for registration
+        helper.client.start();
+        helper.waitForRegistrationAtServerSide(1);
+
+        // check the client is registered
+        helper.assertClientRegisterered();
+        assertNotNull(helper.lastDiscoverAnswer);
+        assertEquals(ResponseCode.CONTENT, helper.lastDiscoverAnswer.getCode());
+        assertEquals("</>;lwm2m=1.0,</0/0>,</1>,</3/0>", Link.serialize(helper.lastDiscoverAnswer.getObjectLinks()));
+    }
+
+    @Test
+    public void bootstrapWithDiscoverOnDevice() {
+        // Create DM Server without security & start it
+        helper.createServer();
+        helper.server.start();
+
+        // Create and start bootstrap server
+        helper.createBootstrapServer(null, null, new BootstrapDiscoverRequest(3));
+        helper.bootstrapServer.start();
+
+        // Create Client and check it is not already registered
+        helper.createClient();
+        helper.assertClientNotRegisterered();
+
+        // Start it and wait for registration
+        helper.client.start();
+        helper.waitForRegistrationAtServerSide(1);
+
+        // check the client is registered
+        helper.assertClientRegisterered();
+        assertNotNull(helper.lastDiscoverAnswer);
+        assertEquals(ResponseCode.CONTENT, helper.lastDiscoverAnswer.getCode());
+        assertEquals("</>;lwm2m=1.0,</3/0>", Link.serialize(helper.lastDiscoverAnswer.getObjectLinks()));
+    }
+
     @Test
     public void bootstrapDeleteSecurity() {
         // Create DM Server without security & start it