Repository: eclipse/leshan
Pull request #768 (2019-11-07 15:54:47)

Make CreateRequest compliant with LWM2M specification
After asking question about create request at OMA (https://github.com/OpenMobileAlliance/OMA_LwM2M_for_Developers/issues/456), I discover that finally we aren't really compliant with the specification on this point.


So this PR aims to fix this : 
- Now you can create several instances with only 1 request.
- Create request only targets Object (path must be a object path)


Patch:
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 f99f8853..45f574f4 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
@@ -37,6 +37,7 @@ import org.eclipse.leshan.core.attributes.AttributeSet;
 import org.eclipse.leshan.core.model.LwM2mModel;
 import org.eclipse.leshan.core.model.StaticModel;
 import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mObject;
 import org.eclipse.leshan.core.node.LwM2mObjectInstance;
 import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.node.codec.CodecException;
@@ -279,22 +280,26 @@ public class ObjectResource extends LwM2mClientCoapResource implements NotifySen
         // Manage Create Request
         try {
             // decode the payload as an instance
-            LwM2mObjectInstance newInstance = decoder.decode(exchange.getRequestPayload(), contentFormat,
-                    new LwM2mPath(path.getObjectId()), model, LwM2mObjectInstance.class);
+            LwM2mObject object = decoder.decode(exchange.getRequestPayload(), contentFormat,
+                    new LwM2mPath(path.getObjectId()), model, LwM2mObject.class);
 
             CreateRequest createRequest;
-            if (newInstance.getId() != LwM2mObjectInstance.UNDEFINED) {
-                createRequest = new CreateRequest(contentFormat, path.getObjectId(), newInstance);
-            } else {
+            // check if this is the "special" case where instance ID is not defined ...
+            LwM2mObjectInstance newInstance = object.getInstance(LwM2mObjectInstance.UNDEFINED);
+            if (object.getInstances().size() == 1 && newInstance != null) {
                 // the instance Id was not part of the create request payload.
                 // will be assigned by the client.
                 createRequest = new CreateRequest(contentFormat, path.getObjectId(),
                         newInstance.getResources().values());
+            } else {
+                createRequest = new CreateRequest(contentFormat, path.getObjectId(), object.getInstances().values()
+                        .toArray(new LwM2mObjectInstance[object.getInstances().values().size()]));
             }
 
             CreateResponse response = nodeEnabler.create(identity, createRequest);
             if (response.getCode() == org.eclipse.leshan.ResponseCode.CREATED) {
-                exchange.setLocationPath(response.getLocation());
+                if (response.getLocation() != null)
+                    exchange.setLocationPath(response.getLocation());
                 exchange.respond(toCoapResponseCode(response.getCode()));
                 return;
             } else {
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/ObjectEnabler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/ObjectEnabler.java
index 10ac6b20..47b9435c 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/ObjectEnabler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/ObjectEnabler.java
@@ -18,6 +18,7 @@
 package org.eclipse.leshan.client.resource;
 
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -101,36 +102,65 @@ public class ObjectEnabler extends BaseObjectEnabler {
 
     @Override
     protected CreateResponse doCreate(ServerIdentity identity, CreateRequest request) {
-        Integer instanceId = request.getInstanceId(); // instanceId CAN be NULL
+        if (!getObjectModel().multiple && instances.size() > 0) {
+            return CreateResponse.badRequest("an instance already exist for this single instance object");
+        }
 
-        // check instance id is valid
-        if (instanceId != null) {
-            if (instances.containsKey(instanceId)) {
-                return CreateResponse.badRequest("Target already exists");
-            }
-            if (!getObjectModel().multiple && instanceId != 0) {
-                return CreateResponse.badRequest("id must be 0 for single instance");
-            }
+        if (request.unknownObjectInstanceId()) {
+            // create instance
+            LwM2mInstanceEnabler newInstance = createInstance(identity, getObjectModel().multiple ? null : 0,
+                    request.getResources());
+
+            // add new instance to this object
+            instances.put(newInstance.getId(), newInstance);
+            listenInstance(newInstance, newInstance.getId());
+
+            return CreateResponse
+                    .success(new LwM2mPath(request.getPath().getObjectId(), newInstance.getId()).toString());
         } else {
-            // default 0 instance id for single instance object
+            List<LwM2mObjectInstance> instanceNodes = request.getObjectInstances();
+
+            // checks single object instances
             if (!getObjectModel().multiple) {
-                instanceId = 0;
+                if (request.getObjectInstances().size() > 1) {
+                    return CreateResponse.badRequest("can not create several instances on this single instance object");
+                }
+                if (request.getObjectInstances().get(0).getId() != 0) {
+                    return CreateResponse.badRequest("single instance object must use 0 as ID");
+                }
             }
+            // ensure instance does not already exists
+            for (LwM2mObjectInstance instance : instanceNodes) {
+                if (instances.containsKey(instance.getId())) {
+                    return CreateResponse.badRequest(String.format("instance %d already exists", instance.getId()));
+                }
+            }
+
+            // create the new instances
+            for (LwM2mObjectInstance instance : request.getObjectInstances()) {
+                // create instance
+                LwM2mInstanceEnabler newInstance = createInstance(identity, instance.getId(),
+                        instance.getResources().values());
+
+                // add new instance to this object
+                instances.put(newInstance.getId(), newInstance);
+                listenInstance(newInstance, newInstance.getId());
+            }
+            return CreateResponse.success();
         }
+    }
 
+    protected LwM2mInstanceEnabler createInstance(ServerIdentity identity, Integer instanceId,
+            Collection<LwM2mResource> resources) {
         // create the new instance
         LwM2mInstanceEnabler newInstance = instanceFactory.create(getObjectModel(), instanceId, instances.keySet());
 
         // add/write resource
-        for (LwM2mResource resource : request.getResources()) {
+        for (LwM2mResource resource : resources) {
             newInstance.write(identity, resource.getId(), resource);
         }
 
-        // add new instance to this object
-        instances.put(newInstance.getId(), newInstance);
-        listenInstance(newInstance, newInstance.getId());
-
-        return CreateResponse.success(new LwM2mPath(request.getPath().getObjectId(), newInstance.getId()).toString());
+        return newInstance;
     }
 
     @Override
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mObjectInstance.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mObjectInstance.java
index 06b5ac4a..0489949c 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mObjectInstance.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mObjectInstance.java
@@ -21,6 +21,7 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 
+import org.eclipse.leshan.core.node.codec.tlv.LwM2mNodeTlvDecoder;
 import org.eclipse.leshan.util.Validate;
 
 /**
@@ -35,6 +36,13 @@ public class LwM2mObjectInstance implements LwM2mNode {
 
     private final Map<Integer, LwM2mResource> resources;
 
+    /**
+     * This constructor is only for internal purpose.
+     * <p>
+     * It SHOULD NOT be used. It only exist to support the "special" use case where instance id is not defined on create
+     * request. The rare case where it should make sense to use it, is implementing a decoder which support this special
+     * use case like {@link LwM2mNodeTlvDecoder}.
+     */
     public LwM2mObjectInstance(Collection<LwM2mResource> resources) {
         LwM2mNodeUtil.validateNotNull(resources, "resource MUST NOT be null");
         this.id = UNDEFINED;
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/tlv/LwM2mNodeTlvDecoder.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/tlv/LwM2mNodeTlvDecoder.java
index 638b1e4c..5f5b6946 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/tlv/LwM2mNodeTlvDecoder.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/tlv/LwM2mNodeTlvDecoder.java
@@ -76,10 +76,10 @@ public class LwM2mNodeTlvDecoder {
                 } else if (!oModel.multiple) {
                     instances.put(0, parseObjectInstanceTlv(tlvs, path.getObjectId(), 0, model));
                 } else {
-                    throw new CodecException("Object instance TLV is mandatory for multiple instances object [path:%s]",
-                            path);
+                    // this is strange "create without instance ID" case ...
+                    instances.put(LwM2mObjectInstance.UNDEFINED,
+                            parseObjectInstanceTlvWithoutId(tlvs, path.getObjectId(), model));
                 }
-
             } else {
                 for (Tlv tlv : tlvs) {
                     if (tlv.getType() != TlvType.OBJECT_INSTANCE)
@@ -120,7 +120,8 @@ public class LwM2mNodeTlvDecoder {
                     if (oModel != null && !oModel.multiple) {
                         return (T) parseObjectInstanceTlv(tlvs, path.getObjectId(), 0, model);
                     } else {
-                        return (T) parseObjectInstanceTlvWithoutId(tlvs, path.getObjectId(), model);
+                        throw new CodecException(
+                                "Object instance id is mandatory for multiple instances object [path:%s]", path);
                     }
                 } else {
                     return (T) parseObjectInstanceTlv(tlvs, path.getObjectId(), instanceId, model);
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/CreateRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/CreateRequest.java
index bb03a65e..578bba5b 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/CreateRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/CreateRequest.java
@@ -31,8 +31,8 @@ import org.eclipse.leshan.core.response.CreateResponse;
  */
 public class CreateRequest extends AbstractDownlinkRequest<CreateResponse> {
 
-    private final Integer instanceId;
     private final List<LwM2mResource> resources;
+    private final List<LwM2mObjectInstance> instances;
     private final ContentFormat contentFormat;
 
     // ***************** constructors without object instance id ******************* /
@@ -48,7 +48,7 @@ public class CreateRequest extends AbstractDownlinkRequest<CreateResponse> {
      */
     public CreateRequest(ContentFormat contentFormat, int objectId, LwM2mResource... resources)
             throws InvalidRequestException {
-        this(contentFormat, new LwM2mPath(objectId), null, resources);
+        this(contentFormat, new LwM2mPath(objectId), resources, null);
     }
 
     /**
@@ -59,7 +59,7 @@ public class CreateRequest extends AbstractDownlinkRequest<CreateResponse> {
      * @param resources the resource values for the new instance
      */
     public CreateRequest(int objectId, LwM2mResource... resources) {
-        this(null, new LwM2mPath(objectId), null, resources);
+        this(null, new LwM2mPath(objectId), resources, null);
     }
 
     /**
@@ -90,36 +90,36 @@ public class CreateRequest extends AbstractDownlinkRequest<CreateResponse> {
     // ***************** constructor with object instance ******************* /
 
     /**
-     * Creates a request for creating an instance of a particular object.
+     * Creates a request for creating instances of a particular object.
      * 
      * @param contentFormat the payload format
      * @param objectId the object id
-     * @param instance the object instance
+     * @param instances the object instances to create
      * @exception InvalidRequestException if bad @{link ContentFormat} format was used.
      */
-    public CreateRequest(ContentFormat contentFormat, int objectId, LwM2mObjectInstance instance)
+    public CreateRequest(ContentFormat contentFormat, int objectId, LwM2mObjectInstance... instances)
             throws InvalidRequestException {
-        this(contentFormat, new LwM2mPath(objectId), instance.getId(),
-                instance.getResources().values().toArray(new LwM2mResource[0]));
+        this(contentFormat, new LwM2mPath(objectId), null, instances);
     }
 
     /**
-     * Creates a request for creating an instance of a particular object using the TLV content format.
+     * Creates a request for creating instances of a particular object using the TLV content format.
      * 
      * @param objectId the object id
-     * @param instance the object instance
+     * @param instance the object instances to create
      */
-    public CreateRequest(int objectId, LwM2mObjectInstance instance) {
-        this(null, objectId, instance);
+    public CreateRequest(int objectId, LwM2mObjectInstance... instances) {
+        this(null, objectId, instances);
     }
 
     // ***************** string path constructor ******************* /
     /**
-     * Creates a request for creating an instance of a particular object using the default TLV content format.<br>
-     * If the path is an object path, the instance id will be chosen by the client and accessible in the CreateResponse.
-     * To choose instance id at server side, the path must be an object instance path.
+     * Creates a request for creating an instance of a particular object using the default TLV content format.
+     * <p>
+     * The path MUST BE an object path, the instance id will be chosen by the client and accessible in the
+     * CreateResponse.
      * 
-     * @param path the target path (object or object instance)
+     * @param path the target object path
      * @param resources the resource values for the new instance
      * @exception InvalidRequestException if the target path is not valid.
      */
@@ -128,12 +128,13 @@ public class CreateRequest extends AbstractDownlinkRequest<CreateResponse> {
     }
 
     /**
-     * Creates a request for creating an instance of a particular object.<br>
-     * If the path is an object path, the instance id will be chosen by the client and accessible in the CreateResponse.
-     * To choose instance id at server side, the path must be an object instance path.
+     * Creates a request for creating an instance of a particular object.
+     * <p>
+     * The path MUST BE an object path, the instance id will be chosen by the client and accessible in the
+     * CreateResponse.
      * 
      * @param contentFormat the payload format (TLV or JSON)
-     * @param path the target path (object or object instance)
+     * @param path the target object path
      * @param resources the resource values for the new instance
      * @exception InvalidRequestException if parameters are invalid.
      */
@@ -143,97 +144,85 @@ public class CreateRequest extends AbstractDownlinkRequest<CreateResponse> {
     }
 
     /**
-     * Creates a request for creating an instance of a particular object using the default TLV content format.<br>
-     * If the path is an object path, the instance id will be chosen by the client and accessible in the CreateResponse.
-     * To choose instance id at server side, the path must be an object instance path.
+     * Creates a request for creating an instance of a particular object using the default TLV content format.
+     * <p>
+     * The path MUST BE an object path, the instance id will be chosen by the client and accessible in the
+     * CreateResponse.
      * 
-     * @param path the target path (object or object instance)
+     * @param path the target object path
      * @param resources the resource values for the new instance
      * @exception InvalidRequestException if the target path is not valid.
      */
     public CreateRequest(String path, LwM2mResource... resources) throws InvalidRequestException {
-        this(null, newPath(path), null, resources);
+        this(null, newPath(path), resources, null);
     }
 
     /**
-     * Creates a request for creating an instance of a particular object.<br>
-     * If the path is an object path, the instance id will be chosen by the client and accessible in the CreateResponse.
-     * To choose instance id at server side, the path must be an object instance path.
+     * Creates a request for creating an instance of a particular object.
+     * <p>
+     * The path MUST BE an object path, the instance id will be chosen by the client and accessible in the
+     * CreateResponse.
      * 
      * @param contentFormat the payload format (TLV or JSON)
-     * @param path the target path (object or object instance)
+     * @param path the target object path
      * @param resources the resource values for the new instance
      * @exception InvalidRequestException if parameters are invalid.
      */
     public CreateRequest(ContentFormat contentFormat, String path, LwM2mResource... resources)
             throws InvalidRequestException {
-        this(contentFormat, newPath(path), null, resources);
+        this(contentFormat, newPath(path), resources, null);
     }
 
     /**
-     * Creates a request for creating an instance of a particular object.<br>
-     * If the path is an object path, the instance id will be chosen by the client and accessible in the CreateResponse.
-     * To choose instance id at server side, the path must be an object instance path.
+     * Creates a request for creating instances of a particular object.
      * 
-     * @param path the target path (object or object instance)
-     * @param instance the object instance
+     * @param path the target object path
+     * @param instances the object instances to create
      * @exception InvalidRequestException if the target path is not valid.
      */
-    public CreateRequest(String path, LwM2mObjectInstance instance) throws InvalidRequestException {
-        this(null, newPath(path), instance.getId(),
-                instance.getResources().values().toArray((new LwM2mResource[instance.getResources().size()])));
+    public CreateRequest(String path, LwM2mObjectInstance... instances) throws InvalidRequestException {
+        this(null, newPath(path), null, instances);
     }
 
     /**
-     * Creates a request for creating an instance of a particular object.<br>
-     * If the path is an object path, the instance id will be chosen by the client and accessible in the CreateResponse.
-     * To choose instance id at server side, the path must be an object instance path.
+     * Creates a request for creating instances of a particular object.
      * 
      * @param contentFormat the payload format (TLV or JSON)
-     * @param path the target path (object or object instance)
-     * @param instance the object instance
+     * @param path the target object path
+     * @param instances the object instances to create
      * @exception InvalidRequestException if parameters are invalid.
      */
-    public CreateRequest(ContentFormat contentFormat, String path, LwM2mObjectInstance instance)
+    public CreateRequest(ContentFormat contentFormat, String path, LwM2mObjectInstance... instances)
             throws InvalidRequestException {
-        this(contentFormat, newPath(path), instance.getId(),
-                instance.getResources().values().toArray((new LwM2mResource[instance.getResources().size()])));
+        this(contentFormat, newPath(path), null, instances);
     }
 
     // ***************** generic constructor ******************* /
-    private CreateRequest(ContentFormat format, LwM2mPath target, Integer instanceId, LwM2mResource[] resources) {
+    private CreateRequest(ContentFormat format, LwM2mPath target, LwM2mResource[] resources,
+            LwM2mObjectInstance[] instances) {
         super(target);
-        // accept only object and object instance path
+        // ensure instances and resources attributes is exclusives
+        if ((instances == null && resources == null) || (instances != null && resources != null)) {
+            throw new InvalidRequestException("instance or resources must be present (but not both)");
+        }
+        // accept only object
         if (target.isRoot())
             throw new InvalidRequestException("Create request cannot target root path");
 
-        if (!target.isObject() && !target.isObjectInstance())
-            throw new InvalidRequestException(
-                    "Invalid path %s: Create request must not target an object or object instance", target);
-
-        // validate instance id
-        if (instanceId != null && instanceId == LwM2mObjectInstance.UNDEFINED) {
-            instanceId = null;
-        }
-        if (target.isObjectInstance()) {
-            if (instanceId == null) {
-                instanceId = target.getObjectInstanceId();
-            } else {
-                if (!instanceId.equals(target.getObjectInstanceId())) {
-                    throw new InvalidRequestException("Conflict between path instance id %s and node instance id %s",
-                            target, instanceId);
-                }
-            }
-        }
-        if (instanceId != null && instanceId < 0)
-            throw new InvalidRequestException("Invalid instance id %s for path %s ", instanceId, target);
+        if (!target.isObject())
+            throw new InvalidRequestException("Invalid path %s: Create request must target an object", target);
 
         // store attributes
-        this.instanceId = instanceId;
-        this.resources = Collections.unmodifiableList(Arrays.asList(resources));
+        if (resources != null) {
+            this.resources = Collections.unmodifiableList(Arrays.asList(resources));
+            this.instances = null;
+        } else {
+            this.resources = null;
+            this.instances = Collections.unmodifiableList(Arrays.asList(instances));
+        }
         this.contentFormat = format != null ? format : ContentFormat.TLV; // default to TLV
 
-        if (this.contentFormat == ContentFormat.JSON && instanceId == null) {
+        if (this.contentFormat == ContentFormat.JSON && unknownObjectInstanceId()) {
             throw new InvalidRequestException(
                     "Missing object instance id for CREATE request (%s) using JSON content format.", target);
         }
@@ -248,11 +237,12 @@ public class CreateRequest extends AbstractDownlinkRequest<CreateResponse> {
         return resources;
     }
 
-    /**
-     * @return the id of the new instance. <code>null</code> if not assigned by the server.
-     */
-    public Integer getInstanceId() {
-        return instanceId;
+    public List<LwM2mObjectInstance> getObjectInstances() {
+        return instances;
+    }
+
+    public boolean unknownObjectInstanceId() {
+        return instances == null;
     }
 
     public ContentFormat getContentFormat() {
@@ -271,7 +261,7 @@ public class CreateRequest extends AbstractDownlinkRequest<CreateResponse> {
         final int prime = 31;
         int result = super.hashCode();
         result = prime * result + ((contentFormat == null) ? 0 : contentFormat.hashCode());
-        result = prime * result + ((instanceId == null) ? 0 : instanceId.hashCode());
+        result = prime * result + ((instances == null) ? 0 : instances.hashCode());
         result = prime * result + ((resources == null) ? 0 : resources.hashCode());
         return result;
     }
@@ -285,12 +275,15 @@ public class CreateRequest extends AbstractDownlinkRequest<CreateResponse> {
         if (getClass() != obj.getClass())
             return false;
         CreateRequest other = (CreateRequest) obj;
-        if (contentFormat != other.contentFormat)
+        if (contentFormat == null) {
+            if (other.contentFormat != null)
+                return false;
+        } else if (!contentFormat.equals(other.contentFormat))
             return false;
-        if (instanceId == null) {
-            if (other.instanceId != null)
+        if (instances == null) {
+            if (other.instances != null)
                 return false;
-        } else if (!instanceId.equals(other.instanceId))
+        } else if (!instances.equals(other.instances))
             return false;
         if (resources == null) {
             if (other.resources != null)
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/response/CreateResponse.java b/leshan-core/src/main/java/org/eclipse/leshan/core/response/CreateResponse.java
index ddd75a97..746984fb 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/response/CreateResponse.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/response/CreateResponse.java
@@ -64,6 +64,9 @@ public class CreateResponse extends AbstractLwM2mResponse {
     }
 
     // Syntactic sugar static constructors :
+    public static CreateResponse success() {
+        return new CreateResponse(ResponseCode.CREATED, null, null);
+    }
 
     public static CreateResponse success(String location) {
         return new CreateResponse(ResponseCode.CREATED, location, null);
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 c37d4eb9..803f41fe 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
@@ -23,6 +23,8 @@ import org.eclipse.californium.core.coap.Request;
 import org.eclipse.californium.elements.EndpointContext;
 import org.eclipse.leshan.core.californium.EndpointContextUtil;
 import org.eclipse.leshan.core.model.LwM2mModel;
+import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mObject;
 import org.eclipse.leshan.core.node.LwM2mObjectInstance;
 import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.node.codec.LwM2mNodeEncoder;
@@ -127,13 +129,13 @@ public class CoapRequestBuilder implements DownlinkRequestVisitor {
         coapRequest = Request.newPost();
         coapRequest.getOptions().setContentFormat(request.getContentFormat().getCode());
         // if no instance id, the client will assign it.
-        LwM2mObjectInstance instance;
-        if (request.getInstanceId() == null) {
-            instance = new LwM2mObjectInstance(request.getResources());
+        LwM2mNode node;
+        if (request.unknownObjectInstanceId()) {
+            node = new LwM2mObjectInstance(request.getResources());
         } else {
-            instance = new LwM2mObjectInstance(request.getInstanceId(), request.getResources());
+            node = new LwM2mObject(request.getPath().getObjectId(), request.getObjectInstances());
         }
-        coapRequest.setPayload(encoder.encode(instance, request.getContentFormat(), request.getPath(), model));
+        coapRequest.setPayload(encoder.encode(node, request.getContentFormat(), request.getPath(), model));
         setTarget(coapRequest, request.getPath());
     }
 
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 dcee8c93..1815b856 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
@@ -182,7 +182,9 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
                     coapResponse.getPayloadString(), coapResponse);
         } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CREATED) {
             // handle success response:
-            lwM2mresponse = new CreateResponse(ResponseCode.CREATED, coapResponse.getOptions().getLocationPathString(),
+            lwM2mresponse = new CreateResponse(ResponseCode.CREATED,
+                    coapResponse.getOptions().getLocationPathCount() == 0 ? null
+                            : coapResponse.getOptions().getLocationPathString(),
                     null, coapResponse);
         } else {
             // handle unexpected response:


Test patch:
diff --git a/leshan-core/src/test/java/org/eclipse/leshan/core/node/codec/LwM2mNodeDecoderTest.java b/leshan-core/src/test/java/org/eclipse/leshan/core/node/codec/LwM2mNodeDecoderTest.java
index 665f73d3..017fbc72 100644
--- a/leshan-core/src/test/java/org/eclipse/leshan/core/node/codec/LwM2mNodeDecoderTest.java
+++ b/leshan-core/src/test/java/org/eclipse/leshan/core/node/codec/LwM2mNodeDecoderTest.java
@@ -358,15 +358,17 @@ public class LwM2mNodeDecoderTest {
         assertEquals(5L, resource.getValue(1));
     }
 
-    @Test(expected = CodecException.class)
-    public void tlv_multi_instance_object__missing_instance_tlv() throws CodecException {
-
+    @Test
+    public void tlv_instance_without_id_tlv() throws CodecException {
+        // this is "special" case where instance ID is not defined ...
         byte[] content = TlvEncoder
                 .encode(new Tlv[] { new Tlv(TlvType.RESOURCE_VALUE, null, TlvEncoder.encodeInteger(11), 1),
                                         new Tlv(TlvType.RESOURCE_VALUE, null, TlvEncoder.encodeInteger(10), 2) })
                 .array();
 
-        decoder.decode(content, ContentFormat.TLV, new LwM2mPath(2), model);
+        LwM2mObject object = (LwM2mObject) decoder.decode(content, ContentFormat.TLV, new LwM2mPath(2), model);
+        assertEquals(object.getInstances().size(), 1);
+        assertEquals(object.getInstances().values().iterator().next().getId(), LwM2mObjectInstance.UNDEFINED);
     }
 
     @Test
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/CreateTest.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/CreateTest.java
index 3534313f..1a60b10e 100644
--- a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/CreateTest.java
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/CreateTest.java
@@ -18,6 +18,7 @@
 
 package org.eclipse.leshan.integration.tests;
 
+import static org.eclipse.leshan.core.request.ContentFormat.*;
 import static org.hamcrest.core.Is.is;
 import static org.hamcrest.core.IsInstanceOf.instanceOf;
 import static org.junit.Assert.*;
@@ -26,12 +27,14 @@ import java.util.Arrays;
 
 import org.eclipse.californium.core.coap.Response;
 import org.eclipse.leshan.ResponseCode;
+import org.eclipse.leshan.core.node.LwM2mObject;
 import org.eclipse.leshan.core.node.LwM2mObjectInstance;
 import org.eclipse.leshan.core.node.LwM2mResource;
 import org.eclipse.leshan.core.node.LwM2mSingleResource;
 import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.CreateRequest;
 import org.eclipse.leshan.core.request.ReadRequest;
+import org.eclipse.leshan.core.request.exception.InvalidRequestException;
 import org.eclipse.leshan.core.response.CreateResponse;
 import org.eclipse.leshan.core.response.ReadResponse;
 import org.junit.After;
@@ -61,10 +64,19 @@ public class CreateTest {
     }
 
     @Test
-    public void can_create_instance_of_object_without_instance_id() throws InterruptedException {
+    public void can_create_instance_without_instance_id_tlv() throws InterruptedException {
+        can_create_instance_without_instance_id(TLV);
+    }
+
+    @Test(expected = InvalidRequestException.class)
+    public void can_create_instance_without_instance_id_json() throws InterruptedException {
+        can_create_instance_without_instance_id(JSON);
+    }
+
+    public void can_create_instance_without_instance_id(ContentFormat format) throws InterruptedException {
         // create ACL instance
         CreateResponse response = helper.server.send(helper.getCurrentRegistration(),
-                new CreateRequest(2, new LwM2mResource[] { LwM2mSingleResource.newIntegerResource(0, 123) }));
+                new CreateRequest(format, 2, LwM2mSingleResource.newIntegerResource(0, 123)));
 
         // verify result
         assertEquals(ResponseCode.CREATED, response.getCode());
@@ -74,7 +86,7 @@ public class CreateTest {
 
         // create a second ACL instance
         response = helper.server.send(helper.getCurrentRegistration(),
-                new CreateRequest(2, new LwM2mResource[] { LwM2mSingleResource.newIntegerResource(0, 123) }));
+                new CreateRequest(format, 2, LwM2mSingleResource.newIntegerResource(0, 124)));
 
         // verify result
         assertEquals(ResponseCode.CREATED, response.getCode());
@@ -82,39 +94,76 @@ public class CreateTest {
         assertNotNull(response.getCoapResponse());
         assertThat(response.getCoapResponse(), is(instanceOf(Response.class)));
 
+        // read object 2
+        ReadResponse readResponse = helper.server.send(helper.getCurrentRegistration(), new ReadRequest(2));
+        assertEquals(ResponseCode.CONTENT, readResponse.getCode());
+        LwM2mObject object = (LwM2mObject) readResponse.getContent();
+        assertEquals(object.getInstance(0).getResource(0).getValue(), 123l);
+        assertEquals(object.getInstance(1).getResource(0).getValue(), 124l);
     }
 
     @Test
-    public void can_create_specific_instance_of_object() throws InterruptedException {
+    public void can_create_instance_with_id_tlv() throws InterruptedException {
+        can_create_instance_with_id(TLV);
+    }
+
+    @Test
+    public void can_create_instance_with_id_json() throws InterruptedException {
+        can_create_instance_with_id(JSON);
+    }
+
+    public void can_create_instance_with_id(ContentFormat format) throws InterruptedException {
         // create ACL instance
-        LwM2mObjectInstance instance = new LwM2mObjectInstance(12,
-                Arrays.<LwM2mResource> asList(LwM2mSingleResource.newIntegerResource(3, 123)));
-        CreateResponse response = helper.server.send(helper.getCurrentRegistration(), new CreateRequest(2, instance));
+        LwM2mObjectInstance instance = new LwM2mObjectInstance(12, LwM2mSingleResource.newIntegerResource(3, 123));
+        CreateResponse response = helper.server.send(helper.getCurrentRegistration(),
+                new CreateRequest(format, 2, instance));
 
         // verify result
         assertEquals(ResponseCode.CREATED, response.getCode());
-        assertEquals("2/12", response.getLocation());
+        assertEquals(null, response.getLocation());
         assertNotNull(response.getCoapResponse());
         assertThat(response.getCoapResponse(), is(instanceOf(Response.class)));
+
+        // read object 2
+        ReadResponse readResponse = helper.server.send(helper.getCurrentRegistration(), new ReadRequest(2));
+        assertEquals(ResponseCode.CONTENT, readResponse.getCode());
+        LwM2mObject object = (LwM2mObject) readResponse.getContent();
+        assertEquals(object.getInstance(12).getResource(3).getValue(), 123l);
+    }
+
+    @Test
+    public void can_create_2_instances_of_object_tlv() throws InterruptedException {
+        can_create_2_instances_of_object(TLV);
     }
 
     @Test
-    public void can_create_specific_instance_of_object_with_json() throws InterruptedException {
+    public void can_create_2_instances_of_object_json() throws InterruptedException {
+        can_create_2_instances_of_object(JSON);
+    }
+
+    public void can_create_2_instances_of_object(ContentFormat format) throws InterruptedException {
         // create ACL instance
-        LwM2mObjectInstance instance = new LwM2mObjectInstance(12,
-                Arrays.<LwM2mResource> asList(LwM2mSingleResource.newIntegerResource(3, 123)));
+        LwM2mObjectInstance instance1 = new LwM2mObjectInstance(12, LwM2mSingleResource.newIntegerResource(3, 123));
+        LwM2mObjectInstance instance2 = new LwM2mObjectInstance(13, LwM2mSingleResource.newIntegerResource(3, 124));
         CreateResponse response = helper.server.send(helper.getCurrentRegistration(),
-                new CreateRequest(ContentFormat.JSON, 2, instance));
+                new CreateRequest(format, 2, instance1, instance2));
 
         // verify result
         assertEquals(ResponseCode.CREATED, response.getCode());
-        assertEquals("2/12", response.getLocation());
+        assertEquals(null, response.getLocation());
         assertNotNull(response.getCoapResponse());
         assertThat(response.getCoapResponse(), is(instanceOf(Response.class)));
+
+        // read object 2
+        ReadResponse readResponse = helper.server.send(helper.getCurrentRegistration(), new ReadRequest(2));
+        assertEquals(ResponseCode.CONTENT, readResponse.getCode());
+        LwM2mObject object = (LwM2mObject) readResponse.getContent();
+        assertEquals(object.getInstance(12).getResource(3).getValue(), 123l);
+        assertEquals(object.getInstance(13).getResource(3).getValue(), 124l);
     }
 
     @Test
-    public void cannot_create_instance_of_object() throws InterruptedException {
+    public void cannot_create_instance_of_absent_object() throws InterruptedException {
         // try to create an instance of object 50
         CreateResponse response = helper.server.send(helper.getCurrentRegistration(),
                 new CreateRequest(50, new LwM2mResource[0]));