From 4ca1423e387874accc55c1d0ffcada3eddb833c5 Mon Sep 17 00:00:00 2001
From: Matthew Swift <matthew.swift@forgerock.com>
Date: Wed, 01 May 2013 00:12:40 +0000
Subject: [PATCH] Partial fix for CREST-3: Add patch support

---
 opendj3/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java              |  123 +++++
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SimpleAttributeMapper.java          |    3 
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectAttributeMapper.java          |  203 ++++++--
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AttributeMapper.java                |   90 +++
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JSONConstantAttributeMapper.java    |   41 +
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/LDAPCollectionResourceProvider.java |  210 ++++++---
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractLDAPAttributeMapper.java    |  431 ++++++++++++++-----
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferenceAttributeMapper.java       |  178 +++----
 8 files changed, 920 insertions(+), 359 deletions(-)

diff --git a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractLDAPAttributeMapper.java b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractLDAPAttributeMapper.java
index cd5bb12..7c1186e 100644
--- a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractLDAPAttributeMapper.java
+++ b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractLDAPAttributeMapper.java
@@ -18,8 +18,10 @@
 import static java.util.Collections.emptyList;
 import static java.util.Collections.singletonList;
 import static org.forgerock.opendj.ldap.Attributes.emptyAttribute;
+import static org.forgerock.opendj.rest2ldap.Rest2LDAP.asResourceException;
 import static org.forgerock.opendj.rest2ldap.Utils.i18n;
 import static org.forgerock.opendj.rest2ldap.Utils.isNullOrEmpty;
+import static org.forgerock.opendj.rest2ldap.Utils.transform;
 import static org.forgerock.opendj.rest2ldap.WritabilityPolicy.READ_WRITE;
 
 import java.util.ArrayList;
@@ -30,12 +32,14 @@
 import org.forgerock.json.fluent.JsonPointer;
 import org.forgerock.json.fluent.JsonValue;
 import org.forgerock.json.resource.BadRequestException;
+import org.forgerock.json.resource.NotSupportedException;
+import org.forgerock.json.resource.PatchOperation;
 import org.forgerock.json.resource.ResourceException;
 import org.forgerock.json.resource.ResultHandler;
 import org.forgerock.opendj.ldap.Attribute;
 import org.forgerock.opendj.ldap.AttributeDescription;
-import org.forgerock.opendj.ldap.Attributes;
 import org.forgerock.opendj.ldap.Entry;
+import org.forgerock.opendj.ldap.Function;
 import org.forgerock.opendj.ldap.LinkedAttribute;
 import org.forgerock.opendj.ldap.Modification;
 import org.forgerock.opendj.ldap.ModificationType;
@@ -96,6 +100,12 @@
     }
 
     @Override
+    void create(final Context c, final JsonPointer path, final JsonValue v,
+            final ResultHandler<List<Attribute>> h) {
+        getNewLDAPAttributes(c, path, v, createAttributeHandler(path, h));
+    }
+
+    @Override
     void getLDAPAttributes(final Context c, final JsonPointer path, final JsonPointer subPath,
             final Set<String> ldapAttributes) {
         ldapAttributes.add(ldapAttributeName.toString());
@@ -107,130 +117,323 @@
     abstract T getThis();
 
     @Override
-    void toLDAP(final Context c, final JsonPointer path, final Entry e, final JsonValue v,
+    void patch(final Context c, final JsonPointer path, final PatchOperation operation,
             final ResultHandler<List<Modification>> h) {
-        // Make following code readable.
-        final boolean isUpdateRequest = e != null;
-        final boolean isCreateRequest = !isUpdateRequest;
+        try {
+            final JsonPointer field = operation.getField();
+            final JsonValue v = operation.getValue();
 
-        // Get the existing LDAP attribute.
-        final Attribute oldLDAPAttribute;
-        if (isCreateRequest) {
-            oldLDAPAttribute = emptyAttribute(ldapAttributeName);
-        } else {
-            final Attribute tmp = e.getAttribute(ldapAttributeName);
-            oldLDAPAttribute = tmp != null ? tmp : emptyAttribute(ldapAttributeName);
-        }
-
-        if (v != null && v.isList() && attributeIsSingleValued()) {
-            // Single-valued field violation.
-            h.handleError(new BadRequestException(i18n(
-                    "The request cannot be processed because an array of values was "
-                            + "provided for the single valued field '%s'", path)));
-        } else {
-            final ResultHandler<Attribute> attributeHandler = new ResultHandler<Attribute>() {
-                @Override
-                public void handleError(final ResourceException error) {
-                    h.handleError(error);
-                }
-
-                @Override
-                public void handleResult(final Attribute newLDAPAttribute) {
-                    /*
-                     * If the attribute is read-only then handle the following
-                     * cases:
-                     *
-                     * 1) new values are provided and they are the same as the
-                     * existing values
-                     *
-                     * 2) no new values are provided.
-                     */
-                    if (isCreateRequest && !writabilityPolicy.canCreate(ldapAttributeName)
-                            || isUpdateRequest && !writabilityPolicy.canWrite(ldapAttributeName)) {
-                        if (newLDAPAttribute.isEmpty()
-                                || (isUpdateRequest && newLDAPAttribute.equals(oldLDAPAttribute))
-                                || writabilityPolicy.discardWrites()) {
-                            // No change.
-                            h.handleResult(Collections.<Modification> emptyList());
-                        } else {
-                            h.handleError(new BadRequestException(i18n(
-                                    "The request cannot be processed because it attempts to modify "
-                                            + "the read-only field '%s'", path)));
-                        }
-                    } else {
-                        // Compute the changes to the attribute.
-                        final List<Modification> modifications;
-                        if (oldLDAPAttribute.isEmpty() && newLDAPAttribute.isEmpty()) {
-                            // No change.
-                            modifications = Collections.<Modification> emptyList();
-                        } else if (oldLDAPAttribute.isEmpty()) {
-                            // The attribute is being added.
-                            modifications =
-                                    singletonList(new Modification(ModificationType.REPLACE,
-                                            newLDAPAttribute));
-                        } else if (newLDAPAttribute.isEmpty()) {
-                            /*
-                             * The attribute is being deleted - this is not
-                             * allowed if the attribute is required.
-                             */
-                            if (isRequired) {
-                                h.handleError(new BadRequestException(i18n(
-                                        "The request cannot be processed because it attempts to remove "
-                                                + "the required field '%s'", path)));
-                                return;
-                            } else {
-                                modifications =
-                                        singletonList(new Modification(ModificationType.REPLACE,
-                                                newLDAPAttribute));
-                            }
-                        } else {
-                            /*
-                             * We could do a replace, but try to save bandwidth
-                             * and send diffs instead. Perform deletes first in
-                             * case we don't have an appropriate normalizer:
-                             * permissive add(x) followed by delete(x) is
-                             * destructive, whereas delete(x) followed by add(x)
-                             * is idempotent when adding/removing the same
-                             * value.
-                             */
-                            modifications = new ArrayList<Modification>(2);
-
-                            final Attribute deletedValues = new LinkedAttribute(oldLDAPAttribute);
-                            deletedValues.removeAll(newLDAPAttribute);
-                            if (!deletedValues.isEmpty()) {
-                                modifications.add(new Modification(ModificationType.DELETE,
-                                        deletedValues));
-                            }
-
-                            final Attribute addedValues = new LinkedAttribute(newLDAPAttribute);
-                            addedValues.removeAll(oldLDAPAttribute);
-                            if (!addedValues.isEmpty()) {
-                                modifications.add(new Modification(ModificationType.ADD,
-                                        addedValues));
-                            }
-                        }
-                        h.handleResult(modifications);
-                    }
-                }
-            };
-
-            final List<Object> newValues = asList(v);
-            if (newValues.isEmpty()) {
-                // Skip sub-class implementation if there are no values.
-                attributeHandler.handleResult(Attributes.emptyAttribute(ldapAttributeName));
-            } else {
-                getNewLDAPAttributes(c, path, asList(v), attributeHandler);
+            /*
+             * Reject any attempts to patch this field if it is read-only, even
+             * if it is configured to discard writes.
+             */
+            if (!writabilityPolicy.canWrite(ldapAttributeName)) {
+                throw new BadRequestException(i18n(
+                        "The request cannot be processed because it attempts to modify "
+                                + "the read-only field '%s'", path));
             }
+
+            switch (field.size()) {
+            case 0:
+                /*
+                 * The patch operation targets the entire mapping. If this
+                 * mapping is multi-valued, then the patch value must be a list
+                 * of values to be added, removed, or replaced. If it is
+                 * single-valued then the patch value must not be a list.
+                 */
+                if (attributeIsSingleValued()) {
+                    if (v != null && v.isList()) {
+                        // Single-valued field violation.
+                        throw new BadRequestException(i18n(
+                                "The request cannot be processed because an array of values was "
+                                        + "provided for the single valued field '%s'", path));
+                    }
+                } else if (v != null && !v.isList() && !operation.isIncrement()
+                        && !(v.isNull() && (operation.isReplace() || operation.isRemove()))) {
+                    // Multi-valued field violation.
+                    throw new BadRequestException(i18n(
+                            "The request cannot be processed because an array of values was "
+                                    + "not provided for the multi-valued field '%s'", path));
+                }
+                break;
+            case 1:
+                /*
+                 * The patch operation targets a sub-field. If the sub-field
+                 * name is a number then it is an attempt to patch a single
+                 * value at a specific index. Rest2LDAP cannot support indexed
+                 * updates because LDAP attribute values are unordered. We will,
+                 * however, support the special index "-" indicating that a
+                 * value should be appended.
+                 */
+                final String fieldName = field.get(0);
+                if (fieldName.equals("-") && operation.isAdd()) {
+                    // Append a single value.
+                    if (attributeIsSingleValued()) {
+                        throw new BadRequestException(i18n(
+                                "The request cannot be processed because it attempts to append a "
+                                        + "value to the single valued field '%s'", path));
+                    } else if (v.isList()) {
+                        throw new BadRequestException(i18n(
+                                "The request cannot be processed because it attempts to "
+                                        + "perform an indexed append of an array of values to "
+                                        + "the multi-valued field '%s'", path.child(fieldName)));
+                    }
+                } else if (fieldName.matches("[0-9]+")) {
+                    // Array index - not allowed.
+                    throw new NotSupportedException(i18n(
+                            "The request cannot be processed because it included "
+                                    + "an indexed patch operation '%s' which is not supported "
+                                    + "by this resource provider", path.child(fieldName)));
+                } else {
+                    throw new BadRequestException(i18n(
+                            "The request cannot be processed because it included "
+                                    + "an unrecognized field '%s'", path.child(fieldName)));
+                }
+                break;
+            default:
+                /*
+                 * The patch operation targets the child of a sub-field. This is
+                 * not possible for a LDAP attribute mapper.
+                 */
+                throw new BadRequestException(i18n(
+                        "The request cannot be processed because it included "
+                                + "an unrecognized field '%s'", path.child(field.get(0))));
+            }
+
+            // Check that the values are compatible with the type of patch operation.
+            final List<Object> newValues = asList(v, Collections.emptyList());
+            final ModificationType modType;
+            if (operation.isAdd()) {
+                /*
+                 * Use a replace for single valued fields in case the underlying
+                 * LDAP attribute is multi-valued, or the attribute already
+                 * contains a value.
+                 */
+                modType =
+                        attributeIsSingleValued() ? ModificationType.REPLACE : ModificationType.ADD;
+                if (newValues.isEmpty()) {
+                    throw new BadRequestException(i18n(
+                            "The request cannot be processed because it included "
+                                    + "an add patch operation but no value(s) for field '%s'", path
+                                    .child(field.get(0))));
+                }
+            } else if (operation.isRemove()) {
+                modType = ModificationType.DELETE;
+            } else if (operation.isReplace()) {
+                modType = ModificationType.REPLACE;
+            } else if (operation.isIncrement()) {
+                modType = ModificationType.INCREMENT;
+                if (newValues.isEmpty()) {
+                    throw new BadRequestException(i18n(
+                            "The request cannot be processed because it included "
+                                    + "an increment patch operation but no value for field '%s'",
+                            path.child(field.get(0))));
+                } else if (newValues.size() > 1) {
+                    throw new BadRequestException(
+                            i18n("The request cannot be processed because it included "
+                                    + "an increment patch operation with multiple values for field '%s'",
+                                    path.child(field.get(0))));
+                }
+            } else {
+                throw new NotSupportedException(i18n(
+                        "The request cannot be processed because it included "
+                                + "an unsupported type of patch operation '%s'", operation
+                                .getOperation()));
+            }
+
+            // Create the modification.
+            if (newValues.isEmpty()) {
+                // Deleting the attribute.
+                if (isRequired) {
+                    h.handleError(new BadRequestException(i18n(
+                            "The request cannot be processed because it attempts to remove "
+                                    + "the required field '%s'", path)));
+                } else {
+                    h.handleResult(singletonList(new Modification(modType,
+                            emptyAttribute(ldapAttributeName))));
+                }
+            } else {
+                getNewLDAPAttributes(c, path, newValues, transform(
+                        new Function<Attribute, List<Modification>, Void>() {
+                            @Override
+                            public List<Modification> apply(final Attribute value, final Void p) {
+                                return singletonList(new Modification(modType, value));
+                            }
+                        }, h));
+            }
+        } catch (final Exception e) {
+            h.handleError(asResourceException(e));
         }
     }
 
-    private List<Object> asList(final JsonValue v) {
+    @Override
+    void update(final Context c, final JsonPointer path, final Entry e, final JsonValue v,
+            final ResultHandler<List<Modification>> h) {
+        getNewLDAPAttributes(c, path, v, updateAttributeHandler(path, e, h));
+    }
+
+    private List<Object> asList(final JsonValue v, final List<Object> defaultValues) {
         if (isNullOrEmpty(v)) {
-            return defaultJSONValues;
+            return defaultValues;
         } else if (v.isList()) {
             return v.asList();
         } else {
             return singletonList(v.getObject());
         }
     }
+
+    private void checkSchema(final JsonPointer path, final JsonValue v) throws BadRequestException {
+        if (attributeIsSingleValued()) {
+            if (v != null && v.isList()) {
+                // Single-valued field violation.
+                throw new BadRequestException(i18n(
+                        "The request cannot be processed because an array of values was "
+                                + "provided for the single valued field '%s'", path));
+            }
+        } else if (v != null && !v.isList()) {
+            // Multi-valued field violation.
+            throw new BadRequestException(i18n(
+                    "The request cannot be processed because an array of values was "
+                            + "not provided for the multi-valued field '%s'", path));
+        }
+    }
+
+    private ResultHandler<Attribute> createAttributeHandler(final JsonPointer path,
+            final ResultHandler<List<Attribute>> h) {
+        return new ResultHandler<Attribute>() {
+            @Override
+            public void handleError(final ResourceException error) {
+                h.handleError(error);
+            }
+
+            @Override
+            public void handleResult(final Attribute newLDAPAttribute) {
+                if (!writabilityPolicy.canCreate(ldapAttributeName)) {
+                    if (newLDAPAttribute.isEmpty() || writabilityPolicy.discardWrites()) {
+                        h.handleResult(Collections.<Attribute> emptyList());
+                    } else {
+                        h.handleError(new BadRequestException(i18n(
+                                "The request cannot be processed because it attempts to create "
+                                        + "the read-only field '%s'", path)));
+                    }
+                } else if (newLDAPAttribute.isEmpty()) {
+                    if (isRequired) {
+                        h.handleError(new BadRequestException(i18n(
+                                "The request cannot be processed because it attempts to remove "
+                                        + "the required field '%s'", path)));
+                        return;
+                    } else {
+                        h.handleResult(Collections.<Attribute> emptyList());
+                    }
+                } else {
+                    h.handleResult(singletonList(newLDAPAttribute));
+                }
+            }
+        };
+    }
+
+    private void getNewLDAPAttributes(final Context c, final JsonPointer path, final JsonValue v,
+            final ResultHandler<Attribute> attributeHandler) {
+        try {
+            // Ensure that the value is of the correct type.
+            checkSchema(path, v);
+            final List<Object> newValues = asList(v, defaultJSONValues);
+            if (newValues.isEmpty()) {
+                // Skip sub-class implementation if there are no values.
+                attributeHandler.handleResult(emptyAttribute(ldapAttributeName));
+            } else {
+                getNewLDAPAttributes(c, path, newValues, attributeHandler);
+            }
+        } catch (final Exception ex) {
+            attributeHandler.handleError(asResourceException(ex));
+        }
+    }
+
+    private ResultHandler<Attribute> updateAttributeHandler(final JsonPointer path, final Entry e,
+            final ResultHandler<List<Modification>> h) {
+        // Get the existing LDAP attribute.
+        final Attribute tmp = e.getAttribute(ldapAttributeName);
+        final Attribute oldLDAPAttribute = tmp != null ? tmp : emptyAttribute(ldapAttributeName);
+        return new ResultHandler<Attribute>() {
+            @Override
+            public void handleError(final ResourceException error) {
+                h.handleError(error);
+            }
+
+            @Override
+            public void handleResult(final Attribute newLDAPAttribute) {
+                /*
+                 * If the attribute is read-only then handle the following
+                 * cases:
+                 *
+                 * 1) new values are provided and they are the same as the
+                 * existing values
+                 *
+                 * 2) no new values are provided.
+                 */
+                if (!writabilityPolicy.canWrite(ldapAttributeName)) {
+                    if (newLDAPAttribute.isEmpty() || newLDAPAttribute.equals(oldLDAPAttribute)
+                            || writabilityPolicy.discardWrites()) {
+                        // No change.
+                        h.handleResult(Collections.<Modification> emptyList());
+                    } else {
+                        h.handleError(new BadRequestException(i18n(
+                                "The request cannot be processed because it attempts to modify "
+                                        + "the read-only field '%s'", path)));
+                    }
+                } else {
+                    // Compute the changes to the attribute.
+                    final List<Modification> modifications;
+                    if (oldLDAPAttribute.isEmpty() && newLDAPAttribute.isEmpty()) {
+                        // No change.
+                        modifications = Collections.<Modification> emptyList();
+                    } else if (oldLDAPAttribute.isEmpty()) {
+                        // The attribute is being added.
+                        modifications =
+                                singletonList(new Modification(ModificationType.REPLACE,
+                                        newLDAPAttribute));
+                    } else if (newLDAPAttribute.isEmpty()) {
+                        /*
+                         * The attribute is being deleted - this is not allowed
+                         * if the attribute is required.
+                         */
+                        if (isRequired) {
+                            h.handleError(new BadRequestException(i18n(
+                                    "The request cannot be processed because it attempts to remove "
+                                            + "the required field '%s'", path)));
+                            return;
+                        } else {
+                            modifications =
+                                    singletonList(new Modification(ModificationType.REPLACE,
+                                            newLDAPAttribute));
+                        }
+                    } else {
+                        /*
+                         * We could do a replace, but try to save bandwidth and
+                         * send diffs instead. Perform deletes first in case we
+                         * don't have an appropriate normalizer: permissive
+                         * add(x) followed by delete(x) is destructive, whereas
+                         * delete(x) followed by add(x) is idempotent when
+                         * adding/removing the same value.
+                         */
+                        modifications = new ArrayList<Modification>(2);
+
+                        final Attribute deletedValues = new LinkedAttribute(oldLDAPAttribute);
+                        deletedValues.removeAll(newLDAPAttribute);
+                        if (!deletedValues.isEmpty()) {
+                            modifications.add(new Modification(ModificationType.DELETE,
+                                    deletedValues));
+                        }
+
+                        final Attribute addedValues = new LinkedAttribute(newLDAPAttribute);
+                        addedValues.removeAll(oldLDAPAttribute);
+                        if (!addedValues.isEmpty()) {
+                            modifications.add(new Modification(ModificationType.ADD, addedValues));
+                        }
+                    }
+                    h.handleResult(modifications);
+                }
+            }
+        };
+    }
 }
diff --git a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AttributeMapper.java b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AttributeMapper.java
index 02cc5d6..53a7602 100644
--- a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AttributeMapper.java
+++ b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AttributeMapper.java
@@ -20,7 +20,9 @@
 
 import org.forgerock.json.fluent.JsonPointer;
 import org.forgerock.json.fluent.JsonValue;
+import org.forgerock.json.resource.PatchOperation;
 import org.forgerock.json.resource.ResultHandler;
+import org.forgerock.opendj.ldap.Attribute;
 import org.forgerock.opendj.ldap.Entry;
 import org.forgerock.opendj.ldap.Filter;
 import org.forgerock.opendj.ldap.Modification;
@@ -40,6 +42,33 @@
     }
 
     /**
+     * Maps a JSON value to one or more LDAP attributes, invoking a completion
+     * handler once the transformation has completed. This method is invoked
+     * when a REST resource is created using a create request.
+     * <p>
+     * If the JSON value corresponding to this mapper is not present in the
+     * resource then this method will be invoked with a value of {@code null}.
+     * It is the responsibility of the mapper implementation to take appropriate
+     * action in this case, perhaps by substituting default LDAP values, or by
+     * rejecting the update by invoking the result handler's
+     * {@link ResultHandler#handleError handleError} method.
+     *
+     * @param c
+     *            The context.
+     * @param path
+     *            The pointer from the root of the JSON resource to this
+     *            attribute mapper. This may be used when constructing error
+     *            messages.
+     * @param v
+     *            The JSON value to be converted to LDAP attributes, which may
+     *            be {@code null} indicating that the JSON value was not present
+     *            in the resource.
+     * @param h
+     *            The result handler.
+     */
+    abstract void create(Context c, JsonPointer path, JsonValue v, ResultHandler<List<Attribute>> h);
+
+    /**
      * Adds the names of the LDAP attributes required by this attribute mapper
      * to the provided set.
      * <p>
@@ -48,10 +77,14 @@
      *
      * @param c
      *            The context.
-     * @param jsonAttribute
-     *            The name of the requested sub-attribute within this mapper or
+     * @param path
+     *            The pointer from the root of the JSON resource to this
+     *            attribute mapper. This may be used when constructing error
+     *            messages.
+     * @param subPath
+     *            The targeted JSON field relative to this attribute mapper, or
      *            root if all attributes associated with this mapper have been
-     *            requested.
+     *            targeted.
      * @param ldapAttributes
      *            The set into which the required LDAP attribute names should be
      *            put.
@@ -71,12 +104,16 @@
      *
      * @param c
      *            The context.
+     * @param path
+     *            The pointer from the root of the JSON resource to this
+     *            attribute mapper. This may be used when constructing error
+     *            messages.
+     * @param subPath
+     *            The targeted JSON field relative to this attribute mapper, or
+     *            root if all attributes associated with this mapper have been
+     *            targeted.
      * @param type
      *            The type of REST comparison filter.
-     * @param jsonAttribute
-     *            The name of the targeted sub-attribute within this mapper or
-     *            root if all attributes associated with this mapper have been
-     *            targeted by the filter.
      * @param operator
      *            The name of the extended operator to use for the comparison,
      *            or {@code null} if {@code type} is not
@@ -91,6 +128,28 @@
             String operator, Object valueAssertion, ResultHandler<Filter> h);
 
     /**
+     * Maps a JSON patch operation to one or more LDAP modifications, invoking a
+     * completion handler once the transformation has completed. This method is
+     * invoked when a REST resource is modified using a patch request.
+     *
+     * @param c
+     *            The context.
+     * @param path
+     *            The pointer from the root of the JSON resource to this
+     *            attribute mapper. This may be used when constructing error
+     *            messages.
+     * @param operation
+     *            The JSON patch operation to be converted to LDAP
+     *            modifications. The targeted JSON field will be relative to
+     *            this attribute mapper, or root if all attributes associated
+     *            with this mapper have been targeted.
+     * @param h
+     *            The result handler.
+     */
+    abstract void patch(Context c, JsonPointer path, PatchOperation operation,
+            ResultHandler<List<Modification>> h);
+
+    /**
      * Maps one or more LDAP attributes to their JSON representation, invoking a
      * completion handler once the transformation has completed.
      * <p>
@@ -109,20 +168,21 @@
      *
      * @param c
      *            The context.
+     * @param path
+     *            The pointer from the root of the JSON resource to this
+     *            attribute mapper. This may be used when constructing error
+     *            messages.
      * @param e
      *            The LDAP entry to be converted to JSON.
      * @param h
      *            The result handler.
      */
-    abstract void toJSON(Context c, JsonPointer path, Entry e, ResultHandler<JsonValue> h);
+    abstract void read(Context c, JsonPointer path, Entry e, ResultHandler<JsonValue> h);
 
     /**
-     * Maps a JSON value to one or more LDAP attributes, invoking a completion
-     * handler once the transformation has completed.
-     * <p>
-     * This method is invoked whenever a REST resource is converted to an LDAP
-     * entry or LDAP modification, i.e. when performing create, put, or patch
-     * requests.
+     * Maps a JSON value to one or more LDAP modifications, invoking a
+     * completion handler once the transformation has completed. This method is
+     * invoked when a REST resource is modified using an update request.
      * <p>
      * If the JSON value corresponding to this mapper is not present in the
      * resource then this method will be invoked with a value of {@code null}.
@@ -140,7 +200,7 @@
      * @param h
      *            The result handler.
      */
-    abstract void toLDAP(Context c, JsonPointer path, Entry e, JsonValue v,
+    abstract void update(Context c, JsonPointer path, Entry e, JsonValue v,
             ResultHandler<List<Modification>> h);
 
     // TODO: methods for obtaining schema information (e.g. name, description,
diff --git a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JSONConstantAttributeMapper.java b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JSONConstantAttributeMapper.java
index da36b92..a1eee8d 100644
--- a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JSONConstantAttributeMapper.java
+++ b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JSONConstantAttributeMapper.java
@@ -29,7 +29,9 @@
 import org.forgerock.json.fluent.JsonPointer;
 import org.forgerock.json.fluent.JsonValue;
 import org.forgerock.json.resource.BadRequestException;
+import org.forgerock.json.resource.PatchOperation;
 import org.forgerock.json.resource.ResultHandler;
+import org.forgerock.opendj.ldap.Attribute;
 import org.forgerock.opendj.ldap.Entry;
 import org.forgerock.opendj.ldap.Filter;
 import org.forgerock.opendj.ldap.Modification;
@@ -45,6 +47,18 @@
     }
 
     @Override
+    void create(final Context c, final JsonPointer path, final JsonValue v,
+            final ResultHandler<List<Attribute>> h) {
+        if (!isNullOrEmpty(v) && !v.getObject().equals(value.getObject())) {
+            h.handleError(new BadRequestException(i18n(
+                    "The request cannot be processed because it attempts to create "
+                            + "the read-only field '%s'", path)));
+        } else {
+            h.handleResult(Collections.<Attribute> emptyList());
+        }
+    }
+
+    @Override
     void getLDAPAttributes(final Context c, final JsonPointer path, final JsonPointer subPath,
             final Set<String> ldapAttributes) {
         // Nothing to do.
@@ -90,24 +104,29 @@
     }
 
     @Override
-    void toJSON(final Context c, final JsonPointer path, final Entry e,
+    void patch(final Context c, final JsonPointer path, final PatchOperation operation,
+            final ResultHandler<List<Modification>> h) {
+        h.handleError(new BadRequestException(i18n(
+                "The request cannot be processed because it attempts to patch "
+                        + "the read-only field '%s'", path)));
+    }
+
+    @Override
+    void read(final Context c, final JsonPointer path, final Entry e,
             final ResultHandler<JsonValue> h) {
         h.handleResult(value.copy());
     }
 
     @Override
-    void toLDAP(final Context c, final JsonPointer path, final Entry e, final JsonValue v,
+    void update(final Context c, final JsonPointer path, final Entry e, final JsonValue v,
             final ResultHandler<List<Modification>> h) {
-        if (!isNullOrEmpty(v)) {
-            // A value was provided so it must match.
-            if (!v.getObject().equals(value.getObject())) {
-                h.handleError(new BadRequestException(i18n(
-                        "The request cannot be processed because it attempts to modify "
-                                + "the read-only field '%s'", path)));
-                return;
-            }
+        if (!isNullOrEmpty(v) && !v.getObject().equals(value.getObject())) {
+            h.handleError(new BadRequestException(i18n(
+                    "The request cannot be processed because it attempts to modify "
+                            + "the read-only field '%s'", path)));
+        } else {
+            h.handleResult(Collections.<Modification> emptyList());
         }
-        h.handleResult(Collections.<Modification> emptyList());
     }
 
     private <T extends Comparable<T>> Filter compare(final Context c, final FilterType type,
diff --git a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/LDAPCollectionResourceProvider.java b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/LDAPCollectionResourceProvider.java
index 83f0215..2beb935 100644
--- a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/LDAPCollectionResourceProvider.java
+++ b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/LDAPCollectionResourceProvider.java
@@ -44,8 +44,8 @@
 import org.forgerock.json.resource.CollectionResourceProvider;
 import org.forgerock.json.resource.CreateRequest;
 import org.forgerock.json.resource.DeleteRequest;
-import org.forgerock.json.resource.InternalServerErrorException;
 import org.forgerock.json.resource.NotSupportedException;
+import org.forgerock.json.resource.PatchOperation;
 import org.forgerock.json.resource.PatchRequest;
 import org.forgerock.json.resource.PreconditionFailedException;
 import org.forgerock.json.resource.QueryFilter;
@@ -69,7 +69,6 @@
 import org.forgerock.opendj.ldap.Filter;
 import org.forgerock.opendj.ldap.Function;
 import org.forgerock.opendj.ldap.Modification;
-import org.forgerock.opendj.ldap.ModificationType;
 import org.forgerock.opendj.ldap.SearchResultHandler;
 import org.forgerock.opendj.ldap.SearchScope;
 import org.forgerock.opendj.ldap.controls.AssertionRequestControl;
@@ -136,31 +135,22 @@
             @Override
             public void run() {
                 // Calculate entry content.
-                attributeMapper.toLDAP(c, new JsonPointer(), null, request.getContent(),
-                        new ResultHandler<List<Modification>>() {
+                attributeMapper.create(c, new JsonPointer(), request.getContent(),
+                        new ResultHandler<List<Attribute>>() {
                             @Override
                             public void handleError(final ResourceException error) {
                                 h.handleError(error);
                             }
 
                             @Override
-                            public void handleResult(final List<Modification> result) {
+                            public void handleResult(final List<Attribute> result) {
                                 // Perform add operation.
                                 final AddRequest addRequest = newAddRequest(DN.rootDN());
                                 for (final Attribute attribute : additionalLDAPAttributes) {
                                     addRequest.addAttribute(attribute);
                                 }
-                                for (final Modification modification : result) {
-                                    if (modification.getModificationType() == ModificationType.ADD
-                                            || modification.getModificationType() == ModificationType.REPLACE) {
-                                        addRequest.addAttribute(modification.getAttribute());
-                                    } else {
-                                        // Attribute mappers must return add/replace updates.
-                                        h.handleError(new InternalServerErrorException(
-                                                i18n("Attribute mapper returned a modification which "
-                                                        + "does not add an attribute")));
-                                        return;
-                                    }
+                                for (final Attribute attribute : result) {
+                                    addRequest.addAttribute(attribute);
                                 }
                                 try {
                                     nameStrategy.setResourceId(c, getBaseDN(c), request
@@ -189,59 +179,104 @@
         final Context c = wrap(context);
         final ResultHandler<Resource> h = wrap(c, handler);
 
-        // Get connection then perform the search.
-        c.run(h, new Runnable() {
+        // Get connection, search if needed, then delete.
+        c.run(h, doUpdate(c, resourceId, request.getRevision(), new ResultHandler<DN>() {
             @Override
-            public void run() {
-                // Find the entry and then delete it.
-                final String ldapAttribute =
-                        (etagAttribute != null && request.getRevision() != null) ? etagAttribute
-                                .toString() : "1.1";
-                final SearchRequest searchRequest =
-                        nameStrategy.createSearchRequest(c, getBaseDN(c), resourceId).addAttribute(
-                                ldapAttribute);
-                c.getConnection().searchSingleEntryAsync(searchRequest,
-                        new org.forgerock.opendj.ldap.ResultHandler<SearchResultEntry>() {
-                            @Override
-                            public void handleErrorResult(final ErrorResultException error) {
-                                h.handleError(asResourceException(error));
-                            }
-
-                            @Override
-                            public void handleResult(final SearchResultEntry entry) {
-                                try {
-                                    // Fail-fast if there is a version mismatch.
-                                    ensureMVCCVersionMatches(entry, request.getRevision());
-
-                                    // Perform delete operation.
-                                    final ChangeRecord deleteRequest =
-                                            newDeleteRequest(entry.getName());
-                                    if (config.readOnUpdatePolicy() == CONTROLS) {
-                                        final String[] attributes =
-                                                getLDAPAttributes(c, request.getFields());
-                                        deleteRequest.addControl(PreReadRequestControl.newControl(
-                                                false, attributes));
-                                    }
-                                    if (config.useSubtreeDelete()) {
-                                        deleteRequest.addControl(SubtreeDeleteRequestControl
-                                                .newControl(true));
-                                    }
-                                    addAssertionControl(deleteRequest, request.getRevision());
-                                    c.getConnection().applyChangeAsync(deleteRequest, null,
-                                            postUpdateHandler(c, h));
-                                } catch (final Exception e) {
-                                    h.handleError(asResourceException(e));
-                                }
-                            }
-                        });
+            public void handleError(final ResourceException error) {
+                h.handleError(error);
             }
-        });
+
+            @Override
+            public void handleResult(final DN dn) {
+                try {
+                    final ChangeRecord deleteRequest = newDeleteRequest(dn);
+                    if (config.readOnUpdatePolicy() == CONTROLS) {
+                        final String[] attributes = getLDAPAttributes(c, request.getFields());
+                        deleteRequest.addControl(PreReadRequestControl
+                                .newControl(false, attributes));
+                    }
+                    if (config.useSubtreeDelete()) {
+                        deleteRequest.addControl(SubtreeDeleteRequestControl.newControl(true));
+                    }
+                    addAssertionControl(deleteRequest, request.getRevision());
+                    c.getConnection()
+                            .applyChangeAsync(deleteRequest, null, postUpdateHandler(c, h));
+                } catch (final Exception e) {
+                    h.handleError(asResourceException(e));
+                }
+            }
+        }));
     }
 
     @Override
     public void patchInstance(final ServerContext context, final String resourceId,
             final PatchRequest request, final ResultHandler<Resource> handler) {
-        handler.handleError(new NotSupportedException("Not yet implemented"));
+        final Context c = wrap(context);
+        final ResultHandler<Resource> h = wrap(c, handler);
+
+        /*
+         * Get the connection, search if needed, then determine modifications,
+         * then perform modify.
+         */
+        c.run(h, doUpdate(c, resourceId, request.getRevision(), new ResultHandler<DN>() {
+            @Override
+            public void handleError(final ResourceException error) {
+                h.handleError(error);
+            }
+
+            @Override
+            public void handleResult(final DN dn) {
+                //  Convert the patch operations to LDAP modifications.
+                final ResultHandler<List<Modification>> handler =
+                        accumulate(request.getPatchOperations().size(),
+                                new ResultHandler<List<List<Modification>>>() {
+                                    @Override
+                                    public void handleError(final ResourceException error) {
+                                        h.handleError(error);
+                                    }
+
+                                    @Override
+                                    public void handleResult(final List<List<Modification>> result) {
+                                        //  The patch operations have been converted successfully.
+                                        try {
+                                            final ModifyRequest modifyRequest =
+                                                    newModifyRequest(dn);
+                                            if (config.readOnUpdatePolicy() == CONTROLS) {
+                                                final String[] attributes =
+                                                        getLDAPAttributes(c, request.getFields());
+                                                modifyRequest.addControl(PostReadRequestControl
+                                                        .newControl(false, attributes));
+                                            }
+                                            if (config.usePermissiveModify()) {
+                                                modifyRequest
+                                                        .addControl(PermissiveModifyRequestControl
+                                                                .newControl(true));
+                                            }
+                                            addAssertionControl(modifyRequest, request
+                                                    .getRevision());
+
+                                            // Add the modifications.
+                                            for (final List<Modification> modifications : result) {
+                                                if (modifications != null) {
+                                                    modifyRequest.getModifications().addAll(
+                                                            modifications);
+                                                }
+                                            }
+
+                                            // Perform the modify request.
+                                            c.getConnection().applyChangeAsync(modifyRequest, null,
+                                                    postUpdateHandler(c, h));
+                                        } catch (final Exception e) {
+                                            h.handleError(asResourceException(e));
+                                        }
+                                    }
+                                });
+
+                for (final PatchOperation operation : request.getPatchOperations()) {
+                    attributeMapper.patch(c, new JsonPointer(), operation, handler);
+                }
+            }
+        }));
     }
 
     @Override
@@ -321,7 +356,7 @@
                                             };
 
                                     pendingResourceCount.incrementAndGet();
-                                    attributeMapper.toJSON(c, new JsonPointer(), entry, mapHandler);
+                                    attributeMapper.read(c, new JsonPointer(), entry, mapHandler);
                                     return true;
                                 }
 
@@ -404,7 +439,7 @@
     public void updateInstance(final ServerContext context, final String resourceId,
             final UpdateRequest request, final ResultHandler<Resource> handler) {
         /*
-         * Update operations are a bit awkward because there is not direct
+         * Update operations are a bit awkward because there is no direct
          * mapping to LDAP. We need to convert the update request into an LDAP
          * modify operation which means reading the current LDAP entry,
          * generating the new entry content, then comparing the two in order to
@@ -415,11 +450,10 @@
         final Context c = wrap(context);
         final ResultHandler<Resource> h = wrap(c, handler);
 
-        // Get connection then perform the search.
+        // Get connection then, search for the existing entry, then modify.
         c.run(h, new Runnable() {
             @Override
             public void run() {
-                // First of all read the existing entry.
                 final String[] attributes =
                         getLDAPAttributes(c, Collections.<JsonPointer> emptyList());
                 final SearchRequest searchRequest =
@@ -457,7 +491,7 @@
                                      * Determine the set of changes that need to
                                      * be performed.
                                      */
-                                    attributeMapper.toLDAP(c, new JsonPointer(), entry, request
+                                    attributeMapper.update(c, new JsonPointer(), entry, request
                                             .getNewContent(),
                                             new ResultHandler<List<Modification>>() {
                                                 @Override
@@ -489,7 +523,7 @@
             final ResultHandler<Resource> handler) {
         final String actualResourceId = nameStrategy.getResourceId(c, entry);
         final String revision = getRevisionFromEntry(entry);
-        attributeMapper.toJSON(c, new JsonPointer(), entry, transform(
+        attributeMapper.read(c, new JsonPointer(), entry, transform(
                 new Function<JsonValue, Resource, Void>() {
                     @Override
                     public Resource apply(final JsonValue value, final Void p) {
@@ -507,6 +541,46 @@
         }
     }
 
+    private Runnable doUpdate(final Context c, final String resourceId, final String revision,
+            final ResultHandler<DN> updateHandler) {
+        return new Runnable() {
+            @Override
+            public void run() {
+                final String ldapAttribute =
+                        (etagAttribute != null && revision != null) ? etagAttribute.toString()
+                                : "1.1";
+                final SearchRequest searchRequest =
+                        nameStrategy.createSearchRequest(c, getBaseDN(c), resourceId).addAttribute(
+                                ldapAttribute);
+                if (searchRequest.getScope().equals(SearchScope.BASE_OBJECT)) {
+                    // There's no point in doing a search because we already know the DN.
+                    updateHandler.handleResult(searchRequest.getName());
+                } else {
+                    c.getConnection().searchSingleEntryAsync(searchRequest,
+                            new org.forgerock.opendj.ldap.ResultHandler<SearchResultEntry>() {
+                                @Override
+                                public void handleErrorResult(final ErrorResultException error) {
+                                    updateHandler.handleError(asResourceException(error));
+                                }
+
+                                @Override
+                                public void handleResult(final SearchResultEntry entry) {
+                                    try {
+                                        // Fail-fast if there is a version mismatch.
+                                        ensureMVCCVersionMatches(entry, revision);
+
+                                        // Perform update operation.
+                                        updateHandler.handleResult(entry.getName());
+                                    } catch (final Exception e) {
+                                        updateHandler.handleError(asResourceException(e));
+                                    }
+                                }
+                            });
+                }
+            }
+        };
+    }
+
     private void ensureMVCCSupported() throws NotSupportedException {
         if (etagAttribute == null) {
             throw new NotSupportedException(
diff --git a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectAttributeMapper.java b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectAttributeMapper.java
index d4ba83f..c05677f 100644
--- a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectAttributeMapper.java
+++ b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectAttributeMapper.java
@@ -15,7 +15,9 @@
  */
 package org.forgerock.opendj.rest2ldap;
 
+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.accumulate;
 import static org.forgerock.opendj.rest2ldap.Utils.i18n;
 import static org.forgerock.opendj.rest2ldap.Utils.toLowerCase;
@@ -32,7 +34,10 @@
 import org.forgerock.json.fluent.JsonPointer;
 import org.forgerock.json.fluent.JsonValue;
 import org.forgerock.json.resource.BadRequestException;
+import org.forgerock.json.resource.PatchOperation;
+import org.forgerock.json.resource.ResourceException;
 import org.forgerock.json.resource.ResultHandler;
+import org.forgerock.opendj.ldap.Attribute;
 import org.forgerock.opendj.ldap.Entry;
 import org.forgerock.opendj.ldap.Filter;
 import org.forgerock.opendj.ldap.Function;
@@ -80,6 +85,37 @@
     }
 
     @Override
+    void create(final Context c, final JsonPointer path, final JsonValue v,
+            final ResultHandler<List<Attribute>> h) {
+        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);
+
+            // Accumulate the results of the subordinate mappings.
+            final ResultHandler<List<Attribute>> handler = accumulator(h);
+
+            // Invoke mappings for which there are values provided.
+            if (v != null && !v.isNull()) {
+                for (final Map.Entry<String, Object> me : v.asMap().entrySet()) {
+                    final Mapping mapping = getMapping(me.getKey());
+                    final JsonValue subValue = new JsonValue(me.getValue());
+                    mapping.mapper.create(c, path.child(me.getKey()), subValue, handler);
+                }
+            }
+
+            // Invoke mappings for which there were no values provided.
+            for (final Mapping mapping : missingMappings.values()) {
+                mapping.mapper.create(c, path.child(mapping.name), null, handler);
+            }
+        } catch (final Exception e) {
+            h.handleError(asResourceException(e));
+        }
+    }
+
+    @Override
     void getLDAPAttributes(final Context c, final JsonPointer path, final JsonPointer subPath,
             final Set<String> ldapAttributes) {
         if (subPath.isEmpty()) {
@@ -117,7 +153,55 @@
     }
 
     @Override
-    void toJSON(final Context c, final JsonPointer path, final Entry e,
+    void patch(final Context c, final JsonPointer path, final PatchOperation operation,
+            final ResultHandler<List<Modification>> h) {
+        try {
+            final JsonPointer field = operation.getField();
+            final JsonValue v = operation.getValue();
+
+            if (field.isEmpty()) {
+                /*
+                 * The patch operation applies to this object. We'll handle this
+                 * by allowing the JSON value to be a partial object and
+                 * add/remove/replace only the provided values.
+                 */
+                checkMapping(path, operation.getValue());
+
+                // Accumulate the results of the subordinate mappings.
+                final ResultHandler<List<Modification>> handler = accumulator(h);
+
+                // Invoke the sub-mappers using a new patch operation targeted at each field.
+                for (final Map.Entry<String, Object> me : v.asMap().entrySet()) {
+                    final Mapping mapping = getMapping(me.getKey());
+                    final JsonValue subValue = new JsonValue(me.getValue());
+                    final PatchOperation subOperation =
+                            operation(operation.getOperation(), field /* empty */, subValue);
+                    mapping.mapper.patch(c, path.child(me.getKey()), subOperation, handler);
+                }
+            } else {
+                /*
+                 * The patch operation targets a subordinate field. Create a new
+                 * patch operation targeting the field and forward it to the
+                 * appropriate mapper.
+                 */
+                final String fieldName = field.get(0);
+                final Mapping mapping = getMapping(fieldName);
+                if (mapping == null) {
+                    throw new BadRequestException(i18n(
+                            "The request cannot be processed because it included "
+                                    + "an unrecognized field '%s'", path.child(fieldName)));
+                }
+                final PatchOperation subOperation =
+                        operation(operation.getOperation(), field.relativePointer(), v);
+                mapping.mapper.patch(c, path.child(fieldName), subOperation, h);
+            }
+        } catch (final Exception ex) {
+            h.handleError(asResourceException(ex));
+        }
+    }
+
+    @Override
+    void read(final Context c, final JsonPointer path, final Entry e,
             final ResultHandler<JsonValue> h) {
         /*
          * Use an accumulator which will aggregate the results from the
@@ -152,7 +236,7 @@
                         }, h));
 
         for (final Mapping mapping : mappings.values()) {
-            mapping.mapper.toJSON(c, path.child(mapping.name), e, transform(
+            mapping.mapper.read(c, path.child(mapping.name), e, transform(
                     new Function<JsonValue, Map.Entry<String, JsonValue>, Void>() {
                         @Override
                         public Map.Entry<String, JsonValue> apply(final JsonValue value,
@@ -165,69 +249,80 @@
     }
 
     @Override
-    void toLDAP(final Context c, final JsonPointer path, final Entry e, final JsonValue v,
+    void update(final Context c, final JsonPointer path, final Entry e, final JsonValue v,
             final ResultHandler<List<Modification>> h) {
-        /*
-         * Fail immediately if the JSON value has the wrong type or contains
-         * unknown attributes.
-         */
+        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);
+
+            // Accumulate the results of the subordinate mappings.
+            final ResultHandler<List<Modification>> handler = accumulator(h);
+
+            // Invoke mappings for which there are values provided.
+            if (v != null && !v.isNull()) {
+                for (final Map.Entry<String, Object> me : v.asMap().entrySet()) {
+                    final Mapping mapping = getMapping(me.getKey());
+                    final JsonValue subValue = new JsonValue(me.getValue());
+                    mapping.mapper.update(c, path.child(me.getKey()), e, subValue, handler);
+                }
+            }
+
+            // Invoke mappings for which there were no values provided.
+            for (final Mapping mapping : missingMappings.values()) {
+                mapping.mapper.update(c, path.child(mapping.name), e, null, handler);
+            }
+        } catch (final Exception ex) {
+            h.handleError(asResourceException(ex));
+        }
+    }
+
+    private <T> ResultHandler<List<T>> accumulator(final ResultHandler<List<T>> h) {
+        return accumulate(mappings.size(), transform(new Function<List<List<T>>, List<T>, Void>() {
+            @Override
+            public List<T> apply(final List<List<T>> value, final Void p) {
+                switch (value.size()) {
+                case 0:
+                    return Collections.emptyList();
+                case 1:
+                    return value.get(0);
+                default:
+                    final List<T> attributes = new ArrayList<T>(value.size());
+                    for (final List<T> a : value) {
+                        attributes.addAll(a);
+                    }
+                    return attributes;
+                }
+            }
+        }, h));
+    }
+
+    /*
+     * 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 {
         final Map<String, Mapping> missingMappings = new LinkedHashMap<String, Mapping>(mappings);
         if (v != null && !v.isNull()) {
             if (v.isMap()) {
                 for (final String attribute : v.asMap().keySet()) {
                     if (missingMappings.remove(toLowerCase(attribute)) == null) {
-                        h.handleError(new BadRequestException(i18n(
-                                "The request cannot be processed because the JSON resource "
-                                        + "contains an unrecognized field '%s'", path
-                                        .child(attribute))));
-                        return;
+                        throw new BadRequestException(i18n(
+                                "The request cannot be processed because it included "
+                                        + "an unrecognized field '%s'", path.child(attribute)));
                     }
                 }
             } else {
-                h.handleError(new BadRequestException(i18n(
-                        "The request cannot be processed because the JSON resource "
-                                + "contains the field '%s' whose value is the wrong type: "
-                                + "an object is expected", path)));
-                return;
+                throw new BadRequestException(i18n(
+                        "The request cannot be processed because it included "
+                                + "the field '%s' whose value is the wrong type: "
+                                + "an object is expected", path));
             }
         }
-
-        // Accumulate the results of the subordinate mappings.
-        final ResultHandler<List<Modification>> handler =
-                accumulate(mappings.size(), transform(
-                        new Function<List<List<Modification>>, List<Modification>, Void>() {
-                            @Override
-                            public List<Modification> apply(final List<List<Modification>> value,
-                                    final Void p) {
-                                switch (value.size()) {
-                                case 0:
-                                    return Collections.emptyList();
-                                case 1:
-                                    return value.get(0);
-                                default:
-                                    final List<Modification> attributes =
-                                            new ArrayList<Modification>(value.size());
-                                    for (final List<Modification> a : value) {
-                                        attributes.addAll(a);
-                                    }
-                                    return attributes;
-                                }
-                            }
-                        }, h));
-
-        // Invoke mappings for which there are values provided.
-        if (v != null && !v.isNull()) {
-            for (final Map.Entry<String, Object> me : v.asMap().entrySet()) {
-                final Mapping mapping = getMapping(me.getKey());
-                final JsonValue subValue = new JsonValue(me.getValue());
-                mapping.mapper.toLDAP(c, path.child(me.getKey()), e, subValue, handler);
-            }
-        }
-
-        // Invoke mappings for which there were no values provided.
-        for (final Mapping mapping : missingMappings.values()) {
-            mapping.mapper.toLDAP(c, path.child(mapping.name), e, null, handler);
-        }
+        return missingMappings;
     }
 
     private Mapping getMapping(final JsonPointer jsonAttribute) {
diff --git a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferenceAttributeMapper.java b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferenceAttributeMapper.java
index c899a5d..b84037d 100644
--- a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferenceAttributeMapper.java
+++ b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferenceAttributeMapper.java
@@ -46,7 +46,6 @@
 import org.forgerock.opendj.ldap.Filter;
 import org.forgerock.opendj.ldap.Function;
 import org.forgerock.opendj.ldap.LinkedAttribute;
-import org.forgerock.opendj.ldap.Modification;
 import org.forgerock.opendj.ldap.MultipleEntriesFoundException;
 import org.forgerock.opendj.ldap.ResultCode;
 import org.forgerock.opendj.ldap.SearchResultHandler;
@@ -193,105 +192,94 @@
                 new AtomicReference<ResourceException>();
 
         for (final Object value : newValues) {
-            mapper.toLDAP(c, path, null /* force create */, new JsonValue(value),
-                    new ResultHandler<List<Modification>>() {
+            mapper.create(c, path, new JsonValue(value), new ResultHandler<List<Attribute>>() {
 
-                        @Override
-                        public void handleError(final ResourceException error) {
-                            h.handleError(error);
+                @Override
+                public void handleError(final ResourceException error) {
+                    h.handleError(error);
+                }
+
+                @Override
+                public void handleResult(final List<Attribute> result) {
+                    Attribute primaryKeyAttribute = null;
+                    for (final Attribute attribute : result) {
+                        if (attribute.getAttributeDescription().equals(primaryKey)) {
+                            primaryKeyAttribute = attribute;
+                            break;
                         }
+                    }
 
-                        @Override
-                        public void handleResult(final List<Modification> result) {
-                            Attribute primaryKeyAttribute = null;
-                            for (final Modification modification : result) {
-                                if (modification.getAttribute().getAttributeDescription().equals(
-                                        primaryKey)) {
-                                    primaryKeyAttribute = modification.getAttribute();
-                                    break;
+                    if (primaryKeyAttribute == null || primaryKeyAttribute.isEmpty()) {
+                        h.handleError(new BadRequestException(i18n(
+                                "The request cannot be processed because the reference "
+                                        + "field '%s' contains a value which does not contain "
+                                        + "a primary key", path)));
+                        return;
+                    }
+
+                    if (primaryKeyAttribute.size() > 1) {
+                        h.handleError(new BadRequestException(i18n(
+                                "The request cannot be processed because the reference "
+                                        + "field '%s' contains a value which contains multiple "
+                                        + "primary keys", path)));
+                        return;
+                    }
+
+                    // Now search for the referenced entry in to get its DN.
+                    final ByteString primaryKeyValue = primaryKeyAttribute.firstValue();
+                    final Filter filter = Filter.equality(primaryKey.toString(), primaryKeyValue);
+                    final SearchRequest search = createSearchRequest(filter);
+                    c.getConnection().searchSingleEntryAsync(search,
+                            new org.forgerock.opendj.ldap.ResultHandler<SearchResultEntry>() {
+
+                                @Override
+                                public void handleErrorResult(final ErrorResultException error) {
+                                    ResourceException re;
+                                    try {
+                                        throw error;
+                                    } catch (final EntryNotFoundException e) {
+                                        re =
+                                                new BadRequestException(i18n(
+                                                        "The request cannot be processed "
+                                                                + "because the resource '%s' "
+                                                                + "referenced in field '%s' does "
+                                                                + "not exist", primaryKeyValue
+                                                                .toString(), path));
+                                    } catch (final MultipleEntriesFoundException e) {
+                                        re =
+                                                new BadRequestException(i18n(
+                                                        "The request cannot be processed "
+                                                                + "because the resource '%s' "
+                                                                + "referenced in field '%s' is "
+                                                                + "ambiguous", primaryKeyValue
+                                                                .toString(), path));
+                                    } catch (final ErrorResultException e) {
+                                        re = asResourceException(e);
+                                    }
+                                    exception.compareAndSet(null, re);
+                                    completeIfNecessary();
                                 }
-                            }
 
-                            if (primaryKeyAttribute == null || primaryKeyAttribute.isEmpty()) {
-                                h.handleError(new BadRequestException(
-                                        i18n("The request cannot be processed because the reference "
-                                                + "field '%s' contains a value which does not contain "
-                                                + "a primary key", path)));
-                                return;
-                            }
-
-                            if (primaryKeyAttribute.size() > 1) {
-                                h.handleError(new BadRequestException(
-                                        i18n("The request cannot be processed because the reference "
-                                                + "field '%s' contains a value which contains multiple "
-                                                + "primary keys", path)));
-                                return;
-                            }
-
-                            // Now search for the referenced entry in to get its DN.
-                            final ByteString primaryKeyValue = primaryKeyAttribute.firstValue();
-                            final Filter filter =
-                                    Filter.equality(primaryKey.toString(), primaryKeyValue);
-                            final SearchRequest search = createSearchRequest(filter);
-                            c.getConnection()
-                                    .searchSingleEntryAsync(
-                                            search,
-                                            new org.forgerock.opendj.ldap.ResultHandler<SearchResultEntry>() {
-
-                                                @Override
-                                                public void handleErrorResult(
-                                                        final ErrorResultException error) {
-                                                    ResourceException re;
-                                                    try {
-                                                        throw error;
-                                                    } catch (final EntryNotFoundException e) {
-                                                        re =
-                                                                new BadRequestException(
-                                                                        i18n("The request cannot be processed "
-                                                                                + "because the resource '%s' "
-                                                                                + "referenced in field '%s' does "
-                                                                                + "not exist",
-                                                                                primaryKeyValue
-                                                                                        .toString(),
-                                                                                path));
-                                                    } catch (final MultipleEntriesFoundException e) {
-                                                        re =
-                                                                new BadRequestException(
-                                                                        i18n("The request cannot be processed "
-                                                                                + "because the resource '%s' "
-                                                                                + "referenced in field '%s' is "
-                                                                                + "ambiguous",
-                                                                                primaryKeyValue
-                                                                                        .toString(),
-                                                                                path));
-                                                    } catch (final ErrorResultException e) {
-                                                        re = asResourceException(e);
-                                                    }
-                                                    exception.compareAndSet(null, re);
-                                                    completeIfNecessary();
-                                                }
-
-                                                @Override
-                                                public void handleResult(
-                                                        final SearchResultEntry result) {
-                                                    synchronized (newLDAPAttribute) {
-                                                        newLDAPAttribute.add(result.getName());
-                                                    }
-                                                    completeIfNecessary();
-                                                }
-                                            });
-                        }
-
-                        private void completeIfNecessary() {
-                            if (pendingSearches.decrementAndGet() == 0) {
-                                if (exception.get() == null) {
-                                    h.handleResult(newLDAPAttribute);
-                                } else {
-                                    h.handleError(exception.get());
+                                @Override
+                                public void handleResult(final SearchResultEntry result) {
+                                    synchronized (newLDAPAttribute) {
+                                        newLDAPAttribute.add(result.getName());
+                                    }
+                                    completeIfNecessary();
                                 }
-                            }
+                            });
+                }
+
+                private void completeIfNecessary() {
+                    if (pendingSearches.decrementAndGet() == 0) {
+                        if (exception.get() == null) {
+                            h.handleResult(newLDAPAttribute);
+                        } else {
+                            h.handleError(exception.get());
                         }
-                    });
+                    }
+                }
+            });
         }
     }
 
@@ -301,7 +289,7 @@
     }
 
     @Override
-    void toJSON(final Context c, final JsonPointer path, final Entry e,
+    void read(final Context c, final JsonPointer path, final Entry e,
             final ResultHandler<JsonValue> h) {
         final Attribute attribute = e.getAttribute(ldapAttributeName);
         if (attribute == null || attribute.isEmpty()) {
@@ -377,7 +365,7 @@
 
                     @Override
                     public void handleResult(final SearchResultEntry result) {
-                        mapper.toJSON(c, path, result, handler);
+                        mapper.read(c, path, result, handler);
                     }
                 });
     }
diff --git a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SimpleAttributeMapper.java b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SimpleAttributeMapper.java
index c7bc8c2..8ed9a7b 100644
--- a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SimpleAttributeMapper.java
+++ b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SimpleAttributeMapper.java
@@ -49,7 +49,6 @@
  */
 public final class SimpleAttributeMapper extends AbstractLDAPAttributeMapper<SimpleAttributeMapper> {
     private Function<ByteString, ?, Void> decoder = null;
-
     private Function<Object, ByteString, Void> encoder = null;
 
     SimpleAttributeMapper(final AttributeDescription ldapAttributeName) {
@@ -152,7 +151,7 @@
     }
 
     @Override
-    void toJSON(final Context c, final JsonPointer path, final Entry e,
+    void read(final Context c, final JsonPointer path, final Entry e,
             final ResultHandler<JsonValue> h) {
         try {
             final Object value;
diff --git a/opendj3/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java b/opendj3/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java
index 05d119c..0efe249 100644
--- a/opendj3/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java
+++ b/opendj3/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java
@@ -20,7 +20,10 @@
 import static org.fest.assertions.Fail.fail;
 import static org.forgerock.json.fluent.JsonValue.field;
 import static org.forgerock.json.fluent.JsonValue.object;
+import static org.forgerock.json.resource.PatchOperation.add;
+import static org.forgerock.json.resource.PatchOperation.remove;
 import static org.forgerock.json.resource.Requests.newDeleteRequest;
+import static org.forgerock.json.resource.Requests.newPatchRequest;
 import static org.forgerock.json.resource.Requests.newReadRequest;
 import static org.forgerock.json.resource.Requests.newUpdateRequest;
 import static org.forgerock.json.resource.Resources.newCollection;
@@ -103,6 +106,126 @@
     }
 
     @Test
+    public void testPatch() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        final Resource resource1 =
+                connection.patch(ctx(), newPatchRequest("/test1", add("displayName", "changed")));
+        checkResourcesAreEqual(resource1, getTestUser1Updated(12345));
+        final Resource resource2 = connection.read(ctx(), newReadRequest("/test1"));
+        checkResourcesAreEqual(resource2, getTestUser1Updated(12345));
+    }
+
+    @Test
+    public void testPatchAddOptionalAttribute() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        final JsonValue newContent = getTestUser1(12345);
+        newContent.put("description", asList("one", "two"));
+        final Resource resource1 =
+                connection.patch(ctx(), newPatchRequest("/test1", add("/description", asList("one",
+                        "two"))));
+        checkResourcesAreEqual(resource1, newContent);
+        final Resource resource2 = connection.read(ctx(), newReadRequest("/test1"));
+        checkResourcesAreEqual(resource2, newContent);
+    }
+
+    @Test(expectedExceptions = BadRequestException.class)
+    public void testPatchConstantAttribute() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        connection.patch(ctx(), newPatchRequest("/test1", add("/schemas", asList("junk"))));
+    }
+
+    @Test
+    public void testPatchDeleteOptionalAttribute() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        connection.patch(ctx(),
+                newPatchRequest("/test1", add("/description", asList("one", "two"))));
+        final Resource resource1 =
+                connection.patch(ctx(), newPatchRequest("/test1", remove("/description")));
+        checkResourcesAreEqual(resource1, getTestUser1(12345));
+        final Resource resource2 = connection.read(ctx(), newReadRequest("/test1"));
+        checkResourcesAreEqual(resource2, getTestUser1(12345));
+    }
+
+    @Test(expectedExceptions = BadRequestException.class)
+    public void testPatchMissingRequiredAttribute() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        connection.patch(ctx(), newPatchRequest("/test1", remove("/surname")));
+    }
+
+    @Test
+    public void testPatchModifyOptionalAttribute() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        connection.patch(ctx(),
+                newPatchRequest("/test1", add("/description", asList("one", "two"))));
+        final Resource resource1 =
+                connection.patch(ctx(), newPatchRequest("/test1", add("/description",
+                        asList("three"))));
+        final JsonValue newContent = getTestUser1(12345);
+        newContent.put("description", asList("one", "two", "three"));
+        checkResourcesAreEqual(resource1, newContent);
+        final Resource resource2 = connection.read(ctx(), newReadRequest("/test1"));
+        checkResourcesAreEqual(resource2, newContent);
+    }
+
+    @Test
+    public void testPatchMVCCMatch() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        final Resource resource1 =
+                connection.patch(ctx(), newPatchRequest("/test1", add("displayName", "changed"))
+                        .setRevision("12345"));
+        checkResourcesAreEqual(resource1, getTestUser1Updated(12345));
+        final Resource resource2 = connection.read(ctx(), newReadRequest("/test1"));
+        checkResourcesAreEqual(resource2, getTestUser1Updated(12345));
+    }
+
+    @Test(expectedExceptions = PreconditionFailedException.class)
+    public void testPatchMVCCNoMatch() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        connection.patch(ctx(), newPatchRequest("/test1", add("displayName", "changed"))
+                .setRevision("12346"));
+    }
+
+    @Test(expectedExceptions = NotFoundException.class)
+    public void testPatchNotFound() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        connection.patch(ctx(), newPatchRequest("/missing", add("displayName", "changed")));
+    }
+
+    @Test(expectedExceptions = BadRequestException.class)
+    public void testPatchReadOnlyAttribute() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        // Etag is read-only.
+        connection.patch(ctx(), newPatchRequest("/test1", add("_rev", "99999")));
+    }
+
+    @Test(expectedExceptions = BadRequestException.class)
+    public void testPatchSingleValuedAttributeWithMultipleValues() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        connection.patch(ctx(),
+                newPatchRequest("/test1", add("/surname", asList("black", "white"))));
+    }
+
+    @Test(expectedExceptions = BadRequestException.class)
+    public void testPatchUnknownAttribute() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        final JsonValue newContent = getTestUser1Updated(12345);
+        newContent.add("dummy", "junk");
+        connection.patch(ctx(), newPatchRequest("/test1", add("/dummy", "junk")));
+    }
+
+    @Test
     public void testRead() throws Exception {
         final RequestHandler handler = newCollection(builder().build());
         final Resource resource =

--
Gitblit v1.10.0