Repository: eclipse/leshan
Pull request #1008 (2021-05-05 17:06:50)

Add support of "ct=" attribute at Server side
Add support to `ct=` attribute at **Server** side.
"ct" attribute allow to let know to the server content formats supported by the client. 

> If the LwM2M Client supports optional data formats, it MAY inform the LwM2M Server by including the content type in the root path link using the ct= link attribute. (note that the content type value 110 is the value assigned in the CoAP Content-Format Registry for the SenML JSON format used by LwM2M).
>
> </>;ct=110, </1/0>,</1/1>,</2/0>,</2/1>,</2/2>,</2/3>,</2/4>,</3/0>,</4/0>,</5>
 
 (source [core§6.2.1](http://www.openmobilealliance.org/release/LightweightM2M/V1_1_1-20190617-A/HTML-Version/OMA-TS-LightweightM2M_Core-V1_1_1-20190617-A.html#6-2-1-0-621-Register-Operation))

Since LWM2M 1.1, there is no more one mandatory content format  to rule them all (TLV) and so this kind of negotiation is more needed. 
(see https://github.com/OpenMobileAlliance/OMA_LwM2M_for_Developers/issues/514)


Patch:
diff --git a/leshan-server-core/src/main/java/org/eclipse/leshan/server/registration/Registration.java b/leshan-server-core/src/main/java/org/eclipse/leshan/server/registration/Registration.java
index 86b2953b..d9c2e63f 100644
--- a/leshan-server-core/src/main/java/org/eclipse/leshan/server/registration/Registration.java
+++ b/leshan-server-core/src/main/java/org/eclipse/leshan/server/registration/Registration.java
@@ -24,7 +24,9 @@ import java.util.Comparator;
 import java.util.Date;
 import java.util.EnumSet;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -34,15 +36,20 @@ import org.eclipse.leshan.core.LwM2m.Version;
 import org.eclipse.leshan.core.attributes.Attribute;
 import org.eclipse.leshan.core.model.ObjectModel;
 import org.eclipse.leshan.core.request.BindingMode;
+import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.Identity;
 import org.eclipse.leshan.core.util.StringUtils;
 import org.eclipse.leshan.core.util.Validate;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * An immutable structure which represent a LW-M2M client registration on the server
  */
 public class Registration {
 
+    private static final Logger LOG = LoggerFactory.getLogger(Registration.class);
+
     private static final long DEFAULT_LIFETIME_IN_SEC = 86400L;
 
     private final Date registrationDate;
@@ -75,6 +82,9 @@ public class Registration {
     // The location where LWM2M objects are hosted on the device
     private final String rootPath;
 
+    // All ContentFormat supported by the client
+    private final Set<ContentFormat> supportedContentFormats;
+
     private final Date lastUpdate;
 
     protected Registration(Builder builder) {
@@ -91,6 +101,7 @@ public class Registration {
         // object links related params
         objectLinks = builder.objectLinks;
         rootPath = builder.rootPath;
+        supportedContentFormats = builder.supportedContentFormats;
         supportedObjects = new AtomicReference<Map<Integer, String>>(builder.supportedObjects);
 
         // other params
@@ -231,6 +242,13 @@ public class Registration {
         return rootPath;
     }
 
+    /**
+     * @return all {@link ContentFormat} supported by the client.
+     */
+    public Set<ContentFormat> getSupportedContentFormats() {
+        return supportedContentFormats;
+    }
+
     /**
      * Gets the unique name the client has registered with.
      * 
@@ -313,9 +331,10 @@ public class Registration {
     @Override
     public String toString() {
         return String.format(
-                "Registration [registrationDate=%s, identity=%s, lifeTimeInSec=%s, smsNumber=%s, lwM2mVersion=%s, bindingMode=%s, endpoint=%s, registrationId=%s, objectLinks=%s, lastUpdate=%s]",
-                registrationDate, identity, lifeTimeInSec, smsNumber, lwM2mVersion, bindingMode, endpoint, id,
-                Arrays.toString(objectLinks), lastUpdate);
+                "Registration [registrationDate=%s, identity=%s, lifeTimeInSec=%s, smsNumber=%s, lwM2mVersion=%s, bindingMode=%s, queueMode=%s, endpoint=%s, id=%s, objectLinks=%s, supportedObjects=%s, additionalRegistrationAttributes=%s, rootPath=%s, supportedContentFormats=%s, lastUpdate=%s]",
+                registrationDate, identity, lifeTimeInSec, smsNumber, lwM2mVersion, bindingMode, queueMode, endpoint,
+                id, Arrays.toString(objectLinks), supportedObjects, additionalRegistrationAttributes, rootPath,
+                supportedContentFormats, lastUpdate);
     }
 
     @Override
@@ -336,6 +355,7 @@ public class Registration {
         result = prime * result + ((registrationDate == null) ? 0 : registrationDate.hashCode());
         result = prime * result + ((rootPath == null) ? 0 : rootPath.hashCode());
         result = prime * result + ((smsNumber == null) ? 0 : smsNumber.hashCode());
+        result = prime * result + ((supportedContentFormats == null) ? 0 : supportedContentFormats.hashCode());
         return result;
     }
 
@@ -407,6 +427,11 @@ public class Registration {
                 return false;
         } else if (!smsNumber.equals(other.smsNumber))
             return false;
+        if (supportedContentFormats == null) {
+            if (other.supportedContentFormats != null)
+                return false;
+        } else if (!supportedContentFormats.equals(other.supportedContentFormats))
+            return false;
         return true;
     }
 
@@ -467,9 +492,10 @@ public class Registration {
         private String smsNumber;
         private EnumSet<BindingMode> bindingMode;
         private Boolean queueMode;
-        private Version lwM2mVersion;
+        private Version lwM2mVersion = Version.getDefault();
         private Link[] objectLinks;
         private String rootPath;
+        private Set<ContentFormat> supportedContentFormats;
         private Map<Integer, String> supportedObjects;
         private Map<String, String> additionalRegistrationAttributes;
 
@@ -536,6 +562,19 @@ public class Registration {
             return this;
         }
 
+        public Builder supportedContentFormats(Set<ContentFormat> supportedContentFormats) {
+            this.supportedContentFormats = supportedContentFormats;
+            return this;
+        }
+
+        public Builder supportedContentFormats(ContentFormat... supportedContentFormats) {
+            this.supportedContentFormats = new HashSet<>();
+            for (ContentFormat contentFormat : supportedContentFormats) {
+                this.supportedContentFormats.add(contentFormat);
+            }
+            return this;
+        }
+
         public Builder additionalRegistrationAttributes(Map<String, String> additionalRegistrationAttributes) {
             this.additionalRegistrationAttributes = additionalRegistrationAttributes;
             return this;
@@ -557,10 +596,51 @@ public class Registration {
                     }
                 }
 
-                // TODO extract supported Content format
-                // TODO extract object supported
-                // TODO extract available instances
+                // Extract data from link object
+                for (Link link : objectLinks) {
+                    // TODO extract object supported
+                    // TODO extract available instances
+
+                    if (link != null) {
+                        // search supported Content format in root link
+                        if (rootPath.equals(link.getUrl())) {
+                            String ctValue = link.getAttributes().get("ct");
+                            if (ctValue != null) {
+                                supportedContentFormats = extractContentFormat(ctValue);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        private Set<ContentFormat> extractContentFormat(String ctValue) {
+            Set<ContentFormat> supportedContentFormats = new HashSet<>();
+
+            // add content format from ct attributes
+            String[] formats = ctValue.split(" ");
+            for (String codeAsString : formats) {
+                try {
+                    ContentFormat contentformat = ContentFormat.fromCode(codeAsString);
+                    if (supportedContentFormats.contains(contentformat)) {
+                        LOG.warn("Duplicate Content format {} in ct={} attributes for registration {} of client {} ",
+                                codeAsString, supportedContentFormats, registrationId, endpoint);
+                    }
+                    supportedContentFormats.add(contentformat);
+                } catch (NumberFormatException e) {
+                    LOG.warn(
+                            "Invalid supported Content format {} in ct={} attributes for registration {} of client {} ",
+                            codeAsString, supportedContentFormats, registrationId, endpoint);
+                }
+            }
+
+            // add mandatory content format
+            for (ContentFormat format : ContentFormat.knownContentFormat) {
+                if (format.isMandatoryForClient(lwM2mVersion)) {
+                    supportedContentFormats.add(format);
+                }
             }
+            return supportedContentFormats;
         }
 
         public Registration build() {
@@ -573,20 +653,25 @@ public class Registration {
             registrationDate = registrationDate == null ? new Date() : registrationDate;
             lastUpdate = lastUpdate == null ? new Date() : lastUpdate;
 
+            // Extract data from object links if wanted
+            if (extractData) {
+                extractDataFromObjectLinks();
+            }
+
             // Make collection immutable
+            // We create a new Collection and make it "unmodifiable".
+            if (supportedContentFormats == null || supportedContentFormats.isEmpty()) {
+                supportedContentFormats = Collections.emptySet();
+            } else {
+                supportedContentFormats = Collections.unmodifiableSet(new HashSet<>(supportedContentFormats));
+            }
             if (additionalRegistrationAttributes == null || additionalRegistrationAttributes.isEmpty()) {
                 additionalRegistrationAttributes = Collections.emptyMap();
             } else {
-                // We create a new HashMap to have a real immutable map and to avoid "unmodifiableMap" encapsulation.
                 additionalRegistrationAttributes = Collections
                         .unmodifiableMap(new HashMap<>(additionalRegistrationAttributes));
             }
 
-            // Extract data from object links if wanted
-            if (extractData) {
-                extractDataFromObjectLinks();
-            }
-
             // Create Registration
             return new Registration(this);
         }
diff --git a/leshan-server-core/src/main/java/org/eclipse/leshan/server/registration/RegistrationUpdate.java b/leshan-server-core/src/main/java/org/eclipse/leshan/server/registration/RegistrationUpdate.java
index 2a5c7d9d..f41a0358 100644
--- a/leshan-server-core/src/main/java/org/eclipse/leshan/server/registration/RegistrationUpdate.java
+++ b/leshan-server-core/src/main/java/org/eclipse/leshan/server/registration/RegistrationUpdate.java
@@ -87,7 +87,8 @@ public class RegistrationUpdate {
         builder.lwM2mVersion(registration.getLwM2mVersion()).lifeTimeInSec(lifeTimeInSec).smsNumber(smsNumber)
                 .bindingMode(bindingMode).queueMode(registration.getQueueMode()).objectLinks(linkObject)
                 .registrationDate(registration.getRegistrationDate()).lastUpdate(lastUpdate)
-                .additionalRegistrationAttributes(additionalAttributes).rootPath(registration.getRootPath());
+                .additionalRegistrationAttributes(additionalAttributes).rootPath(registration.getRootPath())
+                .supportedContentFormats(registration.getSupportedContentFormats());
 
         return builder.build();
 
diff --git a/leshan-server-redis/src/main/java/org/eclipse/leshan/server/redis/serialization/RegistrationSerDes.java b/leshan-server-redis/src/main/java/org/eclipse/leshan/server/redis/serialization/RegistrationSerDes.java
index ddc20f55..8bcd07e8 100644
--- a/leshan-server-redis/src/main/java/org/eclipse/leshan/server/redis/serialization/RegistrationSerDes.java
+++ b/leshan-server-redis/src/main/java/org/eclipse/leshan/server/redis/serialization/RegistrationSerDes.java
@@ -17,11 +17,14 @@ package org.eclipse.leshan.server.redis.serialization;
 
 import java.util.Date;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
+import java.util.Set;
 
 import org.eclipse.leshan.core.Link;
 import org.eclipse.leshan.core.LwM2m.Version;
 import org.eclipse.leshan.core.request.BindingMode;
+import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.server.registration.Registration;
 
 import com.eclipsesource.json.Json;
@@ -72,6 +75,14 @@ public class RegistrationSerDes {
         o.add("addAttr", addAttr);
         o.add("root", r.getRootPath());
         o.add("lastUp", r.getLastUpdate().getTime());
+
+        // add supported content format
+        Set<ContentFormat> supportedContentFormat = r.getSupportedContentFormats();
+        JsonArray ct = Json.array();
+        for (ContentFormat contentFormat : supportedContentFormat) {
+            ct.add(contentFormat.getCode());
+        }
+        o.add("ct", ct);
         return o;
     }
 
@@ -133,6 +144,18 @@ public class RegistrationSerDes {
         }
         b.additionalRegistrationAttributes(addAttr);
 
+        // add supported content format
+        JsonValue ct = jObj.get("ct");
+        if (ct == null) {
+            // Backward compatibility : if suppObjs doesn't exist we extract supported object from object link
+            b.extractDataFromObjectLink(true);
+        } else {
+            Set<ContentFormat> supportedContentFormat = new HashSet<>();
+            for (JsonValue ctCode : ct.asArray()) {
+                supportedContentFormat.add(ContentFormat.fromCode(ctCode.asInt()));
+            }
+            b.supportedContentFormats(supportedContentFormat);
+        }
         return b.build();
     }
 


Test patch:
diff --git a/leshan-server-core/src/test/java/org/eclipse/leshan/server/registration/RegistrationTest.java b/leshan-server-core/src/test/java/org/eclipse/leshan/server/registration/RegistrationTest.java
index ffce1b5b..2c6edee4 100644
--- a/leshan-server-core/src/test/java/org/eclipse/leshan/server/registration/RegistrationTest.java
+++ b/leshan-server-core/src/test/java/org/eclipse/leshan/server/registration/RegistrationTest.java
@@ -18,10 +18,13 @@ package org.eclipse.leshan.server.registration;
 import static org.junit.Assert.*;
 
 import java.net.InetSocketAddress;
+import java.util.Arrays;
 import java.util.Map;
+import java.util.Set;
 
 import org.eclipse.leshan.core.Link;
 import org.eclipse.leshan.core.model.ObjectModel;
+import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.Identity;
 import org.eclipse.leshan.server.registration.Registration.Builder;
 import org.junit.Test;
@@ -42,9 +45,30 @@ public class RegistrationTest {
         assertEquals(ObjectModel.DEFAULT_VERSION, supportedObject.get(3));
     }
 
+    @Test
+    public void test_object_links_with_ct_but_with_rt() {
+        Registration reg = given_a_registration_with_object_link_like("</>;ct=0 42 11543,</1/0>,</3/0>");
+
+        // check root path
+        assertEquals("/", reg.getRootPath());
+
+        // Ensure supported objects are correct
+        Map<Integer, String> supportedObject = reg.getSupportedObject();
+        assertEquals(2, supportedObject.size());
+        assertEquals(ObjectModel.DEFAULT_VERSION, supportedObject.get(1));
+        assertEquals(ObjectModel.DEFAULT_VERSION, supportedObject.get(3));
+
+        // Check Supported Content format
+        Set<ContentFormat> supportedContentFormats = reg.getSupportedContentFormats();
+        assertEquals(4, supportedContentFormats.size()); // 3 + 1 mandatory TLV content format for LWM2M v1.0
+        assertTrue(supportedContentFormats.containsAll(
+                Arrays.asList(ContentFormat.TLV, ContentFormat.TEXT, ContentFormat.OPAQUE, ContentFormat.JSON)));
+    }
+
     @Test
     public void test_object_links_with_default_rootpath() {
-        Registration reg = given_a_registration_with_object_link_like("</>;rt=\"oma.lwm2m\", </1/0>,</3/0>");
+        Registration reg = given_a_registration_with_object_link_like(
+                "</>;rt=\"oma.lwm2m\";ct=0 42 11543,</1/0>,</3/0>");
 
         // check root path
         assertEquals("/", reg.getRootPath());
@@ -54,6 +78,12 @@ public class RegistrationTest {
         assertEquals(2, supportedObject.size());
         assertEquals(ObjectModel.DEFAULT_VERSION, supportedObject.get(1));
         assertEquals(ObjectModel.DEFAULT_VERSION, supportedObject.get(3));
+
+        // Check Supported Content format
+        Set<ContentFormat> supportedContentFormats = reg.getSupportedContentFormats();
+        assertEquals(4, supportedContentFormats.size()); // 3 + 1 mandatory TLV content format for LWM2M v1.0
+        assertTrue(supportedContentFormats.containsAll(
+                Arrays.asList(ContentFormat.TLV, ContentFormat.TEXT, ContentFormat.OPAQUE, ContentFormat.JSON)));
     }
 
     @Test
diff --git a/leshan-server-redis/src/test/java/org/eclipse/leshan/server/redis/serialization/RegistrationSerDesTest.java b/leshan-server-redis/src/test/java/org/eclipse/leshan/server/redis/serialization/RegistrationSerDesTest.java
index de868ba7..a0215e6b 100644
--- a/leshan-server-redis/src/test/java/org/eclipse/leshan/server/redis/serialization/RegistrationSerDesTest.java
+++ b/leshan-server-redis/src/test/java/org/eclipse/leshan/server/redis/serialization/RegistrationSerDesTest.java
@@ -23,6 +23,7 @@ import java.util.HashMap;
 import java.util.Map;
 
 import org.eclipse.leshan.core.Link;
+import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.Identity;
 import org.eclipse.leshan.server.registration.Registration;
 import org.junit.Test;
@@ -40,7 +41,8 @@ public class RegistrationSerDesTest {
         objs[1] = new Link("/0/2");
 
         Registration.Builder builder = new Registration.Builder("registrationId", "endpoint",
-                Identity.unsecure(Inet4Address.getLoopbackAddress(), 1)).objectLinks(objs).rootPath("/");
+                Identity.unsecure(Inet4Address.getLoopbackAddress(), 1)).objectLinks(objs).rootPath("/")
+                        .supportedContentFormats(ContentFormat.TLV, ContentFormat.TEXT);
 
         builder.registrationDate(new Date(100L));
         builder.lastUpdate(new Date(101L));