From 2be681a57d022e204b8d66cbcf3643c57c60cc20 Mon Sep 17 00:00:00 2001
From: Guy Paddock <guy@rosieapp.com>
Date: Fri, 27 Oct 2017 04:49:12 +0000
Subject: [PATCH] Adds subtree flattening to collections

---
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java |   40 +++++--
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceCollection.java     |   44 +++++++-
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java         |  164 +++++++++++++++++++++++---------
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceSingleton.java      |    8 +
 opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties      |    1 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java           |   36 ++++++
 6 files changed, 224 insertions(+), 69 deletions(-)

diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java
index 65572d5..5f83434 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java
@@ -306,24 +306,37 @@
     private enum NamingStrategyType { CLIENTDNNAMING, CLIENTNAMING, SERVERNAMING }
     private enum SubResourceType { COLLECTION, SINGLETON }
 
-    private static SubResource configureSubResource(final String urlTemplate, final JsonValue config) {
+    private static SubResource configureSubResource(final String urlTemplate,
+                                                    final JsonValue config) {
         final String dnTemplate = config.get("dnTemplate").defaultTo("").asString();
         final Boolean isReadOnly = config.get("isReadOnly").defaultTo(false).asBoolean();
         final String resourceId = config.get("resource").required().asString();
+        final Boolean flattenSubtree =
+            config.get("flattenSubtree").defaultTo(false).asBoolean();
 
-        if (config.get("type").required().as(enumConstant(SubResourceType.class)) == SubResourceType.COLLECTION) {
-            final String[] glueObjectClasses = config.get("glueObjectClasses")
-                                                     .defaultTo(emptyList())
-                                                     .asList(String.class)
-                                                     .toArray(new String[0]);
+        final SubResourceType subResourceType =
+          config.get("type").required().as(enumConstant(SubResourceType.class));
 
-            final SubResourceCollection collection = collectionOf(resourceId).urlTemplate(urlTemplate)
-                                                                             .dnTemplate(dnTemplate)
-                                                                             .isReadOnly(isReadOnly)
-                                                                             .glueObjectClasses(glueObjectClasses);
+        if (subResourceType == SubResourceType.COLLECTION) {
+            final String[] glueObjectClasses =
+                config.get("glueObjectClasses")
+                    .defaultTo(emptyList())
+                    .asList(String.class)
+                    .toArray(new String[0]);
+
+            final SubResourceCollection collection =
+                collectionOf(resourceId)
+                    .urlTemplate(urlTemplate)
+                    .dnTemplate(dnTemplate)
+                    .isReadOnly(isReadOnly)
+                    .glueObjectClasses(glueObjectClasses)
+                    .flattenSubtree(flattenSubtree);
 
             final JsonValue namingStrategy = config.get("namingStrategy").required();
-            switch (namingStrategy.get("type").required().as(enumConstant(NamingStrategyType.class))) {
+            final NamingStrategyType namingStrategyType =
+                namingStrategy.get("type").required().as(enumConstant(NamingStrategyType.class));
+
+            switch (namingStrategyType) {
             case CLIENTDNNAMING:
                 collection.useClientDnNaming(namingStrategy.get("dnAttribute").required().asString());
                 break;
@@ -339,7 +352,10 @@
 
             return collection;
         } else {
-            return singletonOf(resourceId).urlTemplate(urlTemplate).dnTemplate(dnTemplate).isReadOnly(isReadOnly);
+            return singletonOf(resourceId)
+                        .urlTemplate(urlTemplate)
+                        .dnTemplate(dnTemplate)
+                        .isReadOnly(isReadOnly);
         }
     }
 
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceCollection.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceCollection.java
index 7713ced..d66681a 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceCollection.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceCollection.java
@@ -74,9 +74,11 @@
     private final Attribute glueObjectClasses = new LinkedAttribute("objectClass");
 
     private NamingStrategy namingStrategy;
+    private boolean flattenSubtree;
 
     SubResourceCollection(final String resourceId) {
         super(resourceId);
+
         useClientDnNaming("uid");
     }
 
@@ -213,12 +215,36 @@
     /**
      * Indicates whether this sub-resource collection only supports read and query operations.
      *
-     * @param readOnly
+     * @param isReadOnly
      *         {@code true} if this sub-resource collection is read-only.
      * @return A reference to this object.
      */
-    public SubResourceCollection isReadOnly(final boolean readOnly) {
-        isReadOnly = readOnly;
+    public SubResourceCollection isReadOnly(final boolean isReadOnly) {
+        this.isReadOnly = isReadOnly;
+        return this;
+    }
+
+    /**
+     * Controls whether or not LDAP entries in the hierarchy below the root entry of the resource
+     * collection are included in the list of resources (essentially, flattening the hierarchy
+     * into one collection of resources).
+     *
+     * This can only be used if the resource is read-only. The default is not to flatten, which
+     * preserves the legacy behavior of Rest2LDAP.
+     *
+     * @param  flattenSubtree
+     *         Whether or not to flatten the hierarchy by searching the entire subtree.
+     * @return A reference to this object.
+     * @throws IllegalArgumentException
+     *         If the configuration is invalid.
+     */
+    public SubResourceCollection flattenSubtree(boolean flattenSubtree) {
+        if (flattenSubtree && !this.isReadOnly) {
+            throw new LocalizedIllegalArgumentException(
+                ERR_CONFIG_MUST_BE_READ_ONLY_TO_FLATTEN_SUBTREE.get());
+        }
+
+        this.flattenSubtree = flattenSubtree;
         return this;
     }
 
@@ -256,11 +282,13 @@
     }
 
     private SubResourceImpl collection(final Context context) {
-        return new SubResourceImpl(rest2Ldap,
-                                   dnFrom(context),
-                                   dnTemplateString.isEmpty() ? null : glueObjectClasses,
-                                   namingStrategy,
-                                   resource);
+        return new SubResourceImpl(
+            rest2Ldap,
+            dnFrom(context),
+            dnTemplateString.isEmpty() ? null : glueObjectClasses,
+            namingStrategy,
+            resource,
+            this.flattenSubtree);
     }
 
     private String idFrom(final Context context) {
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java
index 7cec13f..127ee64 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java
@@ -31,7 +31,6 @@
 import static org.forgerock.opendj.ldap.ByteString.valueOfBytes;
 import static org.forgerock.opendj.ldap.Filter.alwaysFalse;
 import static org.forgerock.opendj.ldap.Filter.alwaysTrue;
-import static org.forgerock.opendj.ldap.SearchScope.SINGLE_LEVEL;
 import static org.forgerock.opendj.ldap.requests.Requests.*;
 import static org.forgerock.opendj.rest2ldap.ReadOnUpdatePolicy.CONTROLS;
 import static org.forgerock.opendj.rest2ldap.RoutingContext.newCollectionRoutingContext;
@@ -140,9 +139,11 @@
     private final boolean usePermissiveModify;
     private final Resource resource;
     private final Attribute glueObjectClasses;
+    private final boolean flattenSubtree;
 
     SubResourceImpl(final Rest2Ldap rest2Ldap, final DN baseDn, final Attribute glueObjectClasses,
-                    final NamingStrategy namingStrategy, final Resource resource) {
+                    final NamingStrategy namingStrategy, final Resource resource,
+                    final boolean flattenSubtree) {
         this.readOnUpdatePolicy = rest2Ldap.getOptions().get(READ_ON_UPDATE_POLICY);
         this.useSubtreeDelete = rest2Ldap.getOptions().get(USE_SUBTREE_DELETE);
         this.usePermissiveModify = rest2Ldap.getOptions().get(USE_PERMISSIVE_MODIFY);
@@ -153,6 +154,7 @@
         this.glueObjectClasses = glueObjectClasses;
         this.namingStrategy = namingStrategy;
         this.resource = resource;
+        this.flattenSubtree = flattenSubtree;
     }
 
     Promise<ActionResponse, ResourceException> action(
@@ -700,7 +702,7 @@
                 final String[] attributes = getLdapAttributesForUnknownType(request.getFields()).toArray(new String[0]);
                 final Filter searchFilter = ldapFilter == Filter.alwaysTrue() ? Filter.objectClassPresent()
                         : ldapFilter;
-                final SearchRequest searchRequest = newSearchRequest(baseDn, SINGLE_LEVEL, searchFilter, attributes);
+                final SearchRequest searchRequest = createSearchRequest(searchFilter, attributes);
 
                 // Add the page results control. We can support the page offset by reading the next offset pages, or
                 // offset x page size resources.
@@ -1064,6 +1066,34 @@
         return namingStrategy.createSearchRequest(baseDn, resourceId).addAttribute(attributes);
     }
 
+    /**
+     * Creates a request to search LDAP for entries that match the provided search filter, and
+     * the specified attributes.
+     *
+     * If the subtree flattening is enabled, the search request will encompass the whole subtree.
+     *
+     * @param   searchFilter
+     *          The filter that entries must match to be returned.
+     * @param   desiredAttributes
+     *          The names of the attributes to be included with each entry.
+     *
+     * @return  The resulting search request.
+     */
+    private SearchRequest createSearchRequest(Filter searchFilter, String[] desiredAttributes) {
+        final SearchScope searchScope;
+        final SearchRequest searchRequest;
+
+        if (SubResourceImpl.this.flattenSubtree) {
+            searchScope = SearchScope.SUBORDINATES;
+        } else {
+            searchScope = SearchScope.SINGLE_LEVEL;
+        }
+
+        searchRequest = newSearchRequest(baseDn, searchScope, searchFilter, desiredAttributes);
+
+        return searchRequest;
+    }
+
     @SuppressWarnings("unused")
     private static <R> AsyncFunction<LdapException, R, ResourceException> adaptLdapException(final Class<R> clazz) {
         return new AsyncFunction<LdapException, R, ResourceException>() {
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceSingleton.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceSingleton.java
index 77976f2..1e8bc86 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceSingleton.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceSingleton.java
@@ -139,7 +139,13 @@
     }
 
     private SubResourceImpl singleton(final Context context) {
-        return new SubResourceImpl(rest2Ldap, dnFrom(context), null, SINGLETON_NAMING_STRATEGY, resource);
+        return new SubResourceImpl(
+            rest2Ldap,
+            dnFrom(context),
+            null,
+            SINGLETON_NAMING_STRATEGY,
+            resource,
+            false);
     }
 
     /**
diff --git a/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties b/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
index 16e5cf7..40631a1 100644
--- a/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
+++ b/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
@@ -149,3 +149,4 @@
 ERR_PATCH_JSON_INTERNAL_PROPERTY_90=The patch request cannot be processed because it attempts to modify the \
   internal field '%s' of object '%s'. This capability is not currently supported by Rest2Ldap. Applications should \
   instead perform a patch which replaces the entire object '%s'
+ERR_CONFIG_MUST_BE_READ_ONLY_TO_FLATTEN_SUBTREE_91=Sub-resources must be read-only to support sub-tree flattening.
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java
index 7b9c3ac..208023f 100644
--- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java
@@ -93,7 +93,7 @@
     private static final QueryFilter<JsonPointer> NO_FILTER = QueryFilter.alwaysTrue();
 
     @Test
-    public void testQueryAllWithNoSubtree() throws Exception {
+    public void testQueryAllWithNoSubtreeFlattening() throws Exception {
         final Connection connection = newConnection();
         final List<ResourceResponse> resources = new LinkedList<>();
         final QueryResponse result =
@@ -105,25 +105,76 @@
         assertThat(resources).hasSize(7);
         assertThat(result.getPagedResultsCookie()).isNull();
         assertThat(result.getTotalPagedResults()).isEqualTo(-1);
+
+        assertThat(resources.get(0).getContent().get("_ou").isNotNull());
+        assertThat(resources.get(0).getContent().get("_ou").asString()).isEqualTo("level1");
+
+        assertThat(resources.get(1).getContent().get("_ou").isNull());
+        assertThat(resources.get(1).getId()).isEqualTo("test1");
+
+        assertThat(resources.get(2).getContent().get("_ou").isNull());
+        assertThat(resources.get(2).getId()).isEqualTo("test2");
+
+        assertThat(resources.get(3).getContent().get("_ou").isNull());
+        assertThat(resources.get(3).getId()).isEqualTo("test3");
+
+        assertThat(resources.get(4).getContent().get("_ou").isNull());
+        assertThat(resources.get(4).getId()).isEqualTo("test4");
+
+        assertThat(resources.get(5).getContent().get("_ou").isNull());
+        assertThat(resources.get(5).getId()).isEqualTo("test5");
+
+        assertThat(resources.get(6).getContent().get("_ou").isNull());
+        assertThat(resources.get(6).getId()).isEqualTo("test6");
     }
 
-//    @Test
-//    public void testQueryAllWithSubtree() throws Exception {
-//        final Connection connection = newConnection();
-//        final List<ResourceResponse> resources = new LinkedList<>();
-//        final QueryResponse result =
-//            connection.query(
-//                newAuthConnectionContext(),
-//                newQueryRequest("").setQueryFilter(NO_FILTER),
-//                resources);
-//
-//        assertThat(resources).hasSize(7);
-//        assertThat(result.getPagedResultsCookie()).isNull();
-//        assertThat(result.getTotalPagedResults()).isEqualTo(-1);
-//    }
+    @Test
+    public void testQueryAllWithSubtreeFlattening() throws Exception {
+        final Connection connection = newConnection();
+        final List<ResourceResponse> resources = new LinkedList<>();
+        final QueryResponse result =
+            connection.query(
+                newAuthConnectionContext(),
+                newQueryRequest("all-users").setQueryFilter(NO_FILTER),
+                resources);
+
+        assertThat(resources).hasSize(10);
+        assertThat(result.getPagedResultsCookie()).isNull();
+        assertThat(result.getTotalPagedResults()).isEqualTo(-1);
+
+        assertThat(resources.get(0).getContent().get("_ou").isNotNull());
+        assertThat(resources.get(0).getContent().get("_ou").asString()).isEqualTo("level1");
+
+        assertThat(resources.get(1).getContent().get("_ou").isNotNull());
+        assertThat(resources.get(1).getContent().get("_ou").asString()).isEqualTo("level2");
+
+        assertThat(resources.get(2).getContent().get("_ou").isNull());
+        assertThat(resources.get(2).getId()).isEqualTo("sub2");
+
+        assertThat(resources.get(3).getContent().get("_ou").isNull());
+        assertThat(resources.get(3).getId()).isEqualTo("sub1");
+
+        assertThat(resources.get(4).getContent().get("_ou").isNull());
+        assertThat(resources.get(4).getId()).isEqualTo("test1");
+
+        assertThat(resources.get(5).getContent().get("_ou").isNull());
+        assertThat(resources.get(5).getId()).isEqualTo("test2");
+
+        assertThat(resources.get(6).getContent().get("_ou").isNull());
+        assertThat(resources.get(6).getId()).isEqualTo("test3");
+
+        assertThat(resources.get(7).getContent().get("_ou").isNull());
+        assertThat(resources.get(7).getId()).isEqualTo("test4");
+
+        assertThat(resources.get(8).getContent().get("_ou").isNull());
+        assertThat(resources.get(8).getId()).isEqualTo("test5");
+
+        assertThat(resources.get(9).getContent().get("_ou").isNull());
+        assertThat(resources.get(9).getId()).isEqualTo("test6");
+    }
 
     @Test
-    public void testQueryNone() throws Exception {
+    public void testQueryNoneWithNoSubtreeFlattening() throws Exception {
         final Connection connection = newConnection();
         final List<ResourceResponse> resources = new LinkedList<>();
         final QueryResponse result = connection.query(newAuthConnectionContext(),
@@ -135,6 +186,18 @@
     }
 
     @Test
+    public void testQueryNoneWithSubtreeFlattening() throws Exception {
+        final Connection connection = newConnection();
+        final List<ResourceResponse> resources = new LinkedList<>();
+        final QueryResponse result = connection.query(newAuthConnectionContext(),
+          newQueryRequest("all-users").setQueryFilter(QueryFilter.<JsonPointer> alwaysFalse()), resources);
+
+        assertThat(resources).hasSize(0);
+        assertThat(result.getPagedResultsCookie()).isNull();
+        assertThat(result.getTotalPagedResults()).isEqualTo(-1);
+    }
+
+    @Test
     public void testQueryPageResultsCookie() throws Exception {
         final Connection connection = newConnection();
         final List<ResourceResponse> resources = new ArrayList<>();
@@ -799,35 +862,46 @@
     private Rest2Ldap usersApi() throws IOException {
         return rest2Ldap(
             defaultOptions(),
-            resource("api").subResource(
-                collectionOf("user").dnTemplate("dc=test").useClientDnNaming("uid")),
-                resource("user").objectClasses("top", "person")
-                    .property(
-                        "schemas",
-                        constant(asList("urn:scim:schemas:core:1.0")))
-                    .property(
-                        "_id",
-                        simple("uid").isRequired(true).writability(CREATE_ONLY))
-                    .property(
-                        "_ou",
-                        simple("ou").isRequired(false).writability(CREATE_ONLY))
-                    .property(
-                        "name",
-                        object()
-                          .property("displayName", simple("cn").isRequired(true))
-                          .property("surname",     simple("sn").isRequired(true)))
-                    .property(
-                        "_rev",
-                        simple("etag").isRequired(true).writability(READ_ONLY))
-                    .property(
-                        "description",
-                        simple("description").isMultiValued(true))
-                    .property(
-                        "singleNumber",
-                        simple("singleNumber").decoder(byteStringToInteger()))
-                    .property(
-                        "multiNumber",
-                        simple("multiNumber").isMultiValued(true).decoder(byteStringToInteger()))
+            resource("api")
+                .subResource(
+                    collectionOf("user")
+                        .dnTemplate("dc=test")
+                        .useClientDnNaming("uid"))
+                .subResource(
+                    collectionOf("user")
+                        .urlTemplate("all-users")
+                        .dnTemplate("dc=test")
+                        .useClientDnNaming("uid")
+                        .isReadOnly(true)
+                        .flattenSubtree(true)),
+            resource("user")
+                .objectClasses("top", "person")
+                .property(
+                    "schemas",
+                    constant(asList("urn:scim:schemas:core:1.0")))
+                .property(
+                    "_id",
+                    simple("uid").isRequired(true).writability(CREATE_ONLY))
+                .property(
+                    "_ou",
+                    simple("ou").isRequired(false).writability(CREATE_ONLY))
+                .property(
+                    "name",
+                    object()
+                      .property("displayName", simple("cn").isRequired(true))
+                      .property("surname",     simple("sn").isRequired(true)))
+                .property(
+                    "_rev",
+                    simple("etag").isRequired(true).writability(READ_ONLY))
+                .property(
+                    "description",
+                    simple("description").isMultiValued(true))
+                .property(
+                    "singleNumber",
+                    simple("singleNumber").decoder(byteStringToInteger()))
+                .property(
+                    "multiNumber",
+                    simple("multiNumber").isMultiValued(true).decoder(byteStringToInteger()))
         );
     }
 

--
Gitblit v1.10.0