From a08c81f677247ec9eb7721a86250c663065e9930 Mon Sep 17 00:00:00 2001
From: Matthew Swift <matthew.swift@forgerock.com>
Date: Wed, 22 Jun 2016 22:12:03 +0000
Subject: [PATCH] OPENDJ-2871 Add support for sub-resources and inheritance

---
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectPropertyMapper.java |  264 ++++++++++++++++++++++++++++++++++++----------------
 1 files changed, 180 insertions(+), 84 deletions(-)

diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectPropertyMapper.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectPropertyMapper.java
index 47aab51..4aa4ab1 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectPropertyMapper.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectPropertyMapper.java
@@ -15,20 +15,25 @@
  */
 package org.forgerock.opendj.rest2ldap;
 
+import static org.forgerock.opendj.rest2ldap.Rest2Ldap.simple;
 import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
 import static org.forgerock.json.resource.PatchOperation.operation;
 import static org.forgerock.opendj.ldap.Filter.alwaysFalse;
 import static org.forgerock.opendj.rest2ldap.Rest2Ldap.asResourceException;
 import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException;
 import static org.forgerock.opendj.rest2ldap.Utils.toLowerCase;
+import static org.forgerock.util.Utils.joinAsString;
+import static org.forgerock.util.promise.Promises.newResultPromise;
 
-import java.util.AbstractMap.SimpleImmutableEntry;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.TreeSet;
 
 import org.forgerock.json.JsonPointer;
 import org.forgerock.json.JsonValue;
@@ -40,12 +45,12 @@
 import org.forgerock.opendj.ldap.Filter;
 import org.forgerock.opendj.ldap.Modification;
 import org.forgerock.util.Function;
+import org.forgerock.util.Pair;
 import org.forgerock.util.promise.Promise;
 import org.forgerock.util.promise.Promises;
 
 /** An property mapper which maps JSON objects to LDAP attributes. */
 public final class ObjectPropertyMapper extends PropertyMapper {
-
     private static final class Mapping {
         private final PropertyMapper mapper;
         private final String name;
@@ -63,39 +68,80 @@
 
     private final Map<String, Mapping> mappings = new LinkedHashMap<>();
 
+    private boolean includeAllUserAttributesByDefault = false;
+    private final Set<String> excludedDefaultUserAttributes = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+
     ObjectPropertyMapper() {
         // Nothing to do.
     }
 
     /**
-     * Creates a mapping for an attribute contained in the JSON object.
+     * Creates an explicit mapping for a property contained in the JSON object. When user attributes are
+     * {@link #includeAllUserAttributesByDefault included} by default, be careful to {@link
+     * #excludedDefaultUserAttributes exclude} any attributes which have explicit mappings defined using this method,
+     * otherwise they will be duplicated in the JSON representation.
      *
      * @param name
-     *            The name of the JSON attribute to be mapped.
+     *            The name of the JSON property to be mapped.
      * @param mapper
-     *            The property mapper responsible for mapping the JSON
-     *            attribute to LDAP attribute(s).
+     *            The property mapper responsible for mapping the JSON attribute to LDAP attribute(s).
      * @return A reference to this property mapper.
      */
-    public ObjectPropertyMapper attribute(final String name, final PropertyMapper mapper) {
+    public ObjectPropertyMapper property(final String name, final PropertyMapper mapper) {
         mappings.put(toLowerCase(name), new Mapping(name, mapper));
         return this;
     }
 
+    /**
+     * Specifies whether all LDAP user attributes should be mapped by default using the default schema based mapping
+     * rules. Individual attributes can be excluded using {@link #excludedDefaultUserAttributes} in order to prevent
+     * attributes with explicit mappings being mapped twice.
+     *
+     * @param include {@code true} if all LDAP user attributes be mapped by default.
+     * @return A reference to this property mapper.
+     */
+    public ObjectPropertyMapper includeAllUserAttributesByDefault(final boolean include) {
+        this.includeAllUserAttributesByDefault = include;
+        return this;
+    }
+
+    /**
+     * Specifies zero or more user attributes which will be excluded from the default user attribute mappings when
+     * enabled using {@link #includeAllUserAttributesByDefault}. Attributes which have explicit mappings should be
+     * excluded in order to prevent duplication.
+     *
+     * @param attributeNames The list of attributes to be excluded.
+     * @return A reference to this property mapper.
+     */
+    public ObjectPropertyMapper excludedDefaultUserAttributes(final String... attributeNames) {
+        return excludedDefaultUserAttributes(Arrays.asList(attributeNames));
+    }
+
+    /**
+     * Specifies zero or more user attributes which will be excluded from the default user attribute mappings when
+     * enabled using {@link #includeAllUserAttributesByDefault}. Attributes which have explicit mappings should be
+     * excluded in order to prevent duplication.
+     *
+     * @param attributeNames The list of attributes to be excluded.
+     * @return A reference to this property mapper.
+     */
+    public ObjectPropertyMapper excludedDefaultUserAttributes(final Collection<String> attributeNames) {
+        excludedDefaultUserAttributes.addAll(attributeNames);
+        return this;
+    }
+
     @Override
     public String toString() {
-        return "object(" + mappings.values() + ")";
+        return "object(" + joinAsString(", ", mappings.values()) + ")";
     }
 
     @Override
-    Promise<List<Attribute>, ResourceException> create(final Connection connection, final JsonPointer path,
-            final JsonValue v) {
+    Promise<List<Attribute>, ResourceException> create(final Connection connection,
+                                                       final Resource resource, final JsonPointer path,
+                                                       final JsonValue v) {
         try {
-            /*
-             * First check that the JSON value is an object and that the fields
-             * it contains are known by this mapper.
-             */
-            final Map<String, Mapping> missingMappings = checkMapping(path, v);
+            // First check that the JSON value is an object and that the fields it contains are known by this mapper.
+            final Map<String, Mapping> missingMappings = validateJsonValue(path, v);
 
             // Accumulate the results of the subordinate mappings.
             final List<Promise<List<Attribute>, ResourceException>> promises = new ArrayList<>();
@@ -105,61 +151,70 @@
                 for (final Map.Entry<String, Object> me : v.asMap().entrySet()) {
                     final Mapping mapping = getMapping(me.getKey());
                     final JsonValue subValue = new JsonValue(me.getValue());
-                    promises.add(mapping.mapper.create(connection, path.child(me.getKey()), subValue));
+                    promises.add(mapping.mapper.create(connection, resource, path.child(me.getKey()),
+                                                       subValue));
                 }
             }
 
             // Invoke mappings for which there were no values provided.
             for (final Mapping mapping : missingMappings.values()) {
-                promises.add(mapping.mapper.create(connection, path.child(mapping.name), null));
+                promises.add(mapping.mapper.create(connection, resource, path.child(mapping.name), null));
             }
 
             return Promises.when(promises)
                            .then(this.<Attribute> accumulateResults());
         } catch (final Exception e) {
-            return Promises.newExceptionPromise(asResourceException(e));
+            return asResourceException(e).asPromise();
         }
     }
 
     @Override
-    void getLdapAttributes(final Connection connection, final JsonPointer path, final JsonPointer subPath,
-                           final Set<String> ldapAttributes) {
+    void getLdapAttributes(final JsonPointer path, final JsonPointer subPath, final Set<String> ldapAttributes) {
         if (subPath.isEmpty()) {
             // Request all subordinate mappings.
+            if (includeAllUserAttributesByDefault) {
+                ldapAttributes.add("*");
+                // Continue because there may be explicit mappings for operational attributes.
+            }
             for (final Mapping mapping : mappings.values()) {
-                mapping.mapper.getLdapAttributes(connection, path.child(mapping.name), subPath, ldapAttributes);
+                mapping.mapper.getLdapAttributes(path.child(mapping.name), subPath, ldapAttributes);
             }
         } else {
             // Request single subordinate mapping.
-            final Mapping mapping = getMapping(subPath);
+            final Mapping mapping = getMappingOrNull(subPath);
             if (mapping != null) {
-                mapping.mapper.getLdapAttributes(
-                        connection, path.child(subPath.get(0)), subPath.relativePointer(), ldapAttributes);
+                mapping.mapper.getLdapAttributes(path.child(subPath.get(0)), subPath.relativePointer(), ldapAttributes);
             }
         }
     }
 
     @Override
-    Promise<Filter, ResourceException> getLdapFilter(final Connection connection, final JsonPointer path,
-                                                     final JsonPointer subPath, final FilterType type,
-                                                     final String operator, final Object valueAssertion) {
-        final Mapping mapping = getMapping(subPath);
+    Promise<Filter, ResourceException> getLdapFilter(final Connection connection, final Resource resource,
+                                                     final JsonPointer path, final JsonPointer subPath,
+                                                     final FilterType type, final String operator,
+                                                     final Object valueAssertion) {
+        final Mapping mapping = getMappingOrNull(subPath);
         if (mapping != null) {
-            return mapping.mapper.getLdapFilter(connection, path.child(subPath.get(0)),
-                                                subPath.relativePointer(), type, operator, valueAssertion);
+            return mapping.mapper.getLdapFilter(connection,
+                                                resource,
+                                                path.child(subPath.get(0)),
+                                                subPath.relativePointer(),
+                                                type,
+                                                operator,
+                                                valueAssertion);
         } else {
             /*
              * Either the filter targeted the entire object (i.e. it was "/"),
              * or it targeted an unrecognized attribute within the object.
              * Either way, the filter will never match.
              */
-            return Promises.newResultPromise(alwaysFalse());
+            return newResultPromise(alwaysFalse());
         }
     }
 
     @Override
-    Promise<List<Modification>, ResourceException> patch(final Connection connection, final JsonPointer path,
-            final PatchOperation operation) {
+    Promise<List<Modification>, ResourceException> patch(final Connection connection, final Resource resource,
+                                                         final JsonPointer path, final PatchOperation operation) {
         try {
             final JsonPointer field = operation.getField();
             final JsonValue v = operation.getValue();
@@ -170,7 +225,7 @@
                  * by allowing the JSON value to be a partial object and
                  * add/remove/replace only the provided values.
                  */
-                checkMapping(path, v);
+                validateJsonValue(path, v);
 
                 // Accumulate the results of the subordinate mappings.
                 final List<Promise<List<Modification>, ResourceException>> promises = new ArrayList<>();
@@ -182,7 +237,7 @@
                         final JsonValue subValue = new JsonValue(me.getValue());
                         final PatchOperation subOperation =
                                 operation(operation.getOperation(), field /* empty */, subValue);
-                        promises.add(mapping.mapper.patch(connection, path.child(me.getKey()), subOperation));
+                        promises.add(mapping.mapper.patch(connection, resource, path.child(me.getKey()), subOperation));
                     }
                 }
 
@@ -195,71 +250,93 @@
                  * appropriate mapper.
                  */
                 final String fieldName = field.get(0);
-                final Mapping mapping = getMapping(fieldName);
+                final Mapping mapping = getMappingOrNull(fieldName);
                 if (mapping == null) {
                     throw newBadRequestException(ERR_UNRECOGNIZED_FIELD.get(path.child(fieldName)));
                 }
                 final PatchOperation subOperation =
                         operation(operation.getOperation(), field.relativePointer(), v);
-                return mapping.mapper.patch(connection, path.child(fieldName), subOperation);
+                return mapping.mapper.patch(connection, resource, path.child(fieldName), subOperation);
             }
-        } catch (final Exception ex) {
-            return Promises.newExceptionPromise(asResourceException(ex));
+        } catch (final Exception e) {
+            return asResourceException(e).asPromise();
         }
     }
 
     @Override
-    Promise<JsonValue, ResourceException> read(final Connection connection, final JsonPointer path, final Entry e) {
+    Promise<JsonValue, ResourceException> read(final Connection connection, final Resource resource,
+                                               final JsonPointer path, final Entry e) {
         /*
          * Use an accumulator which will aggregate the results from the
          * subordinate mappers into a single list. On completion, the
          * accumulator combines the results into a single JSON map object.
          */
-        final List<Promise<Map.Entry<String, JsonValue>, ResourceException>> promises =
+        final List<Promise<Pair<String, JsonValue>, ResourceException>> promises =
                 new ArrayList<>(mappings.size());
 
         for (final Mapping mapping : mappings.values()) {
-            promises.add(mapping.mapper.read(connection, path.child(mapping.name), e)
-                    .then(new Function<JsonValue, Map.Entry<String, JsonValue>, ResourceException>() {
-                        @Override
-                        public Map.Entry<String, JsonValue> apply(final JsonValue value) {
-                            return value != null ? new SimpleImmutableEntry<String, JsonValue>(mapping.name, value)
-                                                 : null;
-                        }
-                    }));
+            promises.add(mapping.mapper.read(connection, resource, path.child(mapping.name), e)
+                                       .then(toProperty(mapping.name)));
+        }
+
+        if (includeAllUserAttributesByDefault) {
+            // Map all user attributes using a default simple mapping. It would be nice if we could automatically
+            // detect which attributes have been mapped already using explicit mappings, but it would require us to
+            // track which attributes have been accessed in the entry. Instead, we'll rely on the user to exclude
+            // attributes which have explicit mappings.
+            for (final Attribute attribute : e.getAllAttributes()) {
+                // Don't include operational attributes. They must have explicit mappings.
+                if (attribute.getAttributeDescription().getAttributeType().isOperational()) {
+                    continue;
+                }
+                // Filter out excluded attributes.
+                final String attributeName = attribute.getAttributeDescriptionAsString();
+                if (!excludedDefaultUserAttributes.isEmpty() && excludedDefaultUserAttributes.contains(attributeName)) {
+                    continue;
+                }
+                // This attribute needs to be mapped.
+                final SimplePropertyMapper mapper = simple(attribute.getAttributeDescription());
+                promises.add(mapper.read(connection, resource, path.child(attributeName), e)
+                                   .then(toProperty(attributeName)));
+            }
         }
 
         return Promises.when(promises)
-                .then(new Function<List<Map.Entry<String, JsonValue>>, JsonValue, ResourceException>() {
-                    @Override
-                    public JsonValue apply(final List<Map.Entry<String, JsonValue>> value) {
-                        if (value.isEmpty()) {
-                            /*
-                             * No subordinate attributes, so omit the entire
-                             * JSON object from the resource.
-                             */
-                            return null;
-                        } else {
-                            // Combine the sub-attributes into a single JSON object.
-                            final Map<String, Object> result = new LinkedHashMap<>(value.size());
-                            for (final Map.Entry<String, JsonValue> e : value) {
-                                if (e != null) {
-                                    result.put(e.getKey(), e.getValue().getObject());
-                                }
-                            }
-                            return new JsonValue(result);
-                        }
-                    }
-                });
+                       .then(new Function<List<Pair<String, JsonValue>>, JsonValue, ResourceException>() {
+                           @Override
+                           public JsonValue apply(final List<Pair<String, JsonValue>> value) {
+                               if (value.isEmpty()) {
+                                   // No subordinate attributes, so omit the entire JSON object from the resource.
+                                   return null;
+                               } else {
+                                   // Combine the sub-attributes into a single JSON object.
+                                   final Map<String, Object> result = new LinkedHashMap<>(value.size());
+                                   for (final Pair<String, JsonValue> e : value) {
+                                       if (e != null) {
+                                           result.put(e.getFirst(), e.getSecond().getObject());
+                                       }
+                                   }
+                                   return new JsonValue(result);
+                               }
+                           }
+                       });
+    }
+
+    private Function<JsonValue, Pair<String, JsonValue>, ResourceException> toProperty(final String name) {
+        return new Function<JsonValue, Pair<String, JsonValue>, ResourceException>() {
+            @Override
+            public Pair<String, JsonValue> apply(final JsonValue value) {
+                return value != null ? Pair.of(name, value) : null;
+            }
+        };
     }
 
     @Override
-    Promise<List<Modification>, ResourceException> update(
-            final Connection connection, final JsonPointer path, final Entry e, final JsonValue v) {
+    Promise<List<Modification>, ResourceException> update(final Connection connection, final Resource resource,
+                                                          final JsonPointer path, final Entry e, final JsonValue v) {
         try {
-            // First check that the JSON value is an object and that the fields
-            // it contains are known by this mapper.
-            final Map<String, Mapping> missingMappings = checkMapping(path, v);
+            // First check that the JSON value is an object and that the fields it contains are known by this mapper.
+            final Map<String, Mapping> missingMappings = validateJsonValue(path, v);
 
             // Accumulate the results of the subordinate mappings.
             final List<Promise<List<Modification>, ResourceException>> promises = new ArrayList<>();
@@ -269,19 +346,19 @@
                 for (final Map.Entry<String, Object> me : v.asMap().entrySet()) {
                     final Mapping mapping = getMapping(me.getKey());
                     final JsonValue subValue = new JsonValue(me.getValue());
-                    promises.add(mapping.mapper.update(connection, path.child(me.getKey()), e, subValue));
+                    promises.add(mapping.mapper.update(connection, resource, path.child(me.getKey()), e, subValue));
                 }
             }
 
             // Invoke mappings for which there were no values provided.
             for (final Mapping mapping : missingMappings.values()) {
-                promises.add(mapping.mapper.update(connection, path.child(mapping.name), e, null));
+                promises.add(mapping.mapper.update(connection, resource, path.child(mapping.name), e, null));
             }
 
             return Promises.when(promises)
                            .then(this.<Modification> accumulateResults());
         } catch (final Exception ex) {
-            return Promises.newExceptionPromise(asResourceException(ex));
+            return asResourceException(ex).asPromise();
         }
     }
 
@@ -306,13 +383,13 @@
     }
 
     /** Fail immediately if the JSON value has the wrong type or contains unknown attributes. */
-    private Map<String, Mapping> checkMapping(final JsonPointer path, final JsonValue v)
-            throws ResourceException {
+    private Map<String, Mapping> validateJsonValue(final JsonPointer path, final JsonValue v) throws ResourceException {
         final Map<String, Mapping> missingMappings = new LinkedHashMap<>(mappings);
         if (v != null && !v.isNull()) {
             if (v.isMap()) {
                 for (final String attribute : v.asMap().keySet()) {
-                    if (missingMappings.remove(toLowerCase(attribute)) == null) {
+                    if (missingMappings.remove(toLowerCase(attribute)) == null
+                            && !isIncludedDefaultUserAttribute(attribute)) {
                         throw newBadRequestException(ERR_UNRECOGNIZED_FIELD.get(path.child(attribute)));
                     }
                 }
@@ -323,12 +400,31 @@
         return missingMappings;
     }
 
-    private Mapping getMapping(final JsonPointer jsonAttribute) {
-        return jsonAttribute.isEmpty() ? null : getMapping(jsonAttribute.get(0));
+    private Mapping getMappingOrNull(final JsonPointer jsonAttribute) {
+        return jsonAttribute.isEmpty() ? null : getMappingOrNull(jsonAttribute.get(0));
+    }
+
+    private Mapping getMappingOrNull(final String jsonAttribute) {
+        final Mapping mapping = mappings.get(toLowerCase(jsonAttribute));
+        if (mapping != null) {
+            return mapping;
+        }
+        if (isIncludedDefaultUserAttribute(jsonAttribute)) {
+            return new Mapping(jsonAttribute, simple(jsonAttribute));
+        }
+        return null;
     }
 
     private Mapping getMapping(final String jsonAttribute) {
-        return mappings.get(toLowerCase(jsonAttribute));
+        final Mapping mappingOrNull = getMappingOrNull(jsonAttribute);
+        if (mappingOrNull != null) {
+            return mappingOrNull;
+        }
+        throw new IllegalStateException("Unexpected null mapping for jsonAttribute: " + jsonAttribute);
     }
 
+    private boolean isIncludedDefaultUserAttribute(final String attributeName) {
+        return includeAllUserAttributesByDefault
+                && (excludedDefaultUserAttributes.isEmpty() || !excludedDefaultUserAttributes.contains(attributeName));
+    }
 }

--
Gitblit v1.10.0