From 507e00fb190713b1654579123d284bcd3d750abe Mon Sep 17 00:00:00 2001
From: Matthew Swift <matthew.swift@forgerock.com>
Date: Wed, 10 Apr 2013 10:31:19 +0000
Subject: [PATCH] Partial fix for OPENDJ-693: Implement modify/update support

---
 opendj3/opendj-rest2ldap-servlet/src/main/webapp/opendj-rest2ldap-servlet.json                            |   26 
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthenticatedConnectionContext.java |    5 
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAP.java                      |   26 
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectAttributeMapper.java          |  105 ++-
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Config.java                         |   15 
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthzIdTemplate.java                |   19 
 opendj3/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java              |  134 ++++-
 opendj3/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/TestUtils.java                      |  143 +++++
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SimpleAttributeMapper.java          |  169 +-----
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AttributeMapper.java                |   12 
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JSONConstantAttributeMapper.java    |   19 
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/LDAPCollectionResourceProvider.java |  243 +++++++--
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractLDAPAttributeMapper.java    |  217 +++++++++
 opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferenceAttributeMapper.java       |  276 ++++-------
 14 files changed, 934 insertions(+), 475 deletions(-)

diff --git a/opendj3/opendj-rest2ldap-servlet/src/main/webapp/opendj-rest2ldap-servlet.json b/opendj3/opendj-rest2ldap-servlet/src/main/webapp/opendj-rest2ldap-servlet.json
index 422fd95..faf424b 100644
--- a/opendj3/opendj-rest2ldap-servlet/src/main/webapp/opendj-rest2ldap-servlet.json
+++ b/opendj3/opendj-rest2ldap-servlet/src/main/webapp/opendj-rest2ldap-servlet.json
@@ -134,12 +134,13 @@
         // The REST APIs and their LDAP attribute mappings.
         "mappings" : {
             "/users" : {
-                "baseDN" : "ou=people,dc=example,dc=com",
-                "readOnUpdatePolicy" : "controls",
-                "useSubtreeDelete" : true,
-                "etagAttribute" : "etag",
-                "namingStrategy" : {
-                    "strategy" : "clientDNNaming",
+                "baseDN"              : "ou=people,dc=example,dc=com",
+                "readOnUpdatePolicy"  : "controls",
+                "useSubtreeDelete"    : true,
+                "usePermissiveModify" : true,
+                "etagAttribute"       : "etag",
+                "namingStrategy"      : {
+                    "strategy"    : "clientDNNaming",
                     "dnAttribute" : "uid"
                 },
                 "additionalLDAPAttributes" : [
@@ -192,12 +193,13 @@
                 }
             },
             "/groups" : {
-                "baseDN" : "ou=groups,dc=example,dc=com",
-                "readOnUpdatePolicy" : "controls",
-                "useSubtreeDelete" : true,
-                "etagAttribute" : "etag",
-                "namingStrategy" : {
-                    "strategy" : "clientDNNaming",
+                "baseDN"              : "ou=groups,dc=example,dc=com",
+                "readOnUpdatePolicy"  : "controls",
+                "useSubtreeDelete"    : true,
+                "usePermissiveModify" : true,
+                "etagAttribute"       : "etag",
+                "namingStrategy"      : {
+                    "strategy"    : "clientDNNaming",
                     "dnAttribute" : "cn"
                 },
                 "additionalLDAPAttributes" : [
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
new file mode 100644
index 0000000..0a5398f
--- /dev/null
+++ b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractLDAPAttributeMapper.java
@@ -0,0 +1,217 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions Copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2013 ForgeRock AS.
+ */
+package org.forgerock.opendj.rest2ldap;
+
+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.Utils.i18n;
+import static org.forgerock.opendj.rest2ldap.WritabilityPolicy.READ_WRITE;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import org.forgerock.json.fluent.JsonPointer;
+import org.forgerock.json.fluent.JsonValue;
+import org.forgerock.json.resource.BadRequestException;
+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.Entry;
+import org.forgerock.opendj.ldap.LinkedAttribute;
+import org.forgerock.opendj.ldap.Modification;
+import org.forgerock.opendj.ldap.ModificationType;
+
+/**
+ * An abstract LDAP attribute mapper which provides a simple mapping from a JSON
+ * value to a single LDAP attribute.
+ */
+abstract class AbstractLDAPAttributeMapper<T extends AbstractLDAPAttributeMapper<T>> extends
+        AttributeMapper {
+    Object defaultJSONValue = null;
+    List<Object> defaultJSONValues = emptyList();
+    final AttributeDescription ldapAttributeName;
+    private boolean isRequired = false;
+    private boolean isSingleValued = false;
+    private WritabilityPolicy writabilityPolicy = READ_WRITE;
+
+    AbstractLDAPAttributeMapper(final AttributeDescription ldapAttributeName) {
+        this.ldapAttributeName = ldapAttributeName;
+    }
+
+    /**
+     * Indicates that the LDAP attribute is mandatory and must be provided
+     * during create requests.
+     *
+     * @return This attribute mapper.
+     */
+    public final T isRequired() {
+        this.isRequired = true;
+        return getThis();
+    }
+
+    /**
+     * Indicates that multi-valued LDAP attribute should be represented as a
+     * single-valued JSON value, rather than an array of values.
+     *
+     * @return This attribute mapper.
+     */
+    public final T isSingleValued() {
+        this.isSingleValued = true;
+        return getThis();
+    }
+
+    /**
+     * Indicates whether or not the LDAP attribute supports updates. The default
+     * is {@link WritabilityPolicy#READ_WRITE}.
+     *
+     * @param policy
+     *            The writability policy.
+     * @return This attribute mapper.
+     */
+    public final T writability(final WritabilityPolicy policy) {
+        this.writabilityPolicy = policy;
+        return getThis();
+    }
+
+    boolean attributeIsRequired() {
+        return isRequired && defaultJSONValue == null;
+    }
+
+    boolean attributeIsSingleValued() {
+        return isSingleValued || ldapAttributeName.getAttributeType().isSingleValue();
+    }
+
+    @Override
+    void getLDAPAttributes(final Context c, final JsonPointer path, final JsonPointer subPath,
+            final Set<String> ldapAttributes) {
+        ldapAttributes.add(ldapAttributeName.toString());
+    }
+
+    abstract void getNewLDAPAttributes(Context c, JsonPointer path, List<Object> newValues,
+            ResultHandler<Attribute> h);
+
+    abstract T getThis();
+
+    @Override
+    void toLDAP(final Context c, final JsonPointer path, final Entry e, final JsonValue v,
+            final ResultHandler<List<Modification>> h) {
+        // Make following code readable.
+        final boolean isUpdateRequest = e != null;
+        final boolean isCreateRequest = !isUpdateRequest;
+
+        // 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 {
+            getNewLDAPAttributes(c, path, asList(v), 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() || newLDAPAttribute.isEmpty()) {
+                            // Delete or add.
+                            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);
+                    }
+                }
+            });
+        }
+    }
+
+    private List<Object> asList(final JsonValue v) {
+        if (v == null || v.isNull() || (v.isList() && v.size() == 0)) {
+            return defaultJSONValues;
+        } else if (v.isList()) {
+            return v.asList();
+        } else {
+            return singletonList(v.getObject());
+        }
+    }
+
+}
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 70f699a..02cc5d6 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
@@ -21,9 +21,9 @@
 import org.forgerock.json.fluent.JsonPointer;
 import org.forgerock.json.fluent.JsonValue;
 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;
 
 /**
  * An attribute mapper is responsible for converting JSON values to and from
@@ -56,7 +56,8 @@
      *            The set into which the required LDAP attribute names should be
      *            put.
      */
-    abstract void getLDAPAttributes(Context c, JsonPointer jsonAttribute, Set<String> ldapAttributes);
+    abstract void getLDAPAttributes(Context c, JsonPointer path, JsonPointer subPath,
+            Set<String> ldapAttributes);
 
     /**
      * Transforms the provided REST comparison filter parameters to an LDAP
@@ -86,7 +87,7 @@
      * @param h
      *            The result handler.
      */
-    abstract void getLDAPFilter(Context c, FilterType type, JsonPointer jsonAttribute,
+    abstract void getLDAPFilter(Context c, JsonPointer path, JsonPointer subPath, FilterType type,
             String operator, Object valueAssertion, ResultHandler<Filter> h);
 
     /**
@@ -113,7 +114,7 @@
      * @param h
      *            The result handler.
      */
-    abstract void toJSON(Context c, Entry e, ResultHandler<JsonValue> h);
+    abstract void toJSON(Context c, JsonPointer path, Entry e, ResultHandler<JsonValue> h);
 
     /**
      * Maps a JSON value to one or more LDAP attributes, invoking a completion
@@ -139,7 +140,8 @@
      * @param h
      *            The result handler.
      */
-    abstract void toLDAP(Context c, JsonValue v, ResultHandler<List<Attribute>> h);
+    abstract void toLDAP(Context c, JsonPointer path, Entry e, JsonValue v,
+            ResultHandler<List<Modification>> h);
 
     // TODO: methods for obtaining schema information (e.g. name, description,
     // type information).
diff --git a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthenticatedConnectionContext.java b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthenticatedConnectionContext.java
index 78b8d96..84e67c6 100644
--- a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthenticatedConnectionContext.java
+++ b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthenticatedConnectionContext.java
@@ -16,6 +16,7 @@
 package org.forgerock.opendj.rest2ldap;
 
 import static org.forgerock.opendj.rest2ldap.Utils.ensureNotNull;
+import static org.forgerock.opendj.rest2ldap.Utils.i18n;
 
 import org.forgerock.json.fluent.JsonValue;
 import org.forgerock.json.resource.Context;
@@ -88,7 +89,7 @@
     AuthenticatedConnectionContext(final JsonValue savedContext, final PersistenceConfig config)
             throws ResourceException {
         super(savedContext, config);
-        throw new InternalServerErrorException("Cached LDAP connections cannot be restored");
+        throw new InternalServerErrorException(i18n("Cached LDAP connections cannot be restored"));
     }
 
     /**
@@ -98,7 +99,7 @@
     protected void saveToJson(final JsonValue savedContext, final PersistenceConfig config)
             throws ResourceException {
         super.saveToJson(savedContext, config);
-        throw new InternalServerErrorException("Cached LDAP connections cannot be persisted");
+        throw new InternalServerErrorException(i18n("Cached LDAP connections cannot be persisted"));
     }
 
     /**
diff --git a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthzIdTemplate.java b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthzIdTemplate.java
index 50e5472..680797e 100644
--- a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthzIdTemplate.java
+++ b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AuthzIdTemplate.java
@@ -16,6 +16,7 @@
 
 package org.forgerock.opendj.rest2ldap;
 
+import static org.forgerock.opendj.rest2ldap.Utils.i18n;
 import static org.forgerock.opendj.rest2ldap.Utils.isJSONPrimitive;
 
 import java.util.ArrayList;
@@ -53,8 +54,8 @@
                 DN.valueOf(authzId.substring(3), schema);
             } catch (final IllegalArgumentException e) {
                 throw new ForbiddenException(
-                        "The request could not be authorized because the required security principal "
-                                + " was not a valid LDAP DN");
+                        i18n("The request could not be authorized because the required "
+                                + "security principal was not a valid LDAP DN"));
             }
             return authzId;
         }
@@ -135,15 +136,13 @@
             if (isJSONPrimitive(value)) {
                 values[i] = String.valueOf(value);
             } else if (value == null) {
-                // FIXME: i18n.
-                throw new ForbiddenException(
-                        "The request could not be authorized because the required security principal "
-                                + key + " could not be determined");
+                throw new ForbiddenException(i18n(
+                        "The request could not be authorized because the required "
+                                + "security principal '%s' could not be determined", key));
             } else {
-                // FIXME: i18n.
-                throw new ForbiddenException(
-                        "The request could not be authorized because the required security principal "
-                                + key + " had an invalid data type");
+                throw new ForbiddenException(i18n(
+                        "The request could not be authorized because the required "
+                                + "security principal '%s' had an invalid data type", key));
             }
         }
         return values;
diff --git a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Config.java b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Config.java
index cca57bd..101e53d 100644
--- a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Config.java
+++ b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Config.java
@@ -30,15 +30,17 @@
     private final ReadOnUpdatePolicy readOnUpdatePolicy;
     private final Schema schema;
     private final boolean useSubtreeDelete;
+    private final boolean usePermissiveModify;
 
     Config(final ConnectionFactory factory, final ReadOnUpdatePolicy readOnUpdatePolicy,
             final AuthorizationPolicy authzPolicy, final AuthzIdTemplate proxiedAuthzTemplate,
-            final boolean useSubtreeDelete, final Schema schema) {
+            final boolean useSubtreeDelete, final boolean usePermissiveModify, final Schema schema) {
         this.factory = factory;
         this.readOnUpdatePolicy = readOnUpdatePolicy;
         this.authzPolicy = authzPolicy;
         this.proxiedAuthzTemplate = proxiedAuthzTemplate;
         this.useSubtreeDelete = useSubtreeDelete;
+        this.usePermissiveModify = usePermissiveModify;
         this.schema = schema;
         this.options = new DecodeOptions().setSchema(schema);
     }
@@ -88,6 +90,17 @@
     }
 
     /**
+     * Returns {@code true} if modify requests should include the permissive
+     * modify control.
+     *
+     * @return {@code true} if modify requests should include the permissive
+     *         modify control.
+     */
+    boolean usePermissiveModify() {
+        return usePermissiveModify;
+    }
+
+    /**
      * Returns {@code true} if delete requests should include the subtree delete
      * control.
      *
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 85c706a..e87bf66 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
@@ -27,9 +27,9 @@
 import org.forgerock.json.fluent.JsonPointer;
 import org.forgerock.json.fluent.JsonValue;
 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;
 
 /**
  * An attribute mapper which maps a single JSON attribute to a fixed value.
@@ -42,16 +42,17 @@
     }
 
     @Override
-    void getLDAPAttributes(final Context c, final JsonPointer jsonAttribute,
+    void getLDAPAttributes(final Context c, final JsonPointer path, final JsonPointer subPath,
             final Set<String> ldapAttributes) {
         // Nothing to do.
     }
 
     @Override
-    void getLDAPFilter(final Context c, final FilterType type, final JsonPointer jsonAttribute,
-            final String operator, final Object valueAssertion, final ResultHandler<Filter> h) {
+    void getLDAPFilter(final Context c, final JsonPointer path, final JsonPointer subPath,
+            final FilterType type, final String operator, final Object valueAssertion,
+            final ResultHandler<Filter> h) {
         final Filter filter;
-        final JsonValue subValue = value.get(jsonAttribute);
+        final JsonValue subValue = value.get(subPath);
         if (subValue == null) {
             filter = alwaysFalse();
         } else if (type == FilterType.PRESENT) {
@@ -86,14 +87,16 @@
     }
 
     @Override
-    void toJSON(final Context c, final Entry e, final ResultHandler<JsonValue> h) {
+    void toJSON(final Context c, final JsonPointer path, final Entry e,
+            final ResultHandler<JsonValue> h) {
         h.handleResult(value.copy());
     }
 
     @Override
-    void toLDAP(final Context c, final JsonValue v, final ResultHandler<List<Attribute>> h) {
+    void toLDAP(final Context c, final JsonPointer path, final Entry e, final JsonValue v,
+            final ResultHandler<List<Modification>> h) {
         // FIXME: should we check if the provided value matches the constant?
-        h.handleResult(Collections.<Attribute> 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 a3c6824..83f0215 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
@@ -17,9 +17,14 @@
 
 import static org.forgerock.opendj.ldap.Filter.alwaysFalse;
 import static org.forgerock.opendj.ldap.Filter.alwaysTrue;
+import static org.forgerock.opendj.ldap.requests.Requests.newAddRequest;
+import static org.forgerock.opendj.ldap.requests.Requests.newDeleteRequest;
+import static org.forgerock.opendj.ldap.requests.Requests.newModifyRequest;
+import static org.forgerock.opendj.ldap.requests.Requests.newSearchRequest;
 import static org.forgerock.opendj.rest2ldap.ReadOnUpdatePolicy.CONTROLS;
 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.toFilter;
 import static org.forgerock.opendj.rest2ldap.Utils.transform;
 
@@ -39,8 +44,10 @@
 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.PatchRequest;
+import org.forgerock.json.resource.PreconditionFailedException;
 import org.forgerock.json.resource.QueryFilter;
 import org.forgerock.json.resource.QueryFilterVisitor;
 import org.forgerock.json.resource.QueryRequest;
@@ -61,16 +68,19 @@
 import org.forgerock.opendj.ldap.ErrorResultException;
 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;
+import org.forgerock.opendj.ldap.controls.PermissiveModifyRequestControl;
 import org.forgerock.opendj.ldap.controls.PostReadRequestControl;
 import org.forgerock.opendj.ldap.controls.PostReadResponseControl;
 import org.forgerock.opendj.ldap.controls.PreReadRequestControl;
 import org.forgerock.opendj.ldap.controls.PreReadResponseControl;
 import org.forgerock.opendj.ldap.controls.SubtreeDeleteRequestControl;
 import org.forgerock.opendj.ldap.requests.AddRequest;
-import org.forgerock.opendj.ldap.requests.Requests;
+import org.forgerock.opendj.ldap.requests.ModifyRequest;
 import org.forgerock.opendj.ldap.requests.SearchRequest;
 import org.forgerock.opendj.ldap.responses.Result;
 import org.forgerock.opendj.ldap.responses.SearchResultEntry;
@@ -126,22 +136,31 @@
             @Override
             public void run() {
                 // Calculate entry content.
-                attributeMapper.toLDAP(c, request.getContent(),
-                        new ResultHandler<List<Attribute>>() {
+                attributeMapper.toLDAP(c, new JsonPointer(), null, request.getContent(),
+                        new ResultHandler<List<Modification>>() {
                             @Override
                             public void handleError(final ResourceException error) {
                                 h.handleError(error);
                             }
 
                             @Override
-                            public void handleResult(final List<Attribute> result) {
+                            public void handleResult(final List<Modification> result) {
                                 // Perform add operation.
-                                final AddRequest addRequest = Requests.newAddRequest(DN.rootDN());
+                                final AddRequest addRequest = newAddRequest(DN.rootDN());
                                 for (final Attribute attribute : additionalLDAPAttributes) {
                                     addRequest.addAttribute(attribute);
                                 }
-                                for (final Attribute attribute : result) {
-                                    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;
+                                    }
                                 }
                                 try {
                                     nameStrategy.setResourceId(c, getBaseDN(c), request
@@ -175,9 +194,12 @@
             @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(
-                                "1.1");
+                                ldapAttribute);
                 c.getConnection().searchSingleEntryAsync(searchRequest,
                         new org.forgerock.opendj.ldap.ResultHandler<SearchResultEntry>() {
                             @Override
@@ -188,9 +210,12 @@
                             @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 =
-                                            Requests.newDeleteRequest(entry.getName());
+                                            newDeleteRequest(entry.getName());
                                     if (config.readOnUpdatePolicy() == CONTROLS) {
                                         final String[] attributes =
                                                 getLDAPAttributes(c, request.getFields());
@@ -225,7 +250,10 @@
         final Context c = wrap(context);
         final QueryResultHandler h = wrap(c, handler);
 
-        // Get the connection, then calculate the search filter, then perform the search.
+        /*
+         * Get the connection, then calculate the search filter, then perform
+         * the search.
+         */
         c.run(h, new Runnable() {
             @Override
             public void run() {
@@ -238,17 +266,19 @@
 
                     @Override
                     public void handleResult(final Filter ldapFilter) {
-                        // Avoid performing a search if the filter could not be mapped or if it will never match.
+                        /*
+                         * Avoid performing a search if the filter could not be
+                         * mapped or if it will never match.
+                         */
                         if (ldapFilter == null || ldapFilter == alwaysFalse()) {
                             h.handleResult(new QueryResult());
                         } else {
                             // Perform the search.
                             final String[] attributes = getLDAPAttributes(c, request.getFields());
                             final SearchRequest request =
-                                    Requests.newSearchRequest(getBaseDN(c),
-                                            SearchScope.SINGLE_LEVEL, ldapFilter == Filter
-                                                    .alwaysTrue() ? Filter.objectClassPresent()
-                                                    : ldapFilter, attributes);
+                                    newSearchRequest(getBaseDN(c), SearchScope.SINGLE_LEVEL,
+                                            ldapFilter == Filter.alwaysTrue() ? Filter
+                                                    .objectClassPresent() : ldapFilter, attributes);
                             c.getConnection().searchAsync(request, null, new SearchResultHandler() {
                                 private final AtomicInteger pendingResourceCount =
                                         new AtomicInteger();
@@ -291,7 +321,7 @@
                                             };
 
                                     pendingResourceCount.incrementAndGet();
-                                    attributeMapper.toJSON(c, entry, mapHandler);
+                                    attributeMapper.toJSON(c, new JsonPointer(), entry, mapHandler);
                                     return true;
                                 }
 
@@ -373,31 +403,132 @@
     @Override
     public void updateInstance(final ServerContext context, final String resourceId,
             final UpdateRequest request, final ResultHandler<Resource> handler) {
-        handler.handleError(new NotSupportedException("Not yet implemented"));
+        /*
+         * Update operations are a bit awkward because there is not 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
+         * obtain a set of changes. We also need to handle read-only fields
+         * correctly: if a read-only field is included with the new resource
+         * then it must match exactly the value of the existing field.
+         */
+        final Context c = wrap(context);
+        final ResultHandler<Resource> h = wrap(c, handler);
+
+        // Get connection then perform the search.
+        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 =
+                        nameStrategy.createSearchRequest(c, getBaseDN(c), resourceId).addAttribute(
+                                attributes);
+                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());
+
+                                    //  Create the modify request.
+                                    final ModifyRequest modifyRequest =
+                                            newModifyRequest(entry.getName());
+                                    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());
+
+                                    /*
+                                     * Determine the set of changes that need to
+                                     * be performed.
+                                     */
+                                    attributeMapper.toLDAP(c, new JsonPointer(), entry, request
+                                            .getNewContent(),
+                                            new ResultHandler<List<Modification>>() {
+                                                @Override
+                                                public void handleError(
+                                                        final ResourceException error) {
+                                                    h.handleError(error);
+                                                }
+
+                                                @Override
+                                                public void handleResult(
+                                                        final List<Modification> result) {
+                                                    // Perform the modify operation.
+                                                    modifyRequest.getModifications().addAll(result);
+                                                    c.getConnection().applyChangeAsync(
+                                                            modifyRequest, null,
+                                                            postUpdateHandler(c, h));
+                                                }
+                                            });
+                                } catch (final Exception e) {
+                                    h.handleError(asResourceException(e));
+                                }
+                            }
+                        });
+            }
+        });
     }
 
     private void adaptEntry(final Context c, final Entry entry,
             final ResultHandler<Resource> handler) {
         final String actualResourceId = nameStrategy.getResourceId(c, entry);
         final String revision = getRevisionFromEntry(entry);
-        attributeMapper.toJSON(c, entry, transform(new Function<JsonValue, Resource, Void>() {
-            @Override
-            public Resource apply(final JsonValue value, final Void p) {
-                return new Resource(actualResourceId, revision, new JsonValue(value));
-            }
-        }, handler));
+        attributeMapper.toJSON(c, new JsonPointer(), entry, transform(
+                new Function<JsonValue, Resource, Void>() {
+                    @Override
+                    public Resource apply(final JsonValue value, final Void p) {
+                        return new Resource(actualResourceId, revision, new JsonValue(value));
+                    }
+                }, handler));
     }
 
-    private void addAssertionControl(final ChangeRecord request, final String revision)
-            throws NotSupportedException {
-        if (revision != null) {
-            if (etagAttribute != null) {
-                request.addControl(AssertionRequestControl.newControl(true, Filter.equality(
-                        etagAttribute.toString(), revision)));
-            } else {
-                // FIXME: i18n
-                throw new NotSupportedException(
-                        "Multi-version concurrency control is not supported by this resource");
+    private void addAssertionControl(final ChangeRecord request, final String expectedRevision)
+            throws ResourceException {
+        if (expectedRevision != null) {
+            ensureMVCCSupported();
+            request.addControl(AssertionRequestControl.newControl(true, Filter.equality(
+                    etagAttribute.toString(), expectedRevision)));
+        }
+    }
+
+    private void ensureMVCCSupported() throws NotSupportedException {
+        if (etagAttribute == null) {
+            throw new NotSupportedException(
+                    i18n("Multi-version concurrency control is not supported by this resource"));
+        }
+    }
+
+    private void ensureMVCCVersionMatches(final Entry entry, final String expectedRevision)
+            throws ResourceException {
+        if (expectedRevision != null) {
+            ensureMVCCSupported();
+            final String actualRevision = entry.parseAttribute(etagAttribute).asString();
+            if (actualRevision == null) {
+                throw new PreconditionFailedException(i18n(
+                        "The resource could not be accessed because it did not contain any "
+                                + "version information, when the version '%s' was expected",
+                        expectedRevision));
+            } else if (!expectedRevision.equals(actualRevision)) {
+                throw new PreconditionFailedException(i18n(
+                        "The resource could not be accessed because the expected version '%s' "
+                                + "does not match the current version '%s'", expectedRevision,
+                        actualRevision));
             }
         }
     }
@@ -422,12 +553,14 @@
         if (requestedAttributes.isEmpty()) {
             // Full read.
             requestedLDAPAttributes = new LinkedHashSet<String>();
-            attributeMapper.getLDAPAttributes(c, new JsonPointer(), requestedLDAPAttributes);
+            attributeMapper.getLDAPAttributes(c, new JsonPointer(), new JsonPointer(),
+                    requestedLDAPAttributes);
         } else {
             // Partial read.
             requestedLDAPAttributes = new LinkedHashSet<String>(requestedAttributes.size());
             for (final JsonPointer requestedAttribute : requestedAttributes) {
-                attributeMapper.getLDAPAttributes(c, requestedAttribute, requestedLDAPAttributes);
+                attributeMapper.getLDAPAttributes(c, new JsonPointer(), requestedAttribute,
+                        requestedLDAPAttributes);
             }
         }
 
@@ -488,16 +621,16 @@
                     @Override
                     public Void visitContainsFilter(final ResultHandler<Filter> p,
                             final JsonPointer field, final Object valueAssertion) {
-                        attributeMapper.getLDAPFilter(c, FilterType.CONTAINS, field, null,
-                                valueAssertion, p);
+                        attributeMapper.getLDAPFilter(c, new JsonPointer(), field,
+                                FilterType.CONTAINS, null, valueAssertion, p);
                         return null;
                     }
 
                     @Override
                     public Void visitEqualsFilter(final ResultHandler<Filter> p,
                             final JsonPointer field, final Object valueAssertion) {
-                        attributeMapper.getLDAPFilter(c, FilterType.EQUAL_TO, field, null,
-                                valueAssertion, p);
+                        attributeMapper.getLDAPFilter(c, new JsonPointer(), field,
+                                FilterType.EQUAL_TO, null, valueAssertion, p);
                         return null;
                     }
 
@@ -505,40 +638,40 @@
                     public Void visitExtendedMatchFilter(final ResultHandler<Filter> p,
                             final JsonPointer field, final String operator,
                             final Object valueAssertion) {
-                        attributeMapper.getLDAPFilter(c, FilterType.EXTENDED, field, operator,
-                                valueAssertion, p);
+                        attributeMapper.getLDAPFilter(c, new JsonPointer(), field,
+                                FilterType.EXTENDED, operator, valueAssertion, p);
                         return null;
                     }
 
                     @Override
                     public Void visitGreaterThanFilter(final ResultHandler<Filter> p,
                             final JsonPointer field, final Object valueAssertion) {
-                        attributeMapper.getLDAPFilter(c, FilterType.GREATER_THAN, field, null,
-                                valueAssertion, p);
+                        attributeMapper.getLDAPFilter(c, new JsonPointer(), field,
+                                FilterType.GREATER_THAN, null, valueAssertion, p);
                         return null;
                     }
 
                     @Override
                     public Void visitGreaterThanOrEqualToFilter(final ResultHandler<Filter> p,
                             final JsonPointer field, final Object valueAssertion) {
-                        attributeMapper.getLDAPFilter(c, FilterType.GREATER_THAN_OR_EQUAL_TO,
-                                field, null, valueAssertion, p);
+                        attributeMapper.getLDAPFilter(c, new JsonPointer(), field,
+                                FilterType.GREATER_THAN_OR_EQUAL_TO, null, valueAssertion, p);
                         return null;
                     }
 
                     @Override
                     public Void visitLessThanFilter(final ResultHandler<Filter> p,
                             final JsonPointer field, final Object valueAssertion) {
-                        attributeMapper.getLDAPFilter(c, FilterType.LESS_THAN, field, null,
-                                valueAssertion, p);
+                        attributeMapper.getLDAPFilter(c, new JsonPointer(), field,
+                                FilterType.LESS_THAN, null, valueAssertion, p);
                         return null;
                     }
 
                     @Override
                     public Void visitLessThanOrEqualToFilter(final ResultHandler<Filter> p,
                             final JsonPointer field, final Object valueAssertion) {
-                        attributeMapper.getLDAPFilter(c, FilterType.LESS_THAN_OR_EQUAL_TO, field,
-                                null, valueAssertion, p);
+                        attributeMapper.getLDAPFilter(c, new JsonPointer(), field,
+                                FilterType.LESS_THAN_OR_EQUAL_TO, null, valueAssertion, p);
                         return null;
                     }
 
@@ -598,20 +731,24 @@
                     @Override
                     public Void visitPresentFilter(final ResultHandler<Filter> p,
                             final JsonPointer field) {
-                        attributeMapper.getLDAPFilter(c, FilterType.PRESENT, field, null, null, p);
+                        attributeMapper.getLDAPFilter(c, new JsonPointer(), field,
+                                FilterType.PRESENT, null, null, p);
                         return null;
                     }
 
                     @Override
                     public Void visitStartsWithFilter(final ResultHandler<Filter> p,
                             final JsonPointer field, final Object valueAssertion) {
-                        attributeMapper.getLDAPFilter(c, FilterType.STARTS_WITH, field, null,
-                                valueAssertion, p);
+                        attributeMapper.getLDAPFilter(c, new JsonPointer(), field,
+                                FilterType.STARTS_WITH, null, valueAssertion, p);
                         return null;
                     }
 
                 };
-        // Note that the returned LDAP filter may be null if it could not be mapped by any attribute mappers.
+        /*
+         * Note that the returned LDAP filter may be null if it could not be
+         * mapped by any attribute mappers.
+         */
         queryFilter.accept(visitor, h);
     }
 
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 0251974..d4ba83f 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
@@ -17,6 +17,7 @@
 
 import static org.forgerock.opendj.ldap.Filter.alwaysFalse;
 import static org.forgerock.opendj.rest2ldap.Utils.accumulate;
+import static org.forgerock.opendj.rest2ldap.Utils.i18n;
 import static org.forgerock.opendj.rest2ldap.Utils.toLowerCase;
 import static org.forgerock.opendj.rest2ldap.Utils.transform;
 
@@ -32,10 +33,10 @@
 import org.forgerock.json.fluent.JsonValue;
 import org.forgerock.json.resource.BadRequestException;
 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;
+import org.forgerock.opendj.ldap.Modification;
 
 /**
  * An attribute mapper which maps JSON objects to LDAP attributes.
@@ -50,6 +51,11 @@
             this.name = name;
             this.mapper = mapper;
         }
+
+        @Override
+        public String toString() {
+            return name + " -> " + mapper;
+        }
     }
 
     private final Map<String, Mapping> mappings = new LinkedHashMap<String, Mapping>();
@@ -74,43 +80,50 @@
     }
 
     @Override
-    void getLDAPAttributes(final Context c, final JsonPointer jsonAttribute,
+    void getLDAPAttributes(final Context c, final JsonPointer path, final JsonPointer subPath,
             final Set<String> ldapAttributes) {
-        if (jsonAttribute.isEmpty()) {
+        if (subPath.isEmpty()) {
             // Request all subordinate mappings.
             for (final Mapping mapping : mappings.values()) {
-                mapping.mapper.getLDAPAttributes(c, jsonAttribute, ldapAttributes);
+                mapping.mapper.getLDAPAttributes(c, path.child(mapping.name), subPath,
+                        ldapAttributes);
             }
         } else {
             // Request single subordinate mapping.
-            final Mapping mapping = getMapping(jsonAttribute);
+            final Mapping mapping = getMapping(subPath);
             if (mapping != null) {
-                final JsonPointer relativePointer = jsonAttribute.relativePointer();
-                mapping.mapper.getLDAPAttributes(c, relativePointer, ldapAttributes);
+                mapping.mapper.getLDAPAttributes(c, path.child(subPath.get(0)), subPath
+                        .relativePointer(), ldapAttributes);
             }
         }
     }
 
     @Override
-    void getLDAPFilter(final Context c, final FilterType type, final JsonPointer jsonAttribute,
-            final String operator, final Object valueAssertion, final ResultHandler<Filter> h) {
-        final Mapping mapping = getMapping(jsonAttribute);
+    void getLDAPFilter(final Context c, final JsonPointer path, final JsonPointer subPath,
+            final FilterType type, final String operator, final Object valueAssertion,
+            final ResultHandler<Filter> h) {
+        final Mapping mapping = getMapping(subPath);
         if (mapping != null) {
-            final JsonPointer relativePointer = jsonAttribute.relativePointer();
-            mapping.mapper.getLDAPFilter(c, type, relativePointer, operator, valueAssertion, h);
+            mapping.mapper.getLDAPFilter(c, path.child(subPath.get(0)), subPath.relativePointer(),
+                    type, operator, valueAssertion, h);
         } 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.
+            /*
+             * 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.
+             */
             h.handleResult(alwaysFalse());
         }
     }
 
     @Override
-    void toJSON(final Context c, final Entry e, final ResultHandler<JsonValue> h) {
-        // 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.
+    void toJSON(final Context c, final JsonPointer path, final Entry e,
+            final ResultHandler<JsonValue> h) {
+        /*
+         * 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 ResultHandler<Map.Entry<String, JsonValue>> handler =
                 accumulate(mappings.size(), transform(
                         new Function<List<Map.Entry<String, JsonValue>>, JsonValue, Void>() {
@@ -118,11 +131,16 @@
                             public JsonValue apply(final List<Map.Entry<String, JsonValue>> value,
                                     final Void p) {
                                 if (value.isEmpty()) {
-                                    // No subordinate attributes, so omit the entire JSON object
-                                    // from the resource.
+                                    /*
+                                     * No subordinate attributes, so omit the
+                                     * entire JSON object from the resource.
+                                     */
                                     return null;
                                 } else {
-                                    // Combine the sub-attributes into a single JSON object.
+                                    /*
+                                     * Combine the sub-attributes into a single
+                                     * JSON object.
+                                     */
                                     final Map<String, Object> result =
                                             new LinkedHashMap<String, Object>(value.size());
                                     for (final Map.Entry<String, JsonValue> e : value) {
@@ -134,7 +152,7 @@
                         }, h));
 
         for (final Mapping mapping : mappings.values()) {
-            mapping.mapper.toJSON(c, e, transform(
+            mapping.mapper.toJSON(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,
@@ -147,30 +165,39 @@
     }
 
     @Override
-    void toLDAP(final Context c, final JsonValue v, final ResultHandler<List<Attribute>> h) {
-        // Fail immediately if the JSON value has the wrong type or contains unknown attributes.
+    void toLDAP(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.
+         */
         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("unrecognized attribute '"
-                                + attribute + "'"));
+                        h.handleError(new BadRequestException(i18n(
+                                "The request cannot be processed because the JSON resource "
+                                        + "contains an unrecognized field '%s'", path
+                                        .child(attribute))));
                         return;
                     }
                 }
             } else {
-                h.handleError(new BadRequestException("JSON object expected"));
+                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;
             }
         }
 
         // Accumulate the results of the subordinate mappings.
-        final ResultHandler<List<Attribute>> handler =
+        final ResultHandler<List<Modification>> handler =
                 accumulate(mappings.size(), transform(
-                        new Function<List<List<Attribute>>, List<Attribute>, Void>() {
+                        new Function<List<List<Modification>>, List<Modification>, Void>() {
                             @Override
-                            public List<Attribute> apply(final List<List<Attribute>> value,
+                            public List<Modification> apply(final List<List<Modification>> value,
                                     final Void p) {
                                 switch (value.size()) {
                                 case 0:
@@ -178,9 +205,9 @@
                                 case 1:
                                     return value.get(0);
                                 default:
-                                    final List<Attribute> attributes =
-                                            new ArrayList<Attribute>(value.size());
-                                    for (final List<Attribute> a : value) {
+                                    final List<Modification> attributes =
+                                            new ArrayList<Modification>(value.size());
+                                    for (final List<Modification> a : value) {
                                         attributes.addAll(a);
                                     }
                                     return attributes;
@@ -190,16 +217,16 @@
 
         // Invoke mappings for which there are values provided.
         if (v != null && !v.isNull()) {
-            for (final Map.Entry<String, Object> e : v.asMap().entrySet()) {
-                final Mapping mapping = getMapping(e.getKey());
-                final JsonValue subValue = new JsonValue(e.getValue());
-                mapping.mapper.toLDAP(c, subValue, handler);
+            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, null, handler);
+            mapping.mapper.toLDAP(c, path.child(mapping.name), e, null, handler);
         }
     }
 
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 51b3a09..3ac297f 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
@@ -15,16 +15,15 @@
  */
 package org.forgerock.opendj.rest2ldap;
 
-import static java.util.Collections.singletonList;
 import static org.forgerock.opendj.ldap.ErrorResultException.newErrorResult;
+import static org.forgerock.opendj.ldap.requests.Requests.newSearchRequest;
 import static org.forgerock.opendj.rest2ldap.Rest2LDAP.asResourceException;
 import static org.forgerock.opendj.rest2ldap.Utils.accumulate;
 import static org.forgerock.opendj.rest2ldap.Utils.ensureNotNull;
+import static org.forgerock.opendj.rest2ldap.Utils.i18n;
 import static org.forgerock.opendj.rest2ldap.Utils.transform;
-import static org.forgerock.opendj.rest2ldap.WritabilityPolicy.READ_WRITE;
 
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.LinkedHashSet;
 import java.util.LinkedList;
 import java.util.List;
@@ -39,6 +38,7 @@
 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.ByteString;
 import org.forgerock.opendj.ldap.DN;
 import org.forgerock.opendj.ldap.Entry;
@@ -47,11 +47,11 @@
 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;
 import org.forgerock.opendj.ldap.SearchScope;
-import org.forgerock.opendj.ldap.requests.Requests;
 import org.forgerock.opendj.ldap.requests.SearchRequest;
 import org.forgerock.opendj.ldap.responses.Result;
 import org.forgerock.opendj.ldap.responses.SearchResultEntry;
@@ -61,7 +61,8 @@
  * An attribute mapper which provides a mapping from a JSON value to a single DN
  * valued LDAP attribute.
  */
-public final class ReferenceAttributeMapper extends AttributeMapper {
+public final class ReferenceAttributeMapper extends
+        AbstractLDAPAttributeMapper<ReferenceAttributeMapper> {
     /**
      * The maximum number of candidate references to allow in search filters.
      */
@@ -69,45 +70,19 @@
 
     private final DN baseDN;
     private Filter filter = null;
-    private boolean isRequired = false;
-    private boolean isSingleValued = false;
-    private final AttributeDescription ldapAttributeName;
     private final AttributeMapper mapper;
     private final AttributeDescription primaryKey;
     private SearchScope scope = SearchScope.WHOLE_SUBTREE;
-    private WritabilityPolicy writabilityPolicy = READ_WRITE;
 
     ReferenceAttributeMapper(final AttributeDescription ldapAttributeName, final DN baseDN,
             final AttributeDescription primaryKey, final AttributeMapper mapper) {
-        this.ldapAttributeName = ldapAttributeName;
+        super(ldapAttributeName);
         this.baseDN = baseDN;
         this.primaryKey = primaryKey;
         this.mapper = mapper;
     }
 
     /**
-     * Indicates that the LDAP attribute is mandatory and must be provided
-     * during create requests.
-     *
-     * @return This attribute mapper.
-     */
-    public ReferenceAttributeMapper isRequired() {
-        this.isRequired = true;
-        return this;
-    }
-
-    /**
-     * Indicates that multi-valued LDAP attribute should be represented as a
-     * single-valued JSON value, rather than an array of values.
-     *
-     * @return This attribute mapper.
-     */
-    public ReferenceAttributeMapper isSingleValued() {
-        this.isSingleValued = true;
-        return this;
-    }
-
-    /**
      * Sets the filter which should be used when searching for referenced LDAP
      * entries. The default is {@code (objectClass=*)}.
      *
@@ -148,30 +123,12 @@
         return this;
     }
 
-    /**
-     * Indicates whether or not the LDAP attribute supports updates. The default
-     * is {@link WritabilityPolicy#READ_WRITE}.
-     *
-     * @param policy
-     *            The writability policy.
-     * @return This attribute mapper.
-     */
-    public ReferenceAttributeMapper writability(final WritabilityPolicy policy) {
-        this.writabilityPolicy = policy;
-        return this;
-    }
-
     @Override
-    void getLDAPAttributes(final Context c, final JsonPointer jsonAttribute,
-            final Set<String> ldapAttributes) {
-        ldapAttributes.add(ldapAttributeName.toString());
-    }
-
-    @Override
-    void getLDAPFilter(final Context c, final FilterType type, final JsonPointer jsonAttribute,
-            final String operator, final Object valueAssertion, final ResultHandler<Filter> h) {
+    void getLDAPFilter(final Context c, final JsonPointer path, final JsonPointer subPath,
+            final FilterType type, final String operator, final Object valueAssertion,
+            final ResultHandler<Filter> h) {
         // Construct a filter which can be used to find referenced resources.
-        mapper.getLDAPFilter(c, type, jsonAttribute, operator, valueAssertion,
+        mapper.getLDAPFilter(c, path, subPath, type, operator, valueAssertion,
                 new ResultHandler<Filter>() {
                     @Override
                     public void handleError(final ResourceException error) {
@@ -224,86 +181,27 @@
     }
 
     @Override
-    void toJSON(final Context c, final Entry e, final ResultHandler<JsonValue> h) {
-        final Attribute attribute = e.getAttribute(ldapAttributeName);
-        if (attribute == null || attribute.isEmpty()) {
-            h.handleResult(null);
-        } else if (attributeIsSingleValued()) {
-            try {
-                final DN dn = attribute.parse().usingSchema(c.getConfig().schema()).asDN();
-                readEntry(c, dn, h);
-            } catch (final Exception ex) {
-                // The LDAP attribute could not be decoded.
-                h.handleError(asResourceException(ex));
-            }
-        } else {
-            try {
-                final Set<DN> dns =
-                        attribute.parse().usingSchema(c.getConfig().schema()).asSetOfDN();
-                final ResultHandler<JsonValue> handler =
-                        accumulate(dns.size(), transform(
-                                new Function<List<JsonValue>, JsonValue, Void>() {
-                                    @Override
-                                    public JsonValue apply(final List<JsonValue> value, final Void p) {
-                                        if (value.isEmpty()) {
-                                            // No values, so omit the entire JSON object from the resource.
-                                            return null;
-                                        } else {
-                                            // Combine values into a single JSON array.
-                                            final List<Object> result =
-                                                    new ArrayList<Object>(value.size());
-                                            for (final JsonValue e : value) {
-                                                result.add(e.getObject());
-                                            }
-                                            return new JsonValue(result);
-                                        }
-                                    }
-                                }, h));
-                for (final DN dn : dns) {
-                    readEntry(c, dn, handler);
-                }
-            } catch (final Exception ex) {
-                // The LDAP attribute could not be decoded.
-                h.handleError(asResourceException(ex));
-            }
+    void getNewLDAPAttributes(final Context c, final JsonPointer path,
+            final List<Object> newValues, final ResultHandler<Attribute> h) {
+        // No need to do anything if there are no values.
+        if (newValues.isEmpty()) {
+            h.handleResult(Attributes.emptyAttribute(ldapAttributeName));
+            return;
         }
-    }
 
-    @Override
-    void toLDAP(final Context c, final JsonValue v, final ResultHandler<List<Attribute>> h) {
-        try {
-            if (v == null || v.isNull()) {
-                if (attributeIsRequired()) {
-                    // FIXME: improve error message.
-                    throw new BadRequestException("no value provided");
-                } else {
-                    h.handleResult(Collections.<Attribute> emptyList());
-                }
-            } else if (v.isList() && attributeIsSingleValued()) {
-                // FIXME: improve error message.
-                throw new BadRequestException("expected single value, but got multiple values");
-            } else if (!writabilityPolicy.canCreate(ldapAttributeName)) {
-                if (writabilityPolicy.discardWrites()) {
-                    h.handleResult(Collections.<Attribute> emptyList());
-                } else {
-                    // FIXME: improve error message.
-                    throw new BadRequestException("attempted to create a read-only value");
-                }
-            } else {
-                /*
-                 * For each value use the subordinate mapper to obtain the LDAP
-                 * primary key, the perform a search for each one to find the
-                 * corresponding entries.
-                 */
-                final JsonValue valueList =
-                        v.isList() ? v : new JsonValue(singletonList(v.getObject()));
-                final Attribute reference = new LinkedAttribute(ldapAttributeName);
-                final AtomicInteger pendingSearches = new AtomicInteger(valueList.size());
-                final AtomicReference<ResourceException> exception =
-                        new AtomicReference<ResourceException>();
+        /*
+         * For each value use the subordinate mapper to obtain the LDAP primary
+         * key, the perform a search for each one to find the corresponding
+         * entries.
+         */
+        final Attribute newLDAPAttribute = new LinkedAttribute(ldapAttributeName);
+        final AtomicInteger pendingSearches = new AtomicInteger(newValues.size());
+        final AtomicReference<ResourceException> exception =
+                new AtomicReference<ResourceException>();
 
-                for (final JsonValue value : valueList) {
-                    mapper.toLDAP(c, value, new ResultHandler<List<Attribute>>() {
+        for (final Object value : newValues) {
+            mapper.toLDAP(c, path, null /* force create */, new JsonValue(value),
+                    new ResultHandler<List<Modification>>() {
 
                         @Override
                         public void handleError(final ResourceException error) {
@@ -311,32 +209,29 @@
                         }
 
                         @Override
-                        public void handleResult(final List<Attribute> result) {
+                        public void handleResult(final List<Modification> result) {
                             Attribute primaryKeyAttribute = null;
-                            for (final Attribute attribute : result) {
-                                if (attribute.getAttributeDescription().equals(primaryKey)) {
-                                    primaryKeyAttribute = attribute;
+                            for (final Modification modification : result) {
+                                if (modification.getAttribute().getAttributeDescription().equals(
+                                        primaryKey)) {
+                                    primaryKeyAttribute = modification.getAttribute();
                                     break;
                                 }
                             }
-                            if (primaryKeyAttribute == null) {
-                                // FIXME: improve error message.
-                                h.handleError(new BadRequestException(
-                                        "reference primary key attribute is missing"));
-                                return;
-                            }
 
-                            if (primaryKeyAttribute.isEmpty()) {
-                                // FIXME: improve error message.
+                            if (primaryKeyAttribute == null || primaryKeyAttribute.isEmpty()) {
                                 h.handleError(new BadRequestException(
-                                        "reference primary key attribute is empty"));
+                                        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) {
-                                // FIXME: improve error message.
                                 h.handleError(new BadRequestException(
-                                        "reference primary key attribute contains multiple values"));
+                                        i18n("The request cannot be processed because the reference "
+                                                + "field '%s' contains a value which contains multiple "
+                                                + "primary keys", path)));
                                 return;
                             }
 
@@ -356,7 +251,7 @@
                                                     ResourceException re;
                                                     try {
                                                         throw error;
-                                                    } catch (EntryNotFoundException e) {
+                                                    } catch (final EntryNotFoundException e) {
                                                         // FIXME: improve error message.
                                                         re =
                                                                 new BadRequestException(
@@ -364,7 +259,7 @@
                                                                                 + primaryKeyValue
                                                                                         .toString()
                                                                                 + "' does not exist");
-                                                    } catch (MultipleEntriesFoundException e) {
+                                                    } catch (final MultipleEntriesFoundException e) {
                                                         // FIXME: improve error message.
                                                         re =
                                                                 new BadRequestException(
@@ -372,7 +267,7 @@
                                                                                 + primaryKeyValue
                                                                                         .toString()
                                                                                 + "' is ambiguous");
-                                                    } catch (ErrorResultException e) {
+                                                    } catch (final ErrorResultException e) {
                                                         re = asResourceException(e);
                                                     }
                                                     exception.compareAndSet(null, re);
@@ -382,8 +277,8 @@
                                                 @Override
                                                 public void handleResult(
                                                         final SearchResultEntry result) {
-                                                    synchronized (reference) {
-                                                        reference.add(result.getName());
+                                                    synchronized (newLDAPAttribute) {
+                                                        newLDAPAttribute.add(result.getName());
                                                     }
                                                     completeIfNecessary();
                                                 }
@@ -393,40 +288,80 @@
                         private void completeIfNecessary() {
                             if (pendingSearches.decrementAndGet() == 0) {
                                 if (exception.get() == null) {
-                                    h.handleResult(singletonList(reference));
+                                    h.handleResult(newLDAPAttribute);
                                 } else {
                                     h.handleError(exception.get());
                                 }
                             }
                         }
                     });
-                }
-            }
-        } catch (final ResourceException e) {
-            h.handleError(e);
-        } catch (final Exception e) {
-            // FIXME: improve error message.
-            h.handleError(new BadRequestException(e.getMessage()));
         }
     }
 
-    private boolean attributeIsRequired() {
-        return isRequired;
+    @Override
+    ReferenceAttributeMapper getThis() {
+        return this;
     }
 
-    private boolean attributeIsSingleValued() {
-        return isSingleValued || ldapAttributeName.getAttributeType().isSingleValue();
+    @Override
+    void toJSON(final Context c, final JsonPointer path, final Entry e,
+            final ResultHandler<JsonValue> h) {
+        final Attribute attribute = e.getAttribute(ldapAttributeName);
+        if (attribute == null || attribute.isEmpty()) {
+            h.handleResult(null);
+        } else if (attributeIsSingleValued()) {
+            try {
+                final DN dn = attribute.parse().usingSchema(c.getConfig().schema()).asDN();
+                readEntry(c, path, dn, h);
+            } catch (final Exception ex) {
+                // The LDAP attribute could not be decoded.
+                h.handleError(asResourceException(ex));
+            }
+        } else {
+            try {
+                final Set<DN> dns =
+                        attribute.parse().usingSchema(c.getConfig().schema()).asSetOfDN();
+                final ResultHandler<JsonValue> handler =
+                        accumulate(dns.size(), transform(
+                                new Function<List<JsonValue>, JsonValue, Void>() {
+                                    @Override
+                                    public JsonValue apply(final List<JsonValue> value, final Void p) {
+                                        if (value.isEmpty()) {
+                                            /*
+                                             * No values, so omit the entire
+                                             * JSON object from the resource.
+                                             */
+                                            return null;
+                                        } else {
+                                            // Combine values into a single JSON array.
+                                            final List<Object> result =
+                                                    new ArrayList<Object>(value.size());
+                                            for (final JsonValue e : value) {
+                                                result.add(e.getObject());
+                                            }
+                                            return new JsonValue(result);
+                                        }
+                                    }
+                                }, h));
+                for (final DN dn : dns) {
+                    readEntry(c, path, dn, handler);
+                }
+            } catch (final Exception ex) {
+                // The LDAP attribute could not be decoded.
+                h.handleError(asResourceException(ex));
+            }
+        }
     }
 
     private SearchRequest createSearchRequest(final Filter result) {
         final Filter searchFilter = filter != null ? Filter.and(filter, result) : result;
-        final SearchRequest request = Requests.newSearchRequest(baseDN, scope, searchFilter, "1.1");
-        return request;
+        return newSearchRequest(baseDN, scope, searchFilter, "1.1");
     }
 
-    private void readEntry(final Context c, final DN dn, final ResultHandler<JsonValue> handler) {
+    private void readEntry(final Context c, final JsonPointer path, final DN dn,
+            final ResultHandler<JsonValue> handler) {
         final Set<String> requestedLDAPAttributes = new LinkedHashSet<String>();
-        mapper.getLDAPAttributes(c, new JsonPointer(), requestedLDAPAttributes);
+        mapper.getLDAPAttributes(c, path, new JsonPointer(), requestedLDAPAttributes);
         c.getConnection().readEntryAsync(dn, requestedLDAPAttributes,
                 new org.forgerock.opendj.ldap.ResultHandler<SearchResultEntry>() {
 
@@ -435,14 +370,17 @@
                         if (!(error instanceof EntryNotFoundException)) {
                             handler.handleError(asResourceException(error));
                         } else {
-                            // The referenced entry does not exist so ignore it since it cannot be mapped.
+                            /*
+                             * The referenced entry does not exist so ignore it
+                             * since it cannot be mapped.
+                             */
                             handler.handleResult(null);
                         }
                     }
 
                     @Override
                     public void handleResult(final SearchResultEntry result) {
-                        mapper.toJSON(c, result, handler);
+                        mapper.toJSON(c, path, result, handler);
                     }
                 });
     }
diff --git a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAP.java b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAP.java
index 7add0d5..9de51bd 100644
--- a/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAP.java
+++ b/opendj3/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAP.java
@@ -44,6 +44,7 @@
 import org.forgerock.opendj.ldap.ConnectionException;
 import org.forgerock.opendj.ldap.ConnectionFactory;
 import org.forgerock.opendj.ldap.Connections;
+import org.forgerock.opendj.ldap.ConstraintViolationException;
 import org.forgerock.opendj.ldap.DN;
 import org.forgerock.opendj.ldap.Entry;
 import org.forgerock.opendj.ldap.EntryNotFoundException;
@@ -86,6 +87,7 @@
         private AttributeMapper rootMapper;
         private Schema schema = Schema.getDefaultSchema();
         private boolean useSubtreeDelete;
+        private boolean usePermissiveModify;
 
         Builder() {
             useEtagAttribute();
@@ -144,7 +146,7 @@
             }
             return new LDAPCollectionResourceProvider(baseDN, rootMapper, nameStrategy,
                     etagAttribute, new Config(factory, readOnUpdatePolicy, authzPolicy,
-                            proxiedAuthzTemplate, useSubtreeDelete, schema),
+                            proxiedAuthzTemplate, useSubtreeDelete, usePermissiveModify, schema),
                     additionalLDAPAttributes);
         }
 
@@ -196,10 +198,14 @@
                 useEtagAttribute(etagAttribute.asString());
             }
 
-            if (configuration.get("useSubtreeDelete").required().asBoolean()) {
+            if (configuration.get("useSubtreeDelete").defaultTo(false).asBoolean()) {
                 useSubtreeDelete();
             }
 
+            if (configuration.get("usePermissiveModify").defaultTo(false).asBoolean()) {
+                usePermissiveModify();
+            }
+
             mapper(configureObjectMapper(configuration.get("attributes").required()));
 
             return this;
@@ -305,6 +311,11 @@
             return this;
         }
 
+        public Builder usePermissiveModify() {
+            this.usePermissiveModify = true;
+            return this;
+        }
+
         private AttributeDescription ad(final String attribute) {
             return AttributeDescription.valueOf(attribute, schema);
         }
@@ -323,9 +334,6 @@
                 if (config.isDefined("defaultJSONValue")) {
                     s.defaultJSONValue(config.get("defaultJSONValue").getObject());
                 }
-                if (config.isDefined("defaultLDAPValue")) {
-                    s.defaultLDAPValue(config.get("defaultLDAPValue").getObject());
-                }
                 if (config.get("isBinary").defaultTo(false).asBoolean()) {
                     s.isBinary();
                 }
@@ -507,6 +515,14 @@
             return e;
         } catch (final AssertionFailureException e) {
             resourceResultCode = ResourceException.VERSION_MISMATCH;
+        } catch (final ConstraintViolationException e) {
+            final ResultCode rc = e.getResult().getResultCode();
+            if (rc.equals(ResultCode.ENTRY_ALREADY_EXISTS)) {
+                resourceResultCode = ResourceException.VERSION_MISMATCH; // Consistent with MVCC.
+            } else {
+                // Schema violation, etc.
+                resourceResultCode = ResourceException.BAD_REQUEST;
+            }
         } catch (final AuthenticationException e) {
             resourceResultCode = 401;
         } catch (final AuthorizationException e) {
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 4706fbe..b59180f 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
@@ -16,8 +16,6 @@
 package org.forgerock.opendj.rest2ldap;
 
 import static java.util.Collections.emptyList;
-import static java.util.Collections.emptySet;
-import static java.util.Collections.singleton;
 import static java.util.Collections.singletonList;
 import static org.forgerock.opendj.ldap.Filter.alwaysFalse;
 import static org.forgerock.opendj.ldap.Functions.fixedFunction;
@@ -25,20 +23,17 @@
 import static org.forgerock.opendj.rest2ldap.Utils.base64ToByteString;
 import static org.forgerock.opendj.rest2ldap.Utils.byteStringToBase64;
 import static org.forgerock.opendj.rest2ldap.Utils.byteStringToJson;
+import static org.forgerock.opendj.rest2ldap.Utils.i18n;
 import static org.forgerock.opendj.rest2ldap.Utils.jsonToAttribute;
 import static org.forgerock.opendj.rest2ldap.Utils.jsonToByteString;
 import static org.forgerock.opendj.rest2ldap.Utils.toFilter;
-import static org.forgerock.opendj.rest2ldap.WritabilityPolicy.READ_WRITE;
 
-import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 
 import org.forgerock.json.fluent.JsonPointer;
 import org.forgerock.json.fluent.JsonValue;
 import org.forgerock.json.resource.BadRequestException;
-import org.forgerock.json.resource.ResourceException;
 import org.forgerock.json.resource.ResultHandler;
 import org.forgerock.opendj.ldap.Attribute;
 import org.forgerock.opendj.ldap.AttributeDescription;
@@ -46,25 +41,18 @@
 import org.forgerock.opendj.ldap.Entry;
 import org.forgerock.opendj.ldap.Filter;
 import org.forgerock.opendj.ldap.Function;
-import org.forgerock.opendj.ldap.LinkedAttribute;
 
 /**
  * An attribute mapper which provides a simple mapping from a JSON value to a
  * single LDAP attribute.
  */
-public final class SimpleAttributeMapper extends AttributeMapper {
+public final class SimpleAttributeMapper extends AbstractLDAPAttributeMapper<SimpleAttributeMapper> {
     private Function<ByteString, ?, Void> decoder = null;
-    private Object defaultJSONValue = null;
-    private Collection<Object> defaultJSONValues = Collections.emptySet();
-    private ByteString defaultLDAPValue = null;
+
     private Function<Object, ByteString, Void> encoder = null;
-    private boolean isRequired = false;
-    private boolean isSingleValued = false;
-    private final AttributeDescription ldapAttributeName;
-    private WritabilityPolicy writabilityPolicy = READ_WRITE;
 
     SimpleAttributeMapper(final AttributeDescription ldapAttributeName) {
-        this.ldapAttributeName = ldapAttributeName;
+        super(ldapAttributeName);
     }
 
     /**
@@ -90,20 +78,7 @@
      */
     public SimpleAttributeMapper defaultJSONValue(final Object defaultValue) {
         this.defaultJSONValue = defaultValue;
-        this.defaultJSONValues = defaultValue != null ? singleton(defaultValue) : emptySet();
-        return this;
-    }
-
-    /**
-     * Sets the default LDAP value which should be substituted when the JSON
-     * attribute is not found in the JSON value.
-     *
-     * @param defaultValue
-     *            The default LDAP value.
-     * @return This attribute mapper.
-     */
-    public SimpleAttributeMapper defaultLDAPValue(final Object defaultValue) {
-        this.defaultLDAPValue = defaultValue != null ? ByteString.valueOf(defaultValue) : null;
+        this.defaultJSONValues = defaultValue != null ? singletonList(defaultValue) : emptyList();
         return this;
     }
 
@@ -137,61 +112,21 @@
         return this;
     }
 
-    /**
-     * Indicates that the LDAP attribute is mandatory and must be provided
-     * during create requests.
-     *
-     * @return This attribute mapper.
-     */
-    public SimpleAttributeMapper isRequired() {
-        this.isRequired = true;
-        return this;
-    }
-
-    /**
-     * Indicates that multi-valued LDAP attribute should be represented as a
-     * single-valued JSON value, rather than an array of values.
-     *
-     * @return This attribute mapper.
-     */
-    public SimpleAttributeMapper isSingleValued() {
-        this.isSingleValued = true;
-        return this;
-    }
-
-    /**
-     * Indicates whether or not the LDAP attribute supports updates. The default
-     * is {@link WritabilityPolicy#READ_WRITE}.
-     *
-     * @param policy
-     *            The writability policy.
-     * @return This attribute mapper.
-     */
-    public SimpleAttributeMapper writability(final WritabilityPolicy policy) {
-        this.writabilityPolicy = policy;
-        return this;
-    }
-
     @Override
-    void getLDAPAttributes(final Context c, final JsonPointer jsonAttribute,
-            final Set<String> ldapAttributes) {
-        ldapAttributes.add(ldapAttributeName.toString());
-    }
-
-    @Override
-    void getLDAPFilter(final Context c, final FilterType type, final JsonPointer jsonAttribute,
-            final String operator, final Object valueAssertion, final ResultHandler<Filter> h) {
-        if (jsonAttribute.isEmpty()) {
+    void getLDAPFilter(final Context c, final JsonPointer path, final JsonPointer subPath,
+            final FilterType type, final String operator, final Object valueAssertion,
+            final ResultHandler<Filter> h) {
+        if (subPath.isEmpty()) {
             try {
                 final ByteString va =
                         valueAssertion != null ? encoder().apply(valueAssertion, null) : null;
                 h.handleResult(toFilter(c, type, ldapAttributeName.toString(), va));
-            } catch (Exception e) {
+            } catch (final Exception e) {
                 // Invalid assertion value - bad request.
-
-                // FIXME: improve error message.
-                h.handleError(new BadRequestException("Invalid filter assertion value '"
-                        + String.valueOf(valueAssertion) + "'", e));
+                h.handleError(new BadRequestException(i18n(
+                        "The request cannot be processed because it contained an "
+                                + "illegal filter assertion value '%s' for field '%s'", String
+                                .valueOf(valueAssertion), path), e));
             }
         } else {
             // This attribute mapper does not support partial filtering.
@@ -200,7 +135,25 @@
     }
 
     @Override
-    void toJSON(final Context c, final Entry e, final ResultHandler<JsonValue> h) {
+    void getNewLDAPAttributes(final Context c, final JsonPointer path,
+            final List<Object> newValues, final ResultHandler<Attribute> h) {
+        try {
+            h.handleResult(jsonToAttribute(newValues, ldapAttributeName, encoder()));
+        } catch (final Exception ex) {
+            h.handleError(new BadRequestException(i18n(
+                    "The request cannot be processed because an error occurred while "
+                            + "encoding the values for the field '%s': %s", path, ex.getMessage())));
+        }
+    }
+
+    @Override
+    SimpleAttributeMapper getThis() {
+        return this;
+    }
+
+    @Override
+    void toJSON(final Context c, final JsonPointer path, final Entry e,
+            final ResultHandler<JsonValue> h) {
         try {
             final Object value;
             if (attributeIsSingleValued()) {
@@ -211,66 +164,12 @@
                 value = s.isEmpty() ? null : s;
             }
             h.handleResult(value != null ? new JsonValue(value) : null);
-        } catch (Exception ex) {
+        } catch (final Exception ex) {
             // The LDAP attribute could not be decoded.
             h.handleError(asResourceException(ex));
         }
     }
 
-    @Override
-    void toLDAP(final Context c, final JsonValue v, final ResultHandler<List<Attribute>> h) {
-        try {
-            final List<Attribute> result;
-            if (v == null || v.isNull()) {
-                if (attributeIsRequired()) {
-                    // FIXME: improve error message.
-                    throw new BadRequestException("no value provided");
-                } else if (defaultLDAPValue != null) {
-                    result =
-                            singletonList((Attribute) new LinkedAttribute(ldapAttributeName,
-                                    defaultLDAPValue));
-                } else {
-                    result = emptyList();
-                }
-            } else if (v.isList() && attributeIsSingleValued()) {
-                // FIXME: improve error message.
-                throw new BadRequestException("expected single value, but got multiple values");
-            } else if (!writabilityPolicy.canCreate(ldapAttributeName)) {
-                if (writabilityPolicy.discardWrites()) {
-                    result = emptyList();
-                } else {
-                    // FIXME: improve error message.
-                    throw new BadRequestException("attempted to create a read-only value");
-                }
-            } else {
-                final Object value = v.getObject();
-                if (value != null) {
-                    result = singletonList(jsonToAttribute(value, ldapAttributeName, encoder()));
-                } else if (defaultLDAPValue != null) {
-                    result =
-                            singletonList((Attribute) new LinkedAttribute(ldapAttributeName,
-                                    defaultLDAPValue));
-                } else {
-                    result = emptyList();
-                }
-            }
-            h.handleResult(result);
-        } catch (final ResourceException e) {
-            h.handleError(e);
-        } catch (final Exception e) {
-            // FIXME: improve error message.
-            h.handleError(new BadRequestException(e.getMessage()));
-        }
-    }
-
-    private boolean attributeIsRequired() {
-        return isRequired && defaultJSONValue == null;
-    }
-
-    private boolean attributeIsSingleValued() {
-        return isSingleValued || ldapAttributeName.getAttributeType().isSingleValue();
-    }
-
     private Function<ByteString, ? extends Object, Void> decoder() {
         return decoder == null ? fixedFunction(byteStringToJson(), ldapAttributeName) : decoder;
     }
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 28a8851..2123a74 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
@@ -19,20 +19,27 @@
 import static org.fest.assertions.Fail.fail;
 import static org.forgerock.json.resource.Requests.newDeleteRequest;
 import static org.forgerock.json.resource.Requests.newReadRequest;
+import static org.forgerock.json.resource.Requests.newUpdateRequest;
 import static org.forgerock.json.resource.Resources.newCollection;
 import static org.forgerock.json.resource.Resources.newInternalConnection;
 import static org.forgerock.opendj.ldap.Connections.newInternalConnectionFactory;
 import static org.forgerock.opendj.rest2ldap.Rest2LDAP.object;
 import static org.forgerock.opendj.rest2ldap.Rest2LDAP.simple;
+import static org.forgerock.opendj.rest2ldap.TestUtils.asResource;
+import static org.forgerock.opendj.rest2ldap.TestUtils.content;
+import static org.forgerock.opendj.rest2ldap.TestUtils.ctx;
+import static org.forgerock.opendj.rest2ldap.TestUtils.field;
+import static org.forgerock.opendj.rest2ldap.TestUtils.object;
 
 import java.io.IOException;
 
+import org.forgerock.json.fluent.JsonValue;
+import org.forgerock.json.resource.BadRequestException;
 import org.forgerock.json.resource.Connection;
 import org.forgerock.json.resource.NotFoundException;
 import org.forgerock.json.resource.PreconditionFailedException;
 import org.forgerock.json.resource.RequestHandler;
 import org.forgerock.json.resource.Resource;
-import org.forgerock.json.resource.RootContext;
 import org.forgerock.opendj.ldap.ConnectionFactory;
 import org.forgerock.opendj.ldap.MemoryBackend;
 import org.forgerock.opendj.ldif.LDIFEntryReader;
@@ -54,10 +61,10 @@
     public void testDelete() throws Exception {
         final RequestHandler handler = newCollection(builder().build());
         final Connection connection = newInternalConnection(handler);
-        final Resource resource = connection.delete(c(), newDeleteRequest("/test1"));
-        checkTestUser1(resource);
+        final Resource resource = connection.delete(ctx(), newDeleteRequest("/test1"));
+        checkResourcesAreEqual(resource, getTestUser1(12345));
         try {
-            connection.read(c(), newReadRequest("/test1"));
+            connection.read(ctx(), newReadRequest("/test1"));
             fail("Read succeeded unexpectedly");
         } catch (final NotFoundException e) {
             // Expected.
@@ -69,10 +76,10 @@
         final RequestHandler handler = newCollection(builder().build());
         final Connection connection = newInternalConnection(handler);
         final Resource resource =
-                connection.delete(c(), newDeleteRequest("/test1").setRevision("12345"));
-        checkTestUser1(resource);
+                connection.delete(ctx(), newDeleteRequest("/test1").setRevision("12345"));
+        checkResourcesAreEqual(resource, getTestUser1(12345));
         try {
-            connection.read(c(), newReadRequest("/test1"));
+            connection.read(ctx(), newReadRequest("/test1"));
             fail("Read succeeded unexpectedly");
         } catch (final NotFoundException e) {
             // Expected.
@@ -83,50 +90,50 @@
     public void testDeleteMVCCNoMatch() throws Exception {
         final RequestHandler handler = newCollection(builder().build());
         final Connection connection = newInternalConnection(handler);
-        connection.delete(c(), newDeleteRequest("/test1").setRevision("12346"));
+        connection.delete(ctx(), newDeleteRequest("/test1").setRevision("12346"));
     }
 
     @Test(expectedExceptions = NotFoundException.class)
     public void testDeleteNotFound() throws Exception {
         final RequestHandler handler = newCollection(builder().build());
         final Connection connection = newInternalConnection(handler);
-        connection.delete(c(), newDeleteRequest("/missing"));
+        connection.delete(ctx(), newDeleteRequest("/missing"));
     }
 
     @Test
     public void testRead() throws Exception {
         final RequestHandler handler = newCollection(builder().build());
         final Resource resource =
-                newInternalConnection(handler).read(c(), newReadRequest("/test1"));
-        checkTestUser1(resource);
+                newInternalConnection(handler).read(ctx(), newReadRequest("/test1"));
+        checkResourcesAreEqual(resource, getTestUser1(12345));
     }
 
     @Test(expectedExceptions = NotFoundException.class)
     public void testReadNotFound() throws Exception {
         final RequestHandler handler = newCollection(builder().build());
-        newInternalConnection(handler).read(c(), newReadRequest("/missing"));
+        newInternalConnection(handler).read(ctx(), newReadRequest("/missing"));
     }
 
     @Test
     public void testReadSelectAllFields() throws Exception {
         final RequestHandler handler = newCollection(builder().build());
         final Resource resource =
-                newInternalConnection(handler).read(c(), newReadRequest("/test1").addField("/"));
-        checkTestUser1(resource);
+                newInternalConnection(handler).read(ctx(), newReadRequest("/test1").addField("/"));
+        checkResourcesAreEqual(resource, getTestUser1(12345));
     }
 
     @Test
     public void testReadSelectPartial() throws Exception {
         final RequestHandler handler = newCollection(builder().build());
         final Resource resource =
-                newInternalConnection(handler).read(c(),
+                newInternalConnection(handler).read(ctx(),
                         newReadRequest("/test1").addField("surname"));
         assertThat(resource.getId()).isEqualTo("test1");
         assertThat(resource.getRevision()).isEqualTo("12345");
-        assertThat(resource.getContent().get("id").asString()).isNull();
+        assertThat(resource.getContent().get("_id").asString()).isNull();
         assertThat(resource.getContent().get("displayName").asString()).isNull();
         assertThat(resource.getContent().get("surname").asString()).isEqualTo("user 1");
-        assertThat(resource.getContent().get("rev").asString()).isNull();
+        assertThat(resource.getContent().get("_rev").asString()).isNull();
     }
 
     // Disabled - see CREST-86 (Should JSON resource fields be case insensitive?)
@@ -134,14 +141,60 @@
     public void testReadSelectPartialInsensitive() throws Exception {
         final RequestHandler handler = newCollection(builder().build());
         final Resource resource =
-                newInternalConnection(handler).read(c(),
+                newInternalConnection(handler).read(ctx(),
                         newReadRequest("/test1").addField("SURNAME"));
         assertThat(resource.getId()).isEqualTo("test1");
         assertThat(resource.getRevision()).isEqualTo("12345");
-        assertThat(resource.getContent().get("id").asString()).isNull();
+        assertThat(resource.getContent().get("_id").asString()).isNull();
         assertThat(resource.getContent().get("displayName").asString()).isNull();
         assertThat(resource.getContent().get("surname").asString()).isEqualTo("user 1");
-        assertThat(resource.getContent().get("rev").asString()).isNull();
+        assertThat(resource.getContent().get("_rev").asString()).isNull();
+    }
+
+    @Test
+    public void testUpdate() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        final Resource resource1 =
+                connection.update(ctx(), newUpdateRequest("/test1", getTestUser1Updated(12345)));
+        checkResourcesAreEqual(resource1, getTestUser1Updated(12345));
+        final Resource resource2 = connection.read(ctx(), newReadRequest("/test1"));
+        checkResourcesAreEqual(resource2, getTestUser1Updated(12345));
+    }
+
+    @Test
+    public void testUpdateMVCCMatch() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        final Resource resource1 =
+                connection.update(ctx(), newUpdateRequest("/test1", getTestUser1Updated(12345))
+                        .setRevision("12345"));
+        checkResourcesAreEqual(resource1, getTestUser1Updated(12345));
+        final Resource resource2 = connection.read(ctx(), newReadRequest("/test1"));
+        checkResourcesAreEqual(resource2, getTestUser1Updated(12345));
+    }
+
+    @Test(expectedExceptions = PreconditionFailedException.class)
+    public void testUpdateMVCCNoMatch() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        connection.update(ctx(), newUpdateRequest("/test1", getTestUser1Updated(12345))
+                .setRevision("12346"));
+    }
+
+    @Test(expectedExceptions = NotFoundException.class)
+    public void testUpdateNotFound() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        connection.update(ctx(), newUpdateRequest("/missing", getTestUser1Updated(12345)));
+    }
+
+    @Test(expectedExceptions = BadRequestException.class)
+    public void testUpdateReadOnlyAttribute() throws Exception {
+        final RequestHandler handler = newCollection(builder().build());
+        final Connection connection = newInternalConnection(handler);
+        // Etag is read-only.
+        connection.update(ctx(), newUpdateRequest("/test1", getTestUser1Updated(99999)));
     }
 
     private Builder builder() throws IOException {
@@ -149,24 +202,23 @@
                 .useEtagAttribute().useClientDNNaming("uid").readOnUpdatePolicy(
                         ReadOnUpdatePolicy.CONTROLS).authorizationPolicy(AuthorizationPolicy.NONE)
                 .additionalLDAPAttribute("objectClass", "top", "person").mapper(
-                        object().attribute("id", simple("uid").isSingleValued().isRequired())
-                                .attribute("displayName",
-                                        simple("cn").isSingleValued().isRequired()).attribute(
-                                        "surname", simple("sn").isSingleValued().isRequired())
-                                .attribute("rev", simple("etag").isSingleValued().isRequired()));
+                        object().attribute(
+                                "_id",
+                                simple("uid").isSingleValued().isRequired().writability(
+                                        WritabilityPolicy.CREATE_ONLY)).attribute("displayName",
+                                simple("cn").isSingleValued().isRequired()).attribute("surname",
+                                simple("sn").isSingleValued().isRequired()).attribute(
+                                "_rev",
+                                simple("etag").isSingleValued().isRequired().writability(
+                                        WritabilityPolicy.READ_ONLY)));
     }
 
-    private RootContext c() {
-        return new RootContext();
-    }
-
-    private void checkTestUser1(final Resource resource) {
-        assertThat(resource.getId()).isEqualTo("test1");
-        assertThat(resource.getRevision()).isEqualTo("12345");
-        assertThat(resource.getContent().get("id").asString()).isEqualTo("test1");
-        assertThat(resource.getContent().get("displayName").asString()).isEqualTo("test user 1");
-        assertThat(resource.getContent().get("surname").asString()).isEqualTo("user 1");
-        assertThat(resource.getContent().get("rev").asString()).isEqualTo("12345");
+    private void checkResourcesAreEqual(final Resource actual, final JsonValue expected) {
+        final Resource expectedResource = asResource(expected);
+        assertThat(actual.getId()).isEqualTo(expectedResource.getId());
+        assertThat(actual.getRevision()).isEqualTo(expectedResource.getRevision());
+        assertThat(actual.getContent().getObject()).isEqualTo(
+                expectedResource.getContent().getObject());
     }
 
     private ConnectionFactory getConnectionFactory() throws IOException {
@@ -200,4 +252,14 @@
 
         return newInternalConnectionFactory(backend);
     }
+
+    private JsonValue getTestUser1(final int rev) {
+        return content(object(field("_id", "test1"), field("_rev", String.valueOf(rev)), field(
+                "displayName", "test user 1"), field("surname", "user 1")));
+    }
+
+    private JsonValue getTestUser1Updated(final int rev) {
+        return content(object(field("_id", "test1"), field("_rev", String.valueOf(rev)), field(
+                "displayName", "changed"), field("surname", "user 1")));
+    }
 }
diff --git a/opendj3/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/TestUtils.java b/opendj3/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/TestUtils.java
new file mode 100644
index 0000000..7dcc575
--- /dev/null
+++ b/opendj3/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/TestUtils.java
@@ -0,0 +1,143 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2013 ForgeRock Inc.
+ */
+package org.forgerock.opendj.rest2ldap;
+
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.forgerock.json.fluent.JsonPointer;
+import org.forgerock.json.fluent.JsonValue;
+import org.forgerock.json.resource.Resource;
+import org.forgerock.json.resource.RootContext;
+
+/**
+ * Unit test utility methods, including fluent methods for creating JSON
+ * objects.
+ */
+public final class TestUtils {
+
+    /**
+     * Creates a JSON array object.
+     *
+     * @param objects
+     *            The array elements.
+     * @return A JSON array.
+     */
+    public static Object array(final Object... objects) {
+        return Arrays.asList(objects);
+    }
+
+    /**
+     * Returns a {@code Resource} containing the provided JSON content. The ID
+     * and revision will be taken from the "_id" and "_rev" fields respectively.
+     *
+     * @param content
+     *            The JSON content.
+     * @return A {@code Resource} containing the provided JSON content.
+     */
+    public static Resource asResource(final JsonValue content) {
+        return new Resource(content.get("_id").asString(), content.get("_rev").asString(), content);
+    }
+
+    /**
+     * Creates a JSON value for the provided object.
+     *
+     * @param object
+     *            The object.
+     * @return The JSON value.
+     */
+    public static JsonValue content(final Object object) {
+        return new JsonValue(object);
+    }
+
+    /**
+     * Creates a root context to be passed in with client requests.
+     *
+     * @return The root context.
+     */
+    public static RootContext ctx() {
+        return new RootContext();
+    }
+
+    /**
+     * Creates a JSON value for the provided object. This is the same as
+     * {@link #content(Object)} but can yield more readable test data in data
+     * providers.
+     *
+     * @param object
+     *            The object.
+     * @return The JSON value.
+     */
+    public static JsonValue expected(final Object object) {
+        return content(object);
+    }
+
+    /**
+     * Creates a JSON field for inclusion in a JSON object using
+     * {@link #object(java.util.Map.Entry...)}.
+     *
+     * @param key
+     *            The JSON field name.
+     * @param value
+     *            The JSON field value.
+     * @return The JSON field for inclusion in a JSON object.
+     */
+    public static Map.Entry<String, Object> field(final String key, final Object value) {
+        return new AbstractMap.SimpleImmutableEntry<String, Object>(key, value);
+    }
+
+    /**
+     * Creates a list of JSON pointers from the provided string representations.
+     *
+     * @param fields
+     *            The list of JSON pointer strings.
+     * @return The list of parsed JSON pointers.
+     */
+    public static List<JsonPointer> filter(final String... fields) {
+        final List<JsonPointer> result = new ArrayList<JsonPointer>(fields.length);
+        for (final String field : fields) {
+            result.add(new JsonPointer(field));
+        }
+        return result;
+    }
+
+    /**
+     * Creates a JSON object comprised of the provided JSON
+     * {@link #field(String, Object) fields}.
+     *
+     * @param fields
+     *            The list of {@link #field(String, Object) fields} to include
+     *            in the JSON object.
+     * @return The JSON object.
+     */
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    public static Object object(final Map.Entry... fields) {
+        final Map<String, Object> object = new LinkedHashMap<String, Object>(fields.length);
+        for (final Map.Entry<String, Object> field : fields) {
+            object.put(field.getKey(), field.getValue());
+        }
+        return object;
+    }
+
+    private TestUtils() {
+        // Prevent instantiation.
+    }
+
+}

--
Gitblit v1.10.0