ONOS-7577 - REST API to add and remove buckets from a group

Change-Id: I95bad8db7baf6231ffb0c077d9e5d8243da64fd4
diff --git a/core/common/src/main/java/org/onosproject/codec/impl/GroupBucketCodec.java b/core/common/src/main/java/org/onosproject/codec/impl/GroupBucketCodec.java
index a9bf5d8..9170820 100644
--- a/core/common/src/main/java/org/onosproject/codec/impl/GroupBucketCodec.java
+++ b/core/common/src/main/java/org/onosproject/codec/impl/GroupBucketCodec.java
@@ -39,6 +39,7 @@
     private static final String WATCH_GROUP = "watchGroup";
     private static final String PACKETS = "packets";
     private static final String BYTES = "bytes";
+    private static final String BUCKET_ID = "bucketId";
     private static final String MISSING_MEMBER_MESSAGE =
             " member is required in Group";
 
@@ -50,7 +51,8 @@
                 .put(TYPE, bucket.type().toString())
                 .put(WEIGHT, bucket.weight())
                 .put(PACKETS, bucket.packets())
-                .put(BYTES, bucket.bytes());
+                .put(BYTES, bucket.bytes())
+                .put(BUCKET_ID, bucket.hashCode());
 
         if (bucket.watchPort() != null) {
             result.put(WATCH_PORT, bucket.watchPort().toString());
diff --git a/web/api/src/main/java/org/onosproject/rest/resources/GroupsWebResource.java b/web/api/src/main/java/org/onosproject/rest/resources/GroupsWebResource.java
index a0875d5..d2a361e 100644
--- a/web/api/src/main/java/org/onosproject/rest/resources/GroupsWebResource.java
+++ b/web/api/src/main/java/org/onosproject/rest/resources/GroupsWebResource.java
@@ -18,12 +18,17 @@
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.node.ArrayNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.google.common.collect.ImmutableList;
+import org.onlab.util.HexString;
+import org.onosproject.codec.JsonCodec;
 import org.onosproject.net.Device;
 import org.onosproject.net.DeviceId;
 import org.onosproject.net.device.DeviceService;
 import org.onosproject.net.group.DefaultGroupDescription;
 import org.onosproject.net.group.DefaultGroupKey;
 import org.onosproject.net.group.Group;
+import org.onosproject.net.group.GroupBucket;
+import org.onosproject.net.group.GroupBuckets;
 import org.onosproject.net.group.GroupDescription;
 import org.onosproject.net.group.GroupKey;
 import org.onosproject.net.group.GroupService;
@@ -43,8 +48,10 @@
 import javax.ws.rs.core.UriInfo;
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.IntStream;
 
-import org.onlab.util.HexString;
 import static org.onlab.util.Tools.nullIsNotFound;
 import static org.onlab.util.Tools.readTreeFromStream;
 
@@ -65,6 +72,14 @@
     private final ObjectNode root = mapper().createObjectNode();
     private final ArrayNode groupsNode = root.putArray("groups");
 
+    private GroupKey createKey(String appCookieString) {
+        if (!appCookieString.startsWith("0x")) {
+            throw new IllegalArgumentException("APP_COOKIE must be a hex string starts with 0x");
+        }
+        return  new DefaultGroupKey(HexString.fromHexString(
+                appCookieString.split("0x")[1], ""));
+    }
+
     /**
      * Returns all groups of all devices.
      *
@@ -118,11 +133,7 @@
                                                    @PathParam("appCookie") String appCookie) {
         final DeviceId deviceIdInstance = DeviceId.deviceId(deviceId);
 
-        if (!appCookie.startsWith("0x")) {
-            throw new IllegalArgumentException("APP_COOKIE must be a hex string starts with 0x");
-        }
-        final GroupKey appCookieInstance = new DefaultGroupKey(HexString.fromHexString(
-                appCookie.split("0x")[1], ""));
+        final GroupKey appCookieInstance = createKey(appCookie);
 
         Group group = nullIsNotFound(groupService.getGroup(deviceIdInstance, appCookieInstance),
                 GROUP_NOT_FOUND);
@@ -187,13 +198,120 @@
                                                       @PathParam("appCookie") String appCookie) {
         DeviceId deviceIdInstance = DeviceId.deviceId(deviceId);
 
-        if (!appCookie.startsWith("0x")) {
-            throw new IllegalArgumentException("APP_COOKIE must be a hex string starts with 0x");
-        }
-        GroupKey appCookieInstance = new DefaultGroupKey(HexString.fromHexString(
-                appCookie.split("0x")[1], ""));
+        final GroupKey appCookieInstance = createKey(appCookie);
 
         groupService.removeGroup(deviceIdInstance, appCookieInstance, null);
         return Response.noContent().build();
     }
+
+    /**
+     * Adds buckets to a group using the group service.
+     *
+     * @param deviceIdString device Id
+     * @param appCookieString application cookie
+     * @param stream JSON stream
+     */
+    private void updateGroupBuckets(String deviceIdString, String appCookieString, InputStream stream)
+                 throws IOException {
+        DeviceId deviceId = DeviceId.deviceId(deviceIdString);
+        final GroupKey groupKey = createKey(appCookieString);
+
+        Group group = nullIsNotFound(groupService.getGroup(deviceId, groupKey), GROUP_NOT_FOUND);
+
+        ObjectNode jsonTree = readTreeFromStream(mapper(), stream);
+
+        GroupBuckets buckets = null;
+        List<GroupBucket> groupBucketList = new ArrayList<>();
+        JsonNode bucketsJson = jsonTree.get("buckets");
+        final JsonCodec<GroupBucket> groupBucketCodec = codec(GroupBucket.class);
+        if (bucketsJson != null) {
+            IntStream.range(0, bucketsJson.size())
+                    .forEach(i -> {
+                        ObjectNode bucketJson = (ObjectNode) bucketsJson.get(i);
+                        groupBucketList.add(groupBucketCodec.decode(bucketJson, this));
+                    });
+            buckets = new GroupBuckets(groupBucketList);
+        }
+        groupService.addBucketsToGroup(deviceId, groupKey, buckets, groupKey, group.appId());
+    }
+
+    /**
+     * Adds buckets to an existing group.
+     *
+     * @param deviceIdString device identifier
+     * @param appCookieString application cookie
+     * @param stream  buckets JSON
+     * @return status of the request - NO_CONTENT if the JSON is correct,
+     * BAD_REQUEST if the JSON is invalid
+     * @onos.rsModel GroupsBucketsPost
+     */
+    @POST
+    @Path("{deviceId}/{appCookie}/buckets")
+    @Consumes(MediaType.APPLICATION_JSON)
+    @Produces(MediaType.APPLICATION_JSON)
+    public Response addBucket(@PathParam("deviceId") String deviceIdString,
+                              @PathParam("appCookie") String appCookieString,
+                              InputStream stream) {
+        try {
+            updateGroupBuckets(deviceIdString, appCookieString, stream);
+
+            return Response
+                    .noContent()
+                    .build();
+        } catch (IOException ex) {
+            throw new IllegalArgumentException(ex);
+        }
+    }
+
+    /**
+     * Removes buckets from a group using the group service.
+     *
+     * @param deviceIdString device Id
+     * @param appCookieString application cookie
+     * @param bucketIds comma separated list of bucket Ids to remove
+     */
+    private void removeGroupBuckets(String deviceIdString, String appCookieString, String bucketIds) {
+        DeviceId deviceId = DeviceId.deviceId(deviceIdString);
+        final GroupKey groupKey = createKey(appCookieString);
+
+        Group group = nullIsNotFound(groupService.getGroup(deviceId, groupKey), GROUP_NOT_FOUND);
+
+        List<GroupBucket> groupBucketList = new ArrayList<>();
+
+        List<String> bucketsToRemove = ImmutableList.copyOf(bucketIds.split(","));
+
+        bucketsToRemove.forEach(
+                bucketIdToRemove -> {
+                    group.buckets().buckets().stream()
+                            .filter(bucket -> Integer.toString(bucket.hashCode()).equals(bucketIdToRemove))
+                            .forEach(groupBucketList::add);
+                }
+        );
+        groupService.removeBucketsFromGroup(deviceId, groupKey,
+                                            new GroupBuckets(groupBucketList), groupKey,
+                                            group.appId());
+    }
+
+    /**
+     * Removes buckets from an existing group.
+     *
+     * @param deviceIdString device identifier
+     * @param appCookieString application cookie
+     * @param bucketIds comma separated list of identifiers of buckets to remove from this group
+     * @return status of the request - NO_CONTENT if the JSON is correct,
+     * BAD_REQUEST if the JSON is invalid
+     */
+    @DELETE
+    @Path("{deviceId}/{appCookie}/buckets/{bucketIds}")
+    @Consumes(MediaType.APPLICATION_JSON)
+    @Produces(MediaType.APPLICATION_JSON)
+    public Response deleteBuckets(@PathParam("deviceId") String deviceIdString,
+                                  @PathParam("appCookie") String appCookieString,
+                                  @PathParam("bucketIds") String bucketIds) {
+        removeGroupBuckets(deviceIdString, appCookieString, bucketIds);
+
+        return Response
+                .noContent()
+                .build();
+    }
 }
diff --git a/web/api/src/main/resources/definitions/GroupsBucketsPost.json b/web/api/src/main/resources/definitions/GroupsBucketsPost.json
new file mode 100644
index 0000000..2229548
--- /dev/null
+++ b/web/api/src/main/resources/definitions/GroupsBucketsPost.json
@@ -0,0 +1,65 @@
+{
+  "type": "object",
+  "title": "buckets",
+  "required": [
+    "buckets"
+  ],
+  "properties": {
+    "buckets": {
+      "type": "array",
+      "xml": {
+        "name": "buckets",
+        "wrapped": true
+      },
+      "items": {
+        "type": "object",
+        "title": "buckets",
+        "required": [
+          "treatment",
+          "weight",
+          "watchPort",
+          "watchGroup"
+        ],
+        "properties": {
+          "treatment": {
+            "type": "object",
+            "title": "treatment",
+            "required": [
+              "instructions",
+              "deferred"
+            ],
+            "properties": {
+              "instructions": {
+                "type": "array",
+                "title": "treatment",
+                "required": [
+                  "properties",
+                  "port"
+                ],
+                "items": {
+                  "type": "object",
+                  "title": "instructions",
+                  "required": [
+                    "type",
+                    "port"
+                  ],
+                  "properties": {
+                    "type": {
+                      "type": "string",
+                      "example": "OUTPUT"
+                    },
+                    "port": {
+                      "type": "string",
+                      "example": "2"
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+
diff --git a/web/api/src/test/java/org/onosproject/rest/resources/GroupsResourceTest.java b/web/api/src/test/java/org/onosproject/rest/resources/GroupsResourceTest.java
index ab4cf9d..2cdb6ff 100644
--- a/web/api/src/test/java/org/onosproject/rest/resources/GroupsResourceTest.java
+++ b/web/api/src/test/java/org/onosproject/rest/resources/GroupsResourceTest.java
@@ -19,6 +19,7 @@
 import com.eclipsesource.json.Json;
 import com.eclipsesource.json.JsonArray;
 import com.eclipsesource.json.JsonObject;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import org.hamcrest.Description;
 import org.hamcrest.Matchers;
@@ -47,6 +48,7 @@
 import org.onosproject.net.group.GroupDescription;
 import org.onosproject.net.group.GroupKey;
 import org.onosproject.net.group.GroupService;
+import org.onosproject.net.group.GroupServiceAdapter;
 
 import javax.ws.rs.client.Entity;
 import javax.ws.rs.client.WebTarget;
@@ -528,4 +530,122 @@
         assertThat(deleteResponse.getStatus(),
                 is(HttpURLConnection.HTTP_NO_CONTENT));
     }
+
+    private JsonArray fetchAndCheckBuckets(DeviceId deviceId, int expectedBucketCount) {
+        WebTarget wt = target();
+        final String fetchGroupPath = wt.path("groups/" + deviceId).request().get(String.class);
+        final JsonObject result = Json.parse(fetchGroupPath).asObject();
+        assertThat(result, notNullValue());
+        assertThat(result.names(), hasSize(1));
+        assertThat(result.names().get(0), is("groups"));
+        JsonArray jsonGroups = result.get("groups").asArray();
+        assertThat(jsonGroups, notNullValue());
+        JsonObject group = jsonGroups.get(0).asObject();
+        JsonArray buckets = group.get("buckets").asArray();
+        assertThat(buckets.size(), is(expectedBucketCount));
+        return buckets;
+    }
+
+    private void removeBucketsViaRest(String endpointPath) {
+        WebTarget wt = target();
+        Response removeResponse = wt.path(endpointPath)
+                .request(MediaType.APPLICATION_JSON_TYPE)
+                .delete();
+        assertThat(removeResponse.getStatus(), is(HttpURLConnection.HTTP_NO_CONTENT));
+    }
+
+
+    class TestGroupService extends GroupServiceAdapter {
+        @Override
+        public void addBucketsToGroup(DeviceId deviceId, GroupKey oldCookie, GroupBuckets buckets,
+                                      GroupKey newCookie, ApplicationId appId) {
+            group1.buckets = buckets;
+        }
+
+        @Override
+        public void removeBucketsFromGroup(DeviceId deviceId, GroupKey oldCookie, GroupBuckets buckets,
+                                      GroupKey newCookie, ApplicationId appId) {
+            if (!group1.buckets.buckets().isEmpty()) {
+                ArrayList<GroupBucket> newList = new ArrayList<>(group1.buckets.buckets());
+                for (GroupBucket bucketToRemove : buckets.buckets()) {
+                    for (GroupBucket bucketToCheck : group1.buckets.buckets()) {
+                        if (bucketToCheck.equals(bucketToRemove)) {
+                            newList.remove(bucketToCheck);
+                        }
+                    }
+                }
+                group1.buckets = new GroupBuckets(newList);
+            }
+        }
+
+        @Override
+        public Group getGroup(DeviceId deviceId, GroupKey appCookie) {
+            return group1;
+        }
+
+        @Override
+        public Iterable<Group> getGroups(DeviceId deviceId) {
+            return ImmutableList.of(group1);
+        }
+    }
+
+    /**
+     * Tests adding and removing buckets.
+     */
+    @Test
+    public void testAddRemoveBucket() {
+        String deviceIdBasePath = "groups/%s/";
+        String endpointBasePath = deviceIdBasePath + "%s/buckets/";
+        TestGroupService groupService = new TestGroupService();
+        expect(mockGroupService.getGroup(anyObject(), anyObject()))
+                .andDelegateTo(groupService).anyTimes();
+        expect(mockGroupService.getGroups(anyObject()))
+                .andDelegateTo(groupService).anyTimes();
+        mockGroupService.addBucketsToGroup(anyObject(), anyObject(), anyObject(), anyObject(), anyObject());
+        expectLastCall().andDelegateTo(groupService).anyTimes();
+        mockGroupService.removeBucketsFromGroup(anyObject(), anyObject(), anyObject(), anyObject(), anyObject());
+        expectLastCall().andDelegateTo(groupService).anyTimes();
+        replay(mockGroupService);
+
+        WebTarget wt = target();
+
+        // Add buckets
+        String addEndpointPath = String.format(endpointBasePath, group1.deviceId(), group1.appCookie());
+        InputStream addJsonStream = GroupsResourceTest.class
+                .getResourceAsStream("post-group-add-buckets.json");
+
+        Response addResponse = wt.path(addEndpointPath)
+                .request(MediaType.APPLICATION_JSON_TYPE)
+                .post(Entity.json(addJsonStream));
+        assertThat(addResponse.getStatus(), is(HttpURLConnection.HTTP_NO_CONTENT));
+
+        // Check that buckets are there
+        JsonArray bucketsAfterAdd = fetchAndCheckBuckets(deviceId1, 4);
+
+        String bucketId1 = Long.toString(bucketsAfterAdd.get(0).asObject().get("bucketId").asLong());
+        String bucketId2 = Long.toString(bucketsAfterAdd.get(1).asObject().get("bucketId").asLong());
+        String bucketId3 = Long.toString(bucketsAfterAdd.get(2).asObject().get("bucketId").asLong());
+        String bucketId4 = Long.toString(bucketsAfterAdd.get(3).asObject().get("bucketId").asLong());
+
+        // Remove one bucket
+        String removeEndpointPath = addEndpointPath + bucketId1;
+        removeBucketsViaRest(removeEndpointPath);
+        fetchAndCheckBuckets(deviceId1, 3);
+
+        // Remove two buckets
+        String removeTwoEndpointPath = addEndpointPath + bucketId2 + ',' + bucketId3;
+        removeBucketsViaRest(removeTwoEndpointPath);
+        fetchAndCheckBuckets(deviceId1, 1);
+
+        // Remove nothing - non-existent bucket id
+        String removeNothingEndpointPath = addEndpointPath + "no-such-bucket";
+        removeBucketsViaRest(removeNothingEndpointPath);
+        fetchAndCheckBuckets(deviceId1, 1);
+
+        // Remove last bucket - bucket list should be empty
+        String lastOneEndpointPath = addEndpointPath + bucketId4;
+        removeBucketsViaRest(lastOneEndpointPath);
+        fetchAndCheckBuckets(deviceId1, 0);
+    }
+
 }
diff --git a/web/api/src/test/resources/org/onosproject/rest/resources/post-group-add-buckets.json b/web/api/src/test/resources/org/onosproject/rest/resources/post-group-add-buckets.json
new file mode 100644
index 0000000..82f3c9d
--- /dev/null
+++ b/web/api/src/test/resources/org/onosproject/rest/resources/post-group-add-buckets.json
@@ -0,0 +1,49 @@
+{
+  "appCookie": "0x1",
+  "buckets": [
+    {
+      "type": "ALL",
+      "treatment": {
+        "instructions": [
+          {
+            "type": "OUTPUT",
+            "port": 21
+          }
+        ]
+      }
+    },
+    {
+      "type": "ALL",
+      "treatment": {
+        "instructions": [
+          {
+            "type": "OUTPUT",
+            "port": 22
+          }
+        ]
+      }
+    },
+    {
+      "type": "ALL",
+      "treatment": {
+        "instructions": [
+          {
+            "type": "OUTPUT",
+            "port": 23
+          }
+        ]
+      }
+    },
+    {
+      "type": "ALL",
+      "treatment": {
+        "instructions": [
+          {
+            "type": "OUTPUT",
+            "port": 24
+          }
+        ]
+      }
+    }
+  ]
+}
\ No newline at end of file