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

---
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java                            |  658 +++++
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceCollection.java                                |  530 ++++
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java                                    |   72 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/RoutingContext.java                                       |   43 
 opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/Rest2ldapEndpointConfiguration.xml |   16 
 opendj-server-legacy/src/main/java/org/opends/server/protocols/http/rest2ldap/Rest2LdapEndpoint.java                    |   79 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectPropertyMapper.java                                 |  264 +
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ResourceTypePropertyMapper.java                           |  123 +
 opendj-server-legacy/resource/schema/02-config.ldif                                                                     |    4 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/NamingStrategy.java                                       |   79 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResource.java                                          |  151 +
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2Ldap.java                                            | 1171 ++-------
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractLdapPropertyMapper.java                           |   79 
 opendj-server-legacy/resource/config/config.ldif                                                                        |    2 
 opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/rest2ldap/rest2ldap.json                                       |    8 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/PropertyMapper.java                                       |   33 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java                                          |   19 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Action.java                                               |   44 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceSingleton.java                                 |  292 ++
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReadOnlyRequestHandler.java                               |   54 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractRequestHandler.java                               |   84 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SimplePropertyMapper.java                                 |   87 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JsonConstantPropertyMapper.java                           |   46 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java                             |  175 -
 opendj-server-legacy/src/main/assembly/opendj-archive-component.xml                                                     |    5 
 opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties                                 |   29 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferencePropertyMapper.java                              |  186 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Utils.java                                                |   34 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Resource.java                                             |  447 +++
 opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/config.json                                                    |  242 --
 /dev/null                                                                                                               |  104 
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/Rest2LdapTest.java                                        |  465 +++
 opendj-server-legacy/resource/config/rest2ldap/endpoints/api/users-and-groups-v1.json                                   |  234 +
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java                                      |  993 ++++---
 opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/rest2ldap/endpoints/api/users-and-groups-v1.json               |  234 +
 35 files changed, 4,726 insertions(+), 2,360 deletions(-)

diff --git a/opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/Rest2ldapEndpointConfiguration.xml b/opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/Rest2ldapEndpointConfiguration.xml
index 7c8da6a..644feb7 100644
--- a/opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/Rest2ldapEndpointConfiguration.xml
+++ b/opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/Rest2ldapEndpointConfiguration.xml
@@ -39,24 +39,30 @@
       </adm:defined>
     </adm:default-behavior>
   </adm:property-override>
-  <adm:property name="config-url" mandatory="true">
+  <adm:property name="config-directory" mandatory="true">
     <adm:synopsis>
-      URL of the REST2LDAP configuration file.
+      The directory containing the Rest2Ldap configuration file(s) for this specific endpoint.
     </adm:synopsis>
+    <adm:description>
+      The directory must be readable by the server and may contain multiple configuration files, one for each
+      supported version of the REST endpoint. If a relative path is used then it will be resolved against the server's
+      instance directory.
+    </adm:description>
     <adm:syntax>
       <adm:string>
         <adm:pattern>
           <adm:regex>.*</adm:regex>
-          <adm:usage>URL</adm:usage>
+          <adm:usage>DIRECTORY</adm:usage>
           <adm:synopsis>
-            A URL to an existing file that is readable by the server.
+            A directory that is readable by the server.
           </adm:synopsis>
         </adm:pattern>
       </adm:string>
     </adm:syntax>
     <adm:profile name="ldap">
       <ldap:attribute>
-        <ldap:name>ds-cfg-config-url</ldap:name>
+        <ldap:name>ds-cfg-config-directory</ldap:name>
       </ldap:attribute>
     </adm:profile>
+  </adm:property>
 </adm:managed-object>
diff --git a/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/opendj-rest2ldap-config.json b/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/config.json
similarity index 62%
rename from opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/opendj-rest2ldap-config.json
rename to opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/config.json
index 1eab5b3..50722af 100644
--- a/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/opendj-rest2ldap-config.json
+++ b/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/config.json
@@ -302,247 +302,5 @@
                 "authzIdTemplate": "dn:uid={uid},ou=People,dc=example,dc=com"
             }
         }
-    },
-
-
-    // The REST APIs and their LDAP attribute mappings.
-    "mappings": {
-        "/users": {
-            "baseDN": "ou=people,dc=example,dc=com",
-            "readOnUpdatePolicy": "controls",
-            "useSubtreeDelete": false,
-            "usePermissiveModify": true,
-            "etagAttribute": "etag",
-            "namingStrategy": {
-                "strategy": "clientDNNaming",
-                "dnAttribute": "uid"
-            },
-            "additionalLDAPAttributes": [{
-                "type": "objectClass",
-                "values": [
-                    "top",
-                    "person",
-                    "organizationalPerson",
-                    "inetOrgPerson"
-                ]
-            }],
-            "attributes": {
-                "schemas": {
-                    "constant": ["urn:scim:schemas:core:1.0"]
-                },
-                "_id": {
-                    "simple": {
-                        "ldapAttribute": "uid",
-                        "isSingleValued": true,
-                        "isRequired": true,
-                        "writability": "createOnly"
-                    }
-                },
-                "_rev": {
-                    "simple": {
-                        "ldapAttribute": "etag",
-                        "isSingleValued": true,
-                        "writability": "readOnly"
-                    }
-                },
-                "userName": {
-                    "simple": {
-                        "ldapAttribute": "mail",
-                        "isSingleValued": true,
-                        "writability": "readOnly"
-                    }
-                },
-                "displayName": {
-                    "simple": {
-                        "ldapAttribute": "cn",
-                        "isSingleValued": true,
-                        "isRequired": true
-                    }
-                },
-                "name": {
-                    "object": {
-                        "givenName": {
-                            "simple": {
-                                "ldapAttribute": "givenName",
-                                "isSingleValued": true
-                            }
-                        },
-                        "familyName": {
-                            "simple": {
-                                "ldapAttribute": "sn",
-                                "isSingleValued": true,
-                                "isRequired": true
-                            }
-                        }
-                    }
-                },
-                "manager": {
-                    "reference": {
-                        "ldapAttribute": "manager",
-                        "baseDN": "ou=people,dc=example,dc=com",
-                        "primaryKey": "uid",
-                        "mapper": {
-                            "object": {
-                                "_id": {
-                                    "simple": {
-                                        "ldapAttribute": "uid",
-                                        "isSingleValued": true,
-                                        "isRequired": true
-                                    }
-                                },
-                                "displayName": {
-                                    "simple": {
-                                        "ldapAttribute": "cn",
-                                        "isSingleValued": true,
-                                        "writability": "readOnlyDiscardWrites"
-                                    }
-                                }
-                            }
-                        }
-                    }
-                },
-                "groups": {
-                    "reference": {
-                        "ldapAttribute": "isMemberOf",
-                        "baseDN": "ou=groups,dc=example,dc=com",
-                        "writability": "readOnly",
-                        "primaryKey": "cn",
-                        "mapper": {
-                            "object": {
-                                "_id": {
-                                    "simple": {
-                                        "ldapAttribute": "cn",
-                                        "isSingleValued": true
-                                    }
-                                }
-                            }
-                        }
-                    }
-                },
-                "contactInformation": {
-                    "object": {
-                        "telephoneNumber": {
-                            "simple": {
-                                "ldapAttribute": "telephoneNumber",
-                                "isSingleValued": true
-                            }
-                        },
-                        "emailAddress": {
-                            "simple": {
-                                "ldapAttribute": "mail",
-                                "isSingleValued": true
-                            }
-                        }
-                    }
-                },
-                "meta": {
-                    "object": {
-                        "created": {
-                            "simple": {
-                                "ldapAttribute": "createTimestamp",
-                                "isSingleValued": true,
-                                "writability": "readOnly"
-                            }
-                        },
-                        "lastModified": {
-                            "simple": {
-                                "ldapAttribute": "modifyTimestamp",
-                                "isSingleValued": true,
-                                "writability": "readOnly"
-                            }
-                        }
-                    }
-                }
-            }
-        },
-        "/groups": {
-            "baseDN": "ou=groups,dc=example,dc=com",
-            "readOnUpdatePolicy": "controls",
-            "useSubtreeDelete": false,
-            "usePermissiveModify": true,
-            "etagAttribute": "etag",
-            "namingStrategy": {
-                "strategy": "clientDNNaming",
-                "dnAttribute": "cn"
-            },
-            "additionalLDAPAttributes": [{
-                "type": "objectClass",
-                "values": [
-                    "top",
-                    "groupOfUniqueNames"
-                ]
-            }],
-            "attributes": {
-                "schemas": {
-                    "constant": ["urn:scim:schemas:core:1.0"]
-                },
-                "_id": {
-                    "simple": {
-                        "ldapAttribute": "cn",
-                        "isSingleValued": true,
-                        "isRequired": true,
-                        "writability": "createOnly"
-                    }
-                },
-                "_rev": {
-                    "simple": {
-                        "ldapAttribute": "etag",
-                        "isSingleValued": true,
-                        "writability": "readOnly"
-                    }
-                },
-                "displayName": {
-                    "simple": {
-                        "ldapAttribute": "cn",
-                        "isSingleValued": true,
-                        "isRequired": true,
-                        "writability": "readOnly"
-                    }
-                },
-                "members": {
-                    "reference": {
-                        "ldapAttribute": "uniqueMember",
-                        "baseDN": "dc=example,dc=com",
-                        "primaryKey": "uid",
-                        "mapper": {
-                            "object": {
-                                "_id": {
-                                    "simple": {
-                                        "ldapAttribute": "uid",
-                                        "isSingleValued": true,
-                                        "isRequired": true
-                                    }
-                                },
-                                "displayName": {
-                                    "simple": {
-                                        "ldapAttribute": "cn",
-                                        "isSingleValued": true,
-                                        "writability": "readOnlyDiscardWrites"
-                                    }
-                                }
-                            }
-                        }
-                    }
-                },
-                "meta": {
-                    "object": {
-                        "created": {
-                            "simple": {
-                                "ldapAttribute": "createTimestamp",
-                                "isSingleValued": true,
-                                "writability": "readOnly"
-                            }
-                        },
-                        "lastModified": {
-                            "simple": {
-                                "ldapAttribute": "modifyTimestamp",
-                                "isSingleValued": true,
-                                "writability": "readOnly"
-                            }
-                        }
-                    }
-                }
-            }
-        }
     }
 }
diff --git a/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/rest2ldap/endpoints/api/users-and-groups-v1.json b/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/rest2ldap/endpoints/api/users-and-groups-v1.json
new file mode 100644
index 0000000..dbdfe7d
--- /dev/null
+++ b/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/rest2ldap/endpoints/api/users-and-groups-v1.json
@@ -0,0 +1,234 @@
+{
+    // This file defines an example Rest2Ldap API mapping exposing a multi-tenant deployment exposing users,
+    // POSIX users, and groups, as follows:
+    //
+    // /api/{tenant}/users/{uid} - users for a given tenant, e.g. "/api/example/users/bjensen"
+    // /api/{tenant}/groups/{cn} - groups for a given tenant, e.g. "/api/example/groups/administrators"
+    //
+    "version": "1.0",
+
+    // This section defines all of the resources, their inheritance, and relationships.
+    "resourceTypes": {
+        // This resource represents the entry point into the user/group API. It only defines sub-resources and
+        // does not have any properties itself. The URL and DN templates include a template variable allowing
+        // this API to support multi-tenancy. Multiple template variables are permitted.
+        "users-and-groups-v1": {
+            "subResources": {
+                "{tenant}/users": {
+                    "type": "collection",
+                    "dnTemplate": "ou=people,dc={tenant},dc=com",
+                    "resource": "frapi:opendj:rest2ldap:user:1.0",
+                    "namingStrategy": {
+                        "type": "clientDnNaming",
+                        "dnAttribute": "uid"
+                    }
+                },
+                "{tenant}/groups": {
+                    "type": "collection",
+                    "dnTemplate": "ou=groups,dc={tenant},dc=com",
+                    "resource": "frapi:opendj:rest2ldap:group:1.0",
+                    "namingStrategy": {
+                        "type": "clientDNNaming",
+                        "dnAttribute": "cn"
+                    }
+                }
+            }
+        },
+        // This resource will act as the common parent of all resources that have a JSON representation.
+        "frapi:opendj:rest2ldap:object:1.0": {
+            "isAbstract": true,
+            "objectClasses": [ "top" ],
+            // This property will store type information in a resource's JSON representation. It is the
+            // equivalent of the "objectClass" attribute, except that it is single valued and will contain
+            // the resource name, e.g. "frapi:opendj:rest2ldap:user:1.0" or "frapi:opendj:rest2ldap:group:1.0".
+            "resourceTypeProperty": "_schema",
+            "properties": {
+                // Resource type property mappers store the resource's type and don't have any configuration.
+                "_schema": {
+                    "type": "resourceType"
+                },
+                "_rev": {
+                    "type": "simple",
+                    "ldapAttribute": "etag",
+                    "writability": "readOnly"
+                },
+                "_meta": {
+                    "type": "object",
+                    "properties": {
+                        "created": {
+                            "type": "simple",
+                            "ldapAttribute": "createTimestamp",
+                            "writability": "readOnly"
+                        },
+                        "lastModified": {
+                            "type": "simple",
+                            "ldapAttribute": "modifyTimestamp",
+                            "writability": "readOnly"
+                        }
+                    }
+                }
+            }
+        },
+        // A "user" resource includes property mapping for the inetOrgPerson LDAP object class and is identified by
+        // the "uid" LDAP attribute. Users have a single sub-type representing users with POSIX account information.
+        "frapi:opendj:rest2ldap:user:1.0": {
+            "superType": "frapi:opendj:rest2ldap:object:1.0",
+            "objectClasses": [ "person", "organizationalPerson", "inetOrgPerson" ],
+            "supportedActions": [ "passwordModify" ],
+            "properties": {
+                "_id": {
+                    "type": "simple",
+                    "ldapAttribute": "uid",
+                    "isRequired": true,
+                    "writability": "createOnly"
+                },
+                "userName": {
+                    "type": "simple",
+                    "ldapAttribute": "mail"
+                },
+                "displayName": {
+                    "type": "simple",
+                    "ldapAttribute": "cn",
+                    "isMultiValued": true,
+                    "isRequired": true
+                },
+                "name": {
+                    "type": "object",
+                    "properties": {
+                        "givenName": {
+                            "type": "simple"
+                        },
+                        "familyName": {
+                            "type": "simple",
+                            "ldapAttribute": "sn",
+                            "isRequired": true
+                        }
+                    }
+                },
+                "description": {
+                    "type": "simple"
+                },
+                "manager": {
+                    "type": "reference",
+                    "ldapAttribute": "manager",
+                    "baseDn": "ou=people,dc=example,dc=com",
+                    "primaryKey": "uid",
+                    "mapper": {
+                        "type": "object",
+                        "properties": {
+                            "_id": {
+                                "type": "simple",
+                                "ldapAttribute": "uid",
+                                "isRequired": true
+                            },
+                            "displayName": {
+                                "type": "simple",
+                                "ldapAttribute": "cn",
+                                "writability": "readOnlyDiscardWrites"
+                            }
+                        }
+                    }
+                },
+                "groups": {
+                    "type": "reference",
+                    "ldapAttribute": "isMemberOf",
+                    "baseDn": "ou=groups,dc=example,dc=com",
+                    "isMultiValued": true,
+                    "writability": "readOnly",
+                    "primaryKey": "cn",
+                    "mapper": {
+                        "type": "object",
+                        "properties": {
+                            "_id": {
+                                "type": "simple",
+                                "ldapAttribute": "cn"
+                            }
+                        }
+                    }
+                },
+                "contactInformation": {
+                    "type": "object",
+                    "properties": {
+                        "telephoneNumber": {
+                            "type": "simple"
+                        },
+                        "emailAddress": {
+                            "type": "simple",
+                            "ldapAttribute": "mail"
+                        }
+                    }
+                }
+            }
+        },
+        // A user with POSIX account information.
+        "frapi:opendj:rest2ldap:posixUser:1.0": {
+            "superType": "frapi:opendj:rest2ldap:user:1.0",
+            "objectClasses": [ "posixAccount" ],
+            "properties": {
+                "uidNumber": {
+                    "type": "simple",
+                    "isRequired": true
+                },
+                "gidNumber": {
+                    "type": "simple",
+                    "isRequired": true
+                },
+                "homeDirectory": {
+                    "type": "simple",
+                    "isRequired": true
+                },
+                "loginShell": {
+                    "type": "simple"
+                },
+                "gecos": {
+                    "type": "simple"
+                }
+            }
+        },
+        // A "group" resource includes property mapping for the inetOrgPerson LDAP object class and is identified by
+        // the "uid" LDAP attribute. Users have a single sub-type representing users with POSIX account information.
+        "frapi:opendj:rest2ldap:group:1.0": {
+            "superType": "frapi:opendj:rest2ldap:object:1.0",
+            "objectClasses": [ "groupOfUniqueNames" ],
+            "properties": {
+                "_id": {
+                    "type": "simple",
+                    "ldapAttribute": "cn",
+                    "isRequired": true,
+                    "writability": "createOnly"
+                },
+                "displayName": {
+                    "type": "simple",
+                    "ldapAttribute": "cn",
+                    "isRequired": true,
+                    "writability": "readOnly"
+                },
+                "description": {
+                    "type": "simple"
+                },
+                "members": {
+                    "type": "reference",
+                    "ldapAttribute": "uniqueMember",
+                    "baseDn": "dc=example,dc=com",
+                    "primaryKey": "uid",
+                    "isMultiValued": true,
+                    "mapper": {
+                        "type": "object",
+                        "properties": {
+                            "_id": {
+                                "type": "simple",
+                                "ldapAttribute": "uid",
+                                "isRequired": true
+                            },
+                            "displayName": {
+                                "type": "simple",
+                                "ldapAttribute": "cn",
+                                "writability": "readOnlyDiscardWrites"
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/rest2ldap/rest2ldap.json b/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/rest2ldap/rest2ldap.json
new file mode 100644
index 0000000..49ce74e
--- /dev/null
+++ b/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/rest2ldap/rest2ldap.json
@@ -0,0 +1,8 @@
+{
+    // Options controlling how Rest2Ldap interacts with LDAP servers.
+    "useMvcc": true,
+    "mvccAttribute": "etag",
+    "readOnUpdatePolicy": "controls",
+    "useSubtreeDelete": true,
+    "usePermissiveModify": true
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractLdapPropertyMapper.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractLdapPropertyMapper.java
index 66fb8d1..23f37fd 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractLdapPropertyMapper.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractLdapPropertyMapper.java
@@ -24,6 +24,8 @@
 import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException;
 import static org.forgerock.opendj.rest2ldap.Utils.newNotSupportedException;
 import static org.forgerock.opendj.rest2ldap.WritabilityPolicy.READ_WRITE;
+import static org.forgerock.util.promise.Promises.newExceptionPromise;
+import static org.forgerock.util.promise.Promises.newResultPromise;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -54,7 +56,7 @@
     List<Object> defaultJsonValues = emptyList();
     final AttributeDescription ldapAttributeName;
     private boolean isRequired;
-    private boolean isSingleValued;
+    private boolean isMultiValued;
     private WritabilityPolicy writabilityPolicy = READ_WRITE;
 
     AbstractLdapPropertyMapper(final AttributeDescription ldapAttributeName) {
@@ -62,30 +64,29 @@
     }
 
     /**
-     * Indicates that the LDAP attribute is mandatory and must be provided
-     * during create requests.
+     * Indicates that the LDAP attribute is mandatory and must be provided during create requests.
      *
+     * @param isRequired {@code true} if this property is required.
      * @return This property mapper.
      */
-    public final T isRequired() {
-        this.isRequired = true;
+    public final T isRequired(final boolean isRequired) {
+        this.isRequired = isRequired;
         return getThis();
     }
 
     /**
-     * Indicates that multi-valued LDAP attribute should be represented as a
-     * single-valued JSON value, rather than an array of values.
+     * Indicates that the LDAP attribute is multi-valued and should be represented in JSON using an array of values.
      *
+     * @param isMultiValued {@code true} if this property is multi-valued.
      * @return This property mapper.
      */
-    public final T isSingleValued() {
-        this.isSingleValued = true;
+    public final T isMultiValued(final boolean isMultiValued) {
+        this.isMultiValued = isMultiValued;
         return getThis();
     }
 
     /**
-     * Indicates whether the LDAP attribute supports updates.
-     * The default is {@link WritabilityPolicy#READ_WRITE}.
+     * Indicates whether the LDAP attribute supports updates. The default is {@link WritabilityPolicy#READ_WRITE}.
      *
      * @param policy
      *            The writability policy.
@@ -97,13 +98,14 @@
     }
 
     boolean attributeIsSingleValued() {
-        return isSingleValued || ldapAttributeName.getAttributeType().isSingleValue();
+        return !isMultiValued || ldapAttributeName.getAttributeType().isSingleValue();
     }
 
     @Override
-    Promise<List<Attribute>, ResourceException> create(
-            final Connection connection, final JsonPointer path, final JsonValue v) {
-        return getNewLdapAttributes(connection, path, v).then(
+    Promise<List<Attribute>, ResourceException> create(final Connection connection,
+                                                       final Resource resource, final JsonPointer path,
+                                                       final JsonValue v) {
+        return getNewLdapAttributes(connection, resource, path, v).then(
             new Function<Attribute, List<Attribute>, ResourceException>() {
                 @Override
                 public List<Attribute> apply(Attribute newLDAPAttribute) throws ResourceException {
@@ -114,38 +116,33 @@
                         return Collections.emptyList();
                     } else if (newLDAPAttribute.isEmpty()) {
                         if (isRequired) {
-                            throw newBadRequestException(ERR_REMOVE_REQUIRED_FIELD.get("create", path));
+                            throw newBadRequestException(ERR_MISSING_REQUIRED_FIELD.get(path));
                         }
                         return Collections.emptyList();
                     }
-
                     return singletonList(newLDAPAttribute);
                 }
             });
     }
 
     @Override
-    void getLdapAttributes(final Connection connection, final JsonPointer path,
-                           final JsonPointer subPath, final Set<String> ldapAttributes) {
+    void getLdapAttributes(final JsonPointer path, final JsonPointer subPath, final Set<String> ldapAttributes) {
         ldapAttributes.add(ldapAttributeName.toString());
     }
 
-    abstract Promise<Attribute, ResourceException> getNewLdapAttributes(Connection connection, JsonPointer path,
-                                                                        List<Object> newValues);
+    abstract Promise<Attribute, ResourceException> getNewLdapAttributes(Connection connection, Resource resource,
+                                                                        JsonPointer path, List<Object> newValues);
 
     abstract T getThis();
 
     @Override
-    Promise<List<Modification>, ResourceException> patch(
-                final Connection connection, final JsonPointer path, final PatchOperation operation) {
+    Promise<List<Modification>, ResourceException> patch(final Connection connection, final Resource resource,
+                                                         final JsonPointer path, final PatchOperation operation) {
         try {
             final JsonPointer field = operation.getField();
             final JsonValue v = operation.getValue();
 
-            /*
-             * Reject any attempts to patch this field if it is read-only, even
-             * if it is configured to discard writes.
-             */
+            // Reject any attempts to patch this field if it is read-only, even if it is configured to discard writes.
             if (!writabilityPolicy.canWrite(ldapAttributeName)) {
                 throw newBadRequestException(ERR_MODIFY_READ_ONLY_FIELD.get("patch", path));
             }
@@ -211,8 +208,7 @@
                  * LDAP attribute is multi-valued, or the attribute already
                  * contains a value.
                  */
-                modType =
-                        attributeIsSingleValued() ? ModificationType.REPLACE : ModificationType.ADD;
+                modType = attributeIsSingleValued() ? ModificationType.REPLACE : ModificationType.ADD;
                 if (newValues.isEmpty()) {
                     throw newBadRequestException(ERR_PATCH_ADD_NO_VALUE_FOR_FIELD.get(path.child(field.get(0))));
                 }
@@ -233,11 +229,11 @@
                     return Promises.<List<Modification>, ResourceException> newExceptionPromise(
                             newBadRequestException(ERR_REMOVE_REQUIRED_FIELD.get("update", path)));
                 } else {
-                    return Promises.newResultPromise(
+                    return newResultPromise(
                         singletonList(new Modification(modType, emptyAttribute(ldapAttributeName))));
                 }
             } else {
-                return getNewLdapAttributes(connection, path, newValues)
+                return getNewLdapAttributes(connection, resource, path, newValues)
                         .then(new Function<Attribute, List<Modification>, ResourceException>() {
                             @Override
                             public List<Modification> apply(final Attribute value) {
@@ -246,16 +242,16 @@
                         });
             }
         } catch (final RuntimeException e) {
-            return Promises.newExceptionPromise(asResourceException(e));
+            return asResourceException(e).asPromise();
         } catch (final ResourceException e) {
-            return Promises.newExceptionPromise(e);
+            return newExceptionPromise(e);
         }
     }
 
     @Override
-    Promise<List<Modification>, ResourceException> update(final Connection connection, final JsonPointer path,
-            final Entry e, final JsonValue v) {
-        return getNewLdapAttributes(connection, path, v).then(
+    Promise<List<Modification>, ResourceException> update(final Connection connection, final Resource resource,
+                                                          final JsonPointer path, final Entry e, final JsonValue v) {
+        return getNewLdapAttributes(connection, resource, path, v).then(
             new Function<Attribute, List<Modification>, ResourceException>() {
                 @Override
                 public List<Modification> apply(final Attribute newLDAPAttribute) throws ResourceException {
@@ -338,19 +334,20 @@
     }
 
     private Promise<Attribute, ResourceException> getNewLdapAttributes(final Connection connection,
-                                                                       final JsonPointer path, final JsonValue v) {
+                                                                       final Resource resource, final JsonPointer path,
+                                                                       final JsonValue v) {
         try {
             // Ensure that the value is of the correct type.
             checkSchema(path, v);
             final List<Object> newValues = asList(v, defaultJsonValues);
             if (newValues.isEmpty()) {
                 // Skip sub-class implementation if there are no values.
-                return Promises.newResultPromise(emptyAttribute(ldapAttributeName));
+                return newResultPromise(emptyAttribute(ldapAttributeName));
             } else {
-                return getNewLdapAttributes(connection, path, newValues);
+                return getNewLdapAttributes(connection, resource, path, newValues);
             }
-        } catch (final Exception ex) {
-            return Promises.newExceptionPromise(asResourceException(ex));
+        } catch (final Exception e) {
+            return asResourceException(e).asPromise();
         }
     }
 
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractRequestHandler.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractRequestHandler.java
new file mode 100644
index 0000000..b0f427b
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/AbstractRequestHandler.java
@@ -0,0 +1,84 @@
+/*
+ * 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 2016 ForgeRock AS.
+ *
+ */
+package org.forgerock.opendj.rest2ldap;
+
+import org.forgerock.json.resource.ActionRequest;
+import org.forgerock.json.resource.ActionResponse;
+import org.forgerock.json.resource.CreateRequest;
+import org.forgerock.json.resource.DeleteRequest;
+import org.forgerock.json.resource.PatchRequest;
+import org.forgerock.json.resource.QueryRequest;
+import org.forgerock.json.resource.QueryResourceHandler;
+import org.forgerock.json.resource.QueryResponse;
+import org.forgerock.json.resource.ReadRequest;
+import org.forgerock.json.resource.RequestHandler;
+import org.forgerock.json.resource.ResourceException;
+import org.forgerock.json.resource.ResourceResponse;
+import org.forgerock.json.resource.UpdateRequest;
+import org.forgerock.services.context.Context;
+import org.forgerock.util.promise.Promise;
+
+/**
+ * An abstract base class from which request handlers may be easily implemented. The default implementation of each
+ * method is to return the {@link ResourceException} passed in during construction.
+ */
+abstract class AbstractRequestHandler implements RequestHandler {
+    private final ResourceException defaultErrorResponse;
+
+    AbstractRequestHandler(final ResourceException defaultErrorResponse) {
+        this.defaultErrorResponse = defaultErrorResponse;
+    }
+
+    @Override
+    public Promise<ActionResponse, ResourceException> handleAction(final Context context, final ActionRequest request) {
+        return defaultErrorResponse.asPromise();
+    }
+
+    @Override
+    public Promise<ResourceResponse, ResourceException> handleCreate(final Context context,
+                                                                     final CreateRequest request) {
+        return defaultErrorResponse.asPromise();
+    }
+
+    @Override
+    public Promise<ResourceResponse, ResourceException> handleDelete(final Context context,
+                                                                     final DeleteRequest request) {
+        return defaultErrorResponse.asPromise();
+    }
+
+    @Override
+    public Promise<ResourceResponse, ResourceException> handlePatch(final Context context, final PatchRequest request) {
+        return defaultErrorResponse.asPromise();
+    }
+
+    @Override
+    public Promise<QueryResponse, ResourceException> handleQuery(final Context context, final QueryRequest request,
+                                                                 final QueryResourceHandler handler) {
+        return defaultErrorResponse.asPromise();
+    }
+
+    @Override
+    public Promise<ResourceResponse, ResourceException> handleRead(final Context context, final ReadRequest request) {
+        return defaultErrorResponse.asPromise();
+    }
+
+    @Override
+    public Promise<ResourceResponse, ResourceException> handleUpdate(final Context context,
+                                                                     final UpdateRequest request) {
+        return defaultErrorResponse.asPromise();
+    }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Action.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Action.java
new file mode 100644
index 0000000..1cd7ee6
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Action.java
@@ -0,0 +1,44 @@
+/*
+ * 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 2016 ForgeRock AS.
+ */
+package org.forgerock.opendj.rest2ldap;
+
+/**
+ * Represents an {@link org.forgerock.json.resource.ActionRequest action} that may be performed against a resource.
+ * Resources will only accept actions which have been {@link Resource#supportedAction(Action) registered} as being
+ * supported.
+ */
+public enum Action {
+    // Notes:
+    //
+    // - actions are likely to become an extension point in future versions of Rest2Ldap, in which case this enum
+    //   will need to be converted into a regular class or interface,
+    //
+    // - the actions are named so that they can be parsed easily from JSON.
+
+    /** An action that allows users to change or reset their password. */
+    PASSWORDMODIFY("passwordModify");
+
+    private final String actionId;
+
+    Action(final String actionId) {
+        this.actionId = actionId;
+    }
+
+    @Override
+    public String toString() {
+        return actionId;
+    }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Config.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Config.java
deleted file mode 100644
index 8a98cb0..0000000
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Config.java
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * 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-2016 ForgeRock AS.
- */
-package org.forgerock.opendj.rest2ldap;
-
-import org.forgerock.opendj.ldap.DecodeOptions;
-import org.forgerock.opendj.ldap.schema.Schema;
-
-/**
- * Common configuration options.
- */
-final class Config {
-    private final DecodeOptions options;
-    private final ReadOnUpdatePolicy readOnUpdatePolicy;
-    private final boolean useSubtreeDelete;
-    private final boolean usePermissiveModify;
-
-    Config(final ReadOnUpdatePolicy readOnUpdatePolicy, final boolean useSubtreeDelete,
-            final boolean usePermissiveModify, final Schema schema) {
-        this.readOnUpdatePolicy = readOnUpdatePolicy;
-        this.useSubtreeDelete = useSubtreeDelete;
-        this.usePermissiveModify = usePermissiveModify;
-        this.options = new DecodeOptions().setSchema(schema);
-    }
-
-    /**
-     * Returns the decoding options which should be used when decoding controls
-     * in responses.
-     *
-     * @return The decoding options which should be used when decoding controls
-     *         in responses.
-     */
-    DecodeOptions decodeOptions() {
-        return options;
-    }
-
-    /**
-     * 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.
-     *
-     * @return {@code true} if delete requests should include the subtree delete
-     *         control.
-     */
-    boolean useSubtreeDelete() {
-        return useSubtreeDelete;
-    }
-
-    /**
-     * Returns the policy which should be used in order to read an entry before
-     * it is deleted, or after it is added or modified.
-     *
-     * @return The policy which should be used in order to read an entry before
-     *         it is deleted, or after it is added or modified.
-     */
-    ReadOnUpdatePolicy readOnUpdatePolicy() {
-        return readOnUpdatePolicy;
-    }
-}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JsonConstantPropertyMapper.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JsonConstantPropertyMapper.java
index 230d2b7..f171ad8 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JsonConstantPropertyMapper.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JsonConstantPropertyMapper.java
@@ -22,6 +22,7 @@
 import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException;
 import static org.forgerock.opendj.rest2ldap.Utils.toFilter;
 import static org.forgerock.opendj.rest2ldap.Utils.toLowerCase;
+import static org.forgerock.util.promise.Promises.newResultPromise;
 
 import java.util.Collections;
 import java.util.List;
@@ -37,7 +38,6 @@
 import org.forgerock.opendj.ldap.Filter;
 import org.forgerock.opendj.ldap.Modification;
 import org.forgerock.util.promise.Promise;
-import org.forgerock.util.promise.Promises;
 
 /**
  * An property mapper which maps a single JSON attribute to a fixed value.
@@ -55,26 +55,26 @@
     }
 
     @Override
-    Promise<List<Attribute>, ResourceException> create(final Connection connection, final JsonPointer path,
-            final JsonValue v) {
+    Promise<List<Attribute>, ResourceException> create(final Connection connection,
+                                                       final Resource resource, final JsonPointer path,
+                                                       final JsonValue v) {
         if (!isNullOrEmpty(v) && !v.getObject().equals(value.getObject())) {
-            return Promises.<List<Attribute>, ResourceException> newExceptionPromise(
-                    newBadRequestException(ERR_CREATION_READ_ONLY_FIELD.get(path)));
+            return newBadRequestException(ERR_CREATION_READ_ONLY_FIELD.get(path)).asPromise();
         } else {
-            return Promises.newResultPromise(Collections.<Attribute> emptyList());
+            return newResultPromise(Collections.<Attribute> emptyList());
         }
     }
 
     @Override
-    void getLdapAttributes(final Connection connection, final JsonPointer path, final JsonPointer subPath,
-                           final Set<String> ldapAttributes) {
+    void getLdapAttributes(final JsonPointer path, final JsonPointer subPath, final Set<String> ldapAttributes) {
         // Nothing to do.
     }
 
     @Override
-    Promise<Filter, ResourceException> getLdapFilter(final Connection connection, final JsonPointer path,
-                                                     final JsonPointer subPath, final FilterType type,
-                                                     final String operator, final Object valueAssertion) {
+    Promise<Filter, ResourceException> getLdapFilter(final Connection connection, final Resource resource,
+                                                     final JsonPointer path, final JsonPointer subPath,
+                                                     final FilterType type, final String operator,
+                                                     final Object valueAssertion) {
         final Filter filter;
         final JsonValue subValue = value.get(subPath);
         if (subValue == null) {
@@ -107,29 +107,28 @@
             // This property mapper is a candidate but it does not match.
             filter = alwaysFalse();
         }
-        return Promises.newResultPromise(filter);
+        return newResultPromise(filter);
     }
 
     @Override
-    Promise<List<Modification>, ResourceException> patch(final Connection connection, final JsonPointer path,
-            final PatchOperation operation) {
-        return Promises.<List<Modification>, ResourceException> newExceptionPromise(
-                newBadRequestException(ERR_PATCH_READ_ONLY_FIELD.get(path)));
+    Promise<List<Modification>, ResourceException> patch(final Connection connection, final Resource resource,
+                                                         final JsonPointer path, final PatchOperation operation) {
+        return newBadRequestException(ERR_PATCH_READ_ONLY_FIELD.get(path)).asPromise();
     }
 
     @Override
-    Promise<JsonValue, ResourceException> read(final Connection connection, final JsonPointer path, final Entry e) {
-        return Promises.newResultPromise(value.copy());
+    Promise<JsonValue, ResourceException> read(final Connection connection, final Resource resource,
+                                               final JsonPointer path, final Entry e) {
+        return newResultPromise(value.copy());
     }
 
     @Override
-    Promise<List<Modification>, ResourceException> update(
-            final Connection connection, final JsonPointer path, final Entry e, final JsonValue v) {
+    Promise<List<Modification>, ResourceException> update(final Connection connection, final Resource resource,
+                                                          final JsonPointer path, final Entry e, final JsonValue v) {
         if (!isNullOrEmpty(v) && !v.getObject().equals(value.getObject())) {
-            return Promises.<List<Modification>, ResourceException> newExceptionPromise(
-                    newBadRequestException(ERR_MODIFY_READ_ONLY_FIELD.get("update", path)));
+            return newBadRequestException(ERR_MODIFY_READ_ONLY_FIELD.get("update", path)).asPromise();
         } else {
-            return Promises.newResultPromise(Collections.<Modification> emptyList());
+            return newResultPromise(Collections.<Modification>emptyList());
         }
     }
 
@@ -149,5 +148,4 @@
             return alwaysFalse(); // Not supported.
         }
     }
-
 }
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/NamingStrategy.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/NamingStrategy.java
index e180964..52366ef 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/NamingStrategy.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/NamingStrategy.java
@@ -13,88 +13,57 @@
  *
  * Copyright 2013-2016 ForgeRock AS.
  */
-
 package org.forgerock.opendj.rest2ldap;
 
-import java.util.Set;
-
 import org.forgerock.json.resource.ResourceException;
-import org.forgerock.opendj.ldap.Connection;
 import org.forgerock.opendj.ldap.DN;
 import org.forgerock.opendj.ldap.Entry;
 import org.forgerock.opendj.ldap.requests.SearchRequest;
 
 /**
- * A naming strategy is responsible for naming REST resources and LDAP entries.
+ * A naming strategy is responsible for naming JSON resources and LDAP entries.
  */
-abstract class NamingStrategy {
-    /*
-     * This interface is an abstract class so that methods can be made package
-     * private until API is finalized.
-     */
-
-    NamingStrategy() {
-        // Nothing to do.
-    }
-
+interface NamingStrategy {
     /**
-     * Returns a search request which can be used to obtain the specified REST
-     * resource.
+     * Returns a search request which can be used to obtain the specified JSON resource.
      *
-     * @param connection
-     *            The LDAP connection to use to perform the operation.
-     * @param baseDN
-     *            The search base DN.
+     * @param baseDn
+     *         The search base DN.
      * @param resourceId
-     *            The resource ID.
-     * @return A search request which can be used to obtain the specified REST
-     *         resource.
+     *         The resource ID.
+     * @return A search request which can be used to obtain the specified JSON resource.
      */
-    abstract SearchRequest createSearchRequest(Connection connection, DN baseDN, String resourceId);
+    SearchRequest createSearchRequest(DN baseDn, String resourceId);
 
     /**
-     * Adds the name of any LDAP attribute required by this naming strategy to the
-     * provided set.
+     * Returns the name of the LDAP attribute from which this naming strategy computes the JSON resource ID.
      *
-     * @param connection
-     *            The LDAP connection to use to perform the operation.
-     * @param ldapAttributes
-     *            The set into which any required LDAP attribute name should be
-     *            put.
+     * @return The name of the LDAP attribute from which this naming strategy computes the JSON resource ID.
      */
-    abstract void getLdapAttributes(Connection connection, Set<String> ldapAttributes);
+    String getResourceIdLdapAttribute();
 
     /**
-     * Retrieves the resource ID from the provided LDAP entry. Implementations
-     * may use the entry DN as well as any attributes in order to determine the
-     * resource ID.
+     * Decodes the JSON resource ID from the provided LDAP entry. Implementations may use the entry DN as well as any
+     * attributes in order to determine the resource ID.
      *
-     * @param connection
-     *            The LDAP connection to use to perform the operation.
      * @param entry
-     *            The LDAP entry from which the resource ID should be obtained.
-     * @return The resource ID.
+     *         The LDAP entry from which the resource ID should be obtained.
+     * @return The resource ID or {@code null} if the resource ID will be obtained from the resource's "_id" field.
      */
-    abstract String getResourceId(Connection connection, Entry entry);
+    String decodeResourceId(Entry entry);
 
     /**
-     * Sets the resource ID in the provided LDAP entry. Implementations are
-     * responsible for setting the entry DN as well as any attributes associated
-     * with the resource ID.
+     * Encodes the JSON resource ID in the provided LDAP entry. Implementations are responsible for setting the entry
+     * DN as well as any attributes associated with the resource ID.
      *
-     * @param connection
-     *            The LDAP connection to use to perform the operation.
-     * @param baseDN
-     *            The baseDN to use when constructing the entry's DN.
+     * @param baseDn
+     *         The base DN to use when constructing the entry's DN.
      * @param resourceId
-     *            The resource ID.
+     *         The resource ID.
      * @param entry
-     *            The LDAP entry whose DN and resource ID attributes are to be
-     *            set.
+     *         The LDAP entry whose DN and resource ID attributes are to be set.
      * @throws ResourceException
-     *             If the resource ID cannot be determined.
+     *         If the resource ID cannot be determined.
      */
-    abstract void setResourceId(Connection connection, DN baseDN, String resourceId, Entry entry)
-            throws ResourceException;
-
+    void encodeResourceId(DN baseDn, String resourceId, Entry entry) throws ResourceException;
 }
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectPropertyMapper.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectPropertyMapper.java
index 47aab51..4aa4ab1 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectPropertyMapper.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ObjectPropertyMapper.java
@@ -15,20 +15,25 @@
  */
 package org.forgerock.opendj.rest2ldap;
 
+import static org.forgerock.opendj.rest2ldap.Rest2Ldap.simple;
 import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
 import static org.forgerock.json.resource.PatchOperation.operation;
 import static org.forgerock.opendj.ldap.Filter.alwaysFalse;
 import static org.forgerock.opendj.rest2ldap.Rest2Ldap.asResourceException;
 import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException;
 import static org.forgerock.opendj.rest2ldap.Utils.toLowerCase;
+import static org.forgerock.util.Utils.joinAsString;
+import static org.forgerock.util.promise.Promises.newResultPromise;
 
-import java.util.AbstractMap.SimpleImmutableEntry;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.TreeSet;
 
 import org.forgerock.json.JsonPointer;
 import org.forgerock.json.JsonValue;
@@ -40,12 +45,12 @@
 import org.forgerock.opendj.ldap.Filter;
 import org.forgerock.opendj.ldap.Modification;
 import org.forgerock.util.Function;
+import org.forgerock.util.Pair;
 import org.forgerock.util.promise.Promise;
 import org.forgerock.util.promise.Promises;
 
 /** An property mapper which maps JSON objects to LDAP attributes. */
 public final class ObjectPropertyMapper extends PropertyMapper {
-
     private static final class Mapping {
         private final PropertyMapper mapper;
         private final String name;
@@ -63,39 +68,80 @@
 
     private final Map<String, Mapping> mappings = new LinkedHashMap<>();
 
+    private boolean includeAllUserAttributesByDefault = false;
+    private final Set<String> excludedDefaultUserAttributes = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+
     ObjectPropertyMapper() {
         // Nothing to do.
     }
 
     /**
-     * Creates a mapping for an attribute contained in the JSON object.
+     * Creates an explicit mapping for a property contained in the JSON object. When user attributes are
+     * {@link #includeAllUserAttributesByDefault included} by default, be careful to {@link
+     * #excludedDefaultUserAttributes exclude} any attributes which have explicit mappings defined using this method,
+     * otherwise they will be duplicated in the JSON representation.
      *
      * @param name
-     *            The name of the JSON attribute to be mapped.
+     *            The name of the JSON property to be mapped.
      * @param mapper
-     *            The property mapper responsible for mapping the JSON
-     *            attribute to LDAP attribute(s).
+     *            The property mapper responsible for mapping the JSON attribute to LDAP attribute(s).
      * @return A reference to this property mapper.
      */
-    public ObjectPropertyMapper attribute(final String name, final PropertyMapper mapper) {
+    public ObjectPropertyMapper property(final String name, final PropertyMapper mapper) {
         mappings.put(toLowerCase(name), new Mapping(name, mapper));
         return this;
     }
 
+    /**
+     * Specifies whether all LDAP user attributes should be mapped by default using the default schema based mapping
+     * rules. Individual attributes can be excluded using {@link #excludedDefaultUserAttributes} in order to prevent
+     * attributes with explicit mappings being mapped twice.
+     *
+     * @param include {@code true} if all LDAP user attributes be mapped by default.
+     * @return A reference to this property mapper.
+     */
+    public ObjectPropertyMapper includeAllUserAttributesByDefault(final boolean include) {
+        this.includeAllUserAttributesByDefault = include;
+        return this;
+    }
+
+    /**
+     * Specifies zero or more user attributes which will be excluded from the default user attribute mappings when
+     * enabled using {@link #includeAllUserAttributesByDefault}. Attributes which have explicit mappings should be
+     * excluded in order to prevent duplication.
+     *
+     * @param attributeNames The list of attributes to be excluded.
+     * @return A reference to this property mapper.
+     */
+    public ObjectPropertyMapper excludedDefaultUserAttributes(final String... attributeNames) {
+        return excludedDefaultUserAttributes(Arrays.asList(attributeNames));
+    }
+
+    /**
+     * Specifies zero or more user attributes which will be excluded from the default user attribute mappings when
+     * enabled using {@link #includeAllUserAttributesByDefault}. Attributes which have explicit mappings should be
+     * excluded in order to prevent duplication.
+     *
+     * @param attributeNames The list of attributes to be excluded.
+     * @return A reference to this property mapper.
+     */
+    public ObjectPropertyMapper excludedDefaultUserAttributes(final Collection<String> attributeNames) {
+        excludedDefaultUserAttributes.addAll(attributeNames);
+        return this;
+    }
+
     @Override
     public String toString() {
-        return "object(" + mappings.values() + ")";
+        return "object(" + joinAsString(", ", mappings.values()) + ")";
     }
 
     @Override
-    Promise<List<Attribute>, ResourceException> create(final Connection connection, final JsonPointer path,
-            final JsonValue v) {
+    Promise<List<Attribute>, ResourceException> create(final Connection connection,
+                                                       final Resource resource, final JsonPointer path,
+                                                       final JsonValue v) {
         try {
-            /*
-             * First check that the JSON value is an object and that the fields
-             * it contains are known by this mapper.
-             */
-            final Map<String, Mapping> missingMappings = checkMapping(path, v);
+            // First check that the JSON value is an object and that the fields it contains are known by this mapper.
+            final Map<String, Mapping> missingMappings = validateJsonValue(path, v);
 
             // Accumulate the results of the subordinate mappings.
             final List<Promise<List<Attribute>, ResourceException>> promises = new ArrayList<>();
@@ -105,61 +151,70 @@
                 for (final Map.Entry<String, Object> me : v.asMap().entrySet()) {
                     final Mapping mapping = getMapping(me.getKey());
                     final JsonValue subValue = new JsonValue(me.getValue());
-                    promises.add(mapping.mapper.create(connection, path.child(me.getKey()), subValue));
+                    promises.add(mapping.mapper.create(connection, resource, path.child(me.getKey()),
+                                                       subValue));
                 }
             }
 
             // Invoke mappings for which there were no values provided.
             for (final Mapping mapping : missingMappings.values()) {
-                promises.add(mapping.mapper.create(connection, path.child(mapping.name), null));
+                promises.add(mapping.mapper.create(connection, resource, path.child(mapping.name), null));
             }
 
             return Promises.when(promises)
                            .then(this.<Attribute> accumulateResults());
         } catch (final Exception e) {
-            return Promises.newExceptionPromise(asResourceException(e));
+            return asResourceException(e).asPromise();
         }
     }
 
     @Override
-    void getLdapAttributes(final Connection connection, final JsonPointer path, final JsonPointer subPath,
-                           final Set<String> ldapAttributes) {
+    void getLdapAttributes(final JsonPointer path, final JsonPointer subPath, final Set<String> ldapAttributes) {
         if (subPath.isEmpty()) {
             // Request all subordinate mappings.
+            if (includeAllUserAttributesByDefault) {
+                ldapAttributes.add("*");
+                // Continue because there may be explicit mappings for operational attributes.
+            }
             for (final Mapping mapping : mappings.values()) {
-                mapping.mapper.getLdapAttributes(connection, path.child(mapping.name), subPath, ldapAttributes);
+                mapping.mapper.getLdapAttributes(path.child(mapping.name), subPath, ldapAttributes);
             }
         } else {
             // Request single subordinate mapping.
-            final Mapping mapping = getMapping(subPath);
+            final Mapping mapping = getMappingOrNull(subPath);
             if (mapping != null) {
-                mapping.mapper.getLdapAttributes(
-                        connection, path.child(subPath.get(0)), subPath.relativePointer(), ldapAttributes);
+                mapping.mapper.getLdapAttributes(path.child(subPath.get(0)), subPath.relativePointer(), ldapAttributes);
             }
         }
     }
 
     @Override
-    Promise<Filter, ResourceException> getLdapFilter(final Connection connection, final JsonPointer path,
-                                                     final JsonPointer subPath, final FilterType type,
-                                                     final String operator, final Object valueAssertion) {
-        final Mapping mapping = getMapping(subPath);
+    Promise<Filter, ResourceException> getLdapFilter(final Connection connection, final Resource resource,
+                                                     final JsonPointer path, final JsonPointer subPath,
+                                                     final FilterType type, final String operator,
+                                                     final Object valueAssertion) {
+        final Mapping mapping = getMappingOrNull(subPath);
         if (mapping != null) {
-            return mapping.mapper.getLdapFilter(connection, path.child(subPath.get(0)),
-                                                subPath.relativePointer(), type, operator, valueAssertion);
+            return mapping.mapper.getLdapFilter(connection,
+                                                resource,
+                                                path.child(subPath.get(0)),
+                                                subPath.relativePointer(),
+                                                type,
+                                                operator,
+                                                valueAssertion);
         } else {
             /*
              * Either the filter targeted the entire object (i.e. it was "/"),
              * or it targeted an unrecognized attribute within the object.
              * Either way, the filter will never match.
              */
-            return Promises.newResultPromise(alwaysFalse());
+            return newResultPromise(alwaysFalse());
         }
     }
 
     @Override
-    Promise<List<Modification>, ResourceException> patch(final Connection connection, final JsonPointer path,
-            final PatchOperation operation) {
+    Promise<List<Modification>, ResourceException> patch(final Connection connection, final Resource resource,
+                                                         final JsonPointer path, final PatchOperation operation) {
         try {
             final JsonPointer field = operation.getField();
             final JsonValue v = operation.getValue();
@@ -170,7 +225,7 @@
                  * by allowing the JSON value to be a partial object and
                  * add/remove/replace only the provided values.
                  */
-                checkMapping(path, v);
+                validateJsonValue(path, v);
 
                 // Accumulate the results of the subordinate mappings.
                 final List<Promise<List<Modification>, ResourceException>> promises = new ArrayList<>();
@@ -182,7 +237,7 @@
                         final JsonValue subValue = new JsonValue(me.getValue());
                         final PatchOperation subOperation =
                                 operation(operation.getOperation(), field /* empty */, subValue);
-                        promises.add(mapping.mapper.patch(connection, path.child(me.getKey()), subOperation));
+                        promises.add(mapping.mapper.patch(connection, resource, path.child(me.getKey()), subOperation));
                     }
                 }
 
@@ -195,71 +250,93 @@
                  * appropriate mapper.
                  */
                 final String fieldName = field.get(0);
-                final Mapping mapping = getMapping(fieldName);
+                final Mapping mapping = getMappingOrNull(fieldName);
                 if (mapping == null) {
                     throw newBadRequestException(ERR_UNRECOGNIZED_FIELD.get(path.child(fieldName)));
                 }
                 final PatchOperation subOperation =
                         operation(operation.getOperation(), field.relativePointer(), v);
-                return mapping.mapper.patch(connection, path.child(fieldName), subOperation);
+                return mapping.mapper.patch(connection, resource, path.child(fieldName), subOperation);
             }
-        } catch (final Exception ex) {
-            return Promises.newExceptionPromise(asResourceException(ex));
+        } catch (final Exception e) {
+            return asResourceException(e).asPromise();
         }
     }
 
     @Override
-    Promise<JsonValue, ResourceException> read(final Connection connection, final JsonPointer path, final Entry e) {
+    Promise<JsonValue, ResourceException> read(final Connection connection, final Resource resource,
+                                               final JsonPointer path, final Entry e) {
         /*
          * Use an accumulator which will aggregate the results from the
          * subordinate mappers into a single list. On completion, the
          * accumulator combines the results into a single JSON map object.
          */
-        final List<Promise<Map.Entry<String, JsonValue>, ResourceException>> promises =
+        final List<Promise<Pair<String, JsonValue>, ResourceException>> promises =
                 new ArrayList<>(mappings.size());
 
         for (final Mapping mapping : mappings.values()) {
-            promises.add(mapping.mapper.read(connection, path.child(mapping.name), e)
-                    .then(new Function<JsonValue, Map.Entry<String, JsonValue>, ResourceException>() {
-                        @Override
-                        public Map.Entry<String, JsonValue> apply(final JsonValue value) {
-                            return value != null ? new SimpleImmutableEntry<String, JsonValue>(mapping.name, value)
-                                                 : null;
-                        }
-                    }));
+            promises.add(mapping.mapper.read(connection, resource, path.child(mapping.name), e)
+                                       .then(toProperty(mapping.name)));
+        }
+
+        if (includeAllUserAttributesByDefault) {
+            // Map all user attributes using a default simple mapping. It would be nice if we could automatically
+            // detect which attributes have been mapped already using explicit mappings, but it would require us to
+            // track which attributes have been accessed in the entry. Instead, we'll rely on the user to exclude
+            // attributes which have explicit mappings.
+            for (final Attribute attribute : e.getAllAttributes()) {
+                // Don't include operational attributes. They must have explicit mappings.
+                if (attribute.getAttributeDescription().getAttributeType().isOperational()) {
+                    continue;
+                }
+                // Filter out excluded attributes.
+                final String attributeName = attribute.getAttributeDescriptionAsString();
+                if (!excludedDefaultUserAttributes.isEmpty() && excludedDefaultUserAttributes.contains(attributeName)) {
+                    continue;
+                }
+                // This attribute needs to be mapped.
+                final SimplePropertyMapper mapper = simple(attribute.getAttributeDescription());
+                promises.add(mapper.read(connection, resource, path.child(attributeName), e)
+                                   .then(toProperty(attributeName)));
+            }
         }
 
         return Promises.when(promises)
-                .then(new Function<List<Map.Entry<String, JsonValue>>, JsonValue, ResourceException>() {
-                    @Override
-                    public JsonValue apply(final List<Map.Entry<String, JsonValue>> value) {
-                        if (value.isEmpty()) {
-                            /*
-                             * No subordinate attributes, so omit the entire
-                             * JSON object from the resource.
-                             */
-                            return null;
-                        } else {
-                            // Combine the sub-attributes into a single JSON object.
-                            final Map<String, Object> result = new LinkedHashMap<>(value.size());
-                            for (final Map.Entry<String, JsonValue> e : value) {
-                                if (e != null) {
-                                    result.put(e.getKey(), e.getValue().getObject());
-                                }
-                            }
-                            return new JsonValue(result);
-                        }
-                    }
-                });
+                       .then(new Function<List<Pair<String, JsonValue>>, JsonValue, ResourceException>() {
+                           @Override
+                           public JsonValue apply(final List<Pair<String, JsonValue>> value) {
+                               if (value.isEmpty()) {
+                                   // No subordinate attributes, so omit the entire JSON object from the resource.
+                                   return null;
+                               } else {
+                                   // Combine the sub-attributes into a single JSON object.
+                                   final Map<String, Object> result = new LinkedHashMap<>(value.size());
+                                   for (final Pair<String, JsonValue> e : value) {
+                                       if (e != null) {
+                                           result.put(e.getFirst(), e.getSecond().getObject());
+                                       }
+                                   }
+                                   return new JsonValue(result);
+                               }
+                           }
+                       });
+    }
+
+    private Function<JsonValue, Pair<String, JsonValue>, ResourceException> toProperty(final String name) {
+        return new Function<JsonValue, Pair<String, JsonValue>, ResourceException>() {
+            @Override
+            public Pair<String, JsonValue> apply(final JsonValue value) {
+                return value != null ? Pair.of(name, value) : null;
+            }
+        };
     }
 
     @Override
-    Promise<List<Modification>, ResourceException> update(
-            final Connection connection, final JsonPointer path, final Entry e, final JsonValue v) {
+    Promise<List<Modification>, ResourceException> update(final Connection connection, final Resource resource,
+                                                          final JsonPointer path, final Entry e, final JsonValue v) {
         try {
-            // First check that the JSON value is an object and that the fields
-            // it contains are known by this mapper.
-            final Map<String, Mapping> missingMappings = checkMapping(path, v);
+            // First check that the JSON value is an object and that the fields it contains are known by this mapper.
+            final Map<String, Mapping> missingMappings = validateJsonValue(path, v);
 
             // Accumulate the results of the subordinate mappings.
             final List<Promise<List<Modification>, ResourceException>> promises = new ArrayList<>();
@@ -269,19 +346,19 @@
                 for (final Map.Entry<String, Object> me : v.asMap().entrySet()) {
                     final Mapping mapping = getMapping(me.getKey());
                     final JsonValue subValue = new JsonValue(me.getValue());
-                    promises.add(mapping.mapper.update(connection, path.child(me.getKey()), e, subValue));
+                    promises.add(mapping.mapper.update(connection, resource, path.child(me.getKey()), e, subValue));
                 }
             }
 
             // Invoke mappings for which there were no values provided.
             for (final Mapping mapping : missingMappings.values()) {
-                promises.add(mapping.mapper.update(connection, path.child(mapping.name), e, null));
+                promises.add(mapping.mapper.update(connection, resource, path.child(mapping.name), e, null));
             }
 
             return Promises.when(promises)
                            .then(this.<Modification> accumulateResults());
         } catch (final Exception ex) {
-            return Promises.newExceptionPromise(asResourceException(ex));
+            return asResourceException(ex).asPromise();
         }
     }
 
@@ -306,13 +383,13 @@
     }
 
     /** Fail immediately if the JSON value has the wrong type or contains unknown attributes. */
-    private Map<String, Mapping> checkMapping(final JsonPointer path, final JsonValue v)
-            throws ResourceException {
+    private Map<String, Mapping> validateJsonValue(final JsonPointer path, final JsonValue v) throws ResourceException {
         final Map<String, Mapping> missingMappings = new LinkedHashMap<>(mappings);
         if (v != null && !v.isNull()) {
             if (v.isMap()) {
                 for (final String attribute : v.asMap().keySet()) {
-                    if (missingMappings.remove(toLowerCase(attribute)) == null) {
+                    if (missingMappings.remove(toLowerCase(attribute)) == null
+                            && !isIncludedDefaultUserAttribute(attribute)) {
                         throw newBadRequestException(ERR_UNRECOGNIZED_FIELD.get(path.child(attribute)));
                     }
                 }
@@ -323,12 +400,31 @@
         return missingMappings;
     }
 
-    private Mapping getMapping(final JsonPointer jsonAttribute) {
-        return jsonAttribute.isEmpty() ? null : getMapping(jsonAttribute.get(0));
+    private Mapping getMappingOrNull(final JsonPointer jsonAttribute) {
+        return jsonAttribute.isEmpty() ? null : getMappingOrNull(jsonAttribute.get(0));
+    }
+
+    private Mapping getMappingOrNull(final String jsonAttribute) {
+        final Mapping mapping = mappings.get(toLowerCase(jsonAttribute));
+        if (mapping != null) {
+            return mapping;
+        }
+        if (isIncludedDefaultUserAttribute(jsonAttribute)) {
+            return new Mapping(jsonAttribute, simple(jsonAttribute));
+        }
+        return null;
     }
 
     private Mapping getMapping(final String jsonAttribute) {
-        return mappings.get(toLowerCase(jsonAttribute));
+        final Mapping mappingOrNull = getMappingOrNull(jsonAttribute);
+        if (mappingOrNull != null) {
+            return mappingOrNull;
+        }
+        throw new IllegalStateException("Unexpected null mapping for jsonAttribute: " + jsonAttribute);
     }
 
+    private boolean isIncludedDefaultUserAttribute(final String attributeName) {
+        return includeAllUserAttributesByDefault
+                && (excludedDefaultUserAttributes.isEmpty() || !excludedDefaultUserAttributes.contains(attributeName));
+    }
 }
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/PropertyMapper.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/PropertyMapper.java
index 7714364..3755d3f 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/PropertyMapper.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/PropertyMapper.java
@@ -53,6 +53,7 @@
      *
      * @param connection
      *            The LDAP connection to use to perform the operation.
+     * @param resource The exact type of resource being created.
      * @param path
      *            The pointer from the root of the JSON resource to this
      *            property mapper. This may be used when constructing error
@@ -63,7 +64,8 @@
      *            in the resource.
      * @return A {@link Promise} containing the result of the operation.
      */
-    abstract Promise<List<Attribute>, ResourceException> create(Connection connection, JsonPointer path, JsonValue v);
+    abstract Promise<List<Attribute>, ResourceException> create(Connection connection, Resource resource,
+                                                                JsonPointer path, JsonValue v);
 
     /**
      * Adds the names of the LDAP attributes required by this property mapper
@@ -72,8 +74,6 @@
      * Implementations should only add the names of attributes found in the LDAP
      * entry directly associated with the resource.
      *
-     * @param connection
-     *            The LDAP connection to use to perform the operation.
      * @param path
      *            The pointer from the root of the JSON resource to this
      *            property mapper. This may be used when constructing error
@@ -83,11 +83,9 @@
      *            root if all attributes associated with this mapper have been
      *            targeted.
      * @param ldapAttributes
-     *            The set into which the required LDAP attribute names should be
-     *            put.
+ *            The set into which the required LDAP attribute names should be
      */
-    abstract void getLdapAttributes(Connection connection, JsonPointer path, JsonPointer subPath,
-                                    Set<String> ldapAttributes);
+    abstract void getLdapAttributes(JsonPointer path, JsonPointer subPath, Set<String> ldapAttributes);
 
     /**
      * Transforms the provided REST comparison filter parameters to an LDAP
@@ -100,6 +98,7 @@
      *
      * @param connection
      *            The LDAP connection to use to perform the operation.
+     * @param resource The type of resource being queried.
      * @param path
      *            The pointer from the root of the JSON resource to this
      *            property mapper. This may be used when constructing error
@@ -119,9 +118,9 @@
      *            {@link FilterType#PRESENT}.
      * @return A {@link Promise} containing the result of the operation.
      */
-    abstract Promise<Filter, ResourceException> getLdapFilter(Connection connection, JsonPointer path,
-                                                              JsonPointer subPath, FilterType type, String operator,
-                                                              Object valueAssertion);
+    abstract Promise<Filter, ResourceException> getLdapFilter(Connection connection, Resource resource,
+                                                              JsonPointer path, JsonPointer subPath, FilterType type,
+                                                              String operator, Object valueAssertion);
 
     /**
      * Maps a JSON patch operation to one or more LDAP modifications, returning
@@ -130,6 +129,7 @@
      *
      * @param connection
      *            The LDAP connection to use to perform the operation.
+     * @param resource The exact type of resource being patched.
      * @param path
      *            The pointer from the root of the JSON resource to this
      *            property mapper. This may be used when constructing error
@@ -141,8 +141,8 @@
      *            with this mapper have been targeted.
      * @return A {@link Promise} containing the result of the operation.
      */
-    abstract Promise<List<Modification>, ResourceException> patch(
-            Connection connection, JsonPointer path, PatchOperation operation);
+    abstract Promise<List<Modification>, ResourceException> patch(Connection connection, Resource resource,
+                                                                  JsonPointer path, PatchOperation operation);
 
     /**
      * Maps one or more LDAP attributes to their JSON representation, returning
@@ -161,6 +161,7 @@
      *
      * @param connection
      *            The LDAP connection to use to perform the operation.
+     * @param resource The exact type of resource being read.
      * @param path
      *            The pointer from the root of the JSON resource to this
      *            property mapper. This may be used when constructing error
@@ -169,7 +170,8 @@
      *            The LDAP entry to be converted to JSON.
      * @return A {@link Promise} containing the result of the operation.
      */
-    abstract Promise<JsonValue, ResourceException> read(Connection connection, JsonPointer path, Entry e);
+    abstract Promise<JsonValue, ResourceException> read(Connection connection, Resource resource,
+                                                        JsonPointer path, Entry e);
 
     /**
      * Maps a JSON value to one or more LDAP modifications, returning a promise
@@ -184,14 +186,15 @@
      *
      * @param connection
      *            The LDAP connection to use to perform the operation.
+     * @param resource The exact type of resource being updated.
      * @param v
      *            The JSON value to be converted to LDAP attributes, which may
      *            be {@code null} indicating that the JSON value was not present
      *            in the resource.
      * @return A {@link Promise} containing the result of the operation.
      */
-    abstract Promise<List<Modification>, ResourceException> update(Connection connection, JsonPointer path, Entry e,
-            JsonValue v);
+    abstract Promise<List<Modification>, ResourceException> update(Connection connection, Resource resource,
+                                                                   JsonPointer path, Entry e, JsonValue v);
 
     // TODO: methods for obtaining schema information (e.g. name, description, type information).
     // TODO: methods for creating sort controls.
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReadOnlyRequestHandler.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReadOnlyRequestHandler.java
new file mode 100644
index 0000000..b7cc7d3
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReadOnlyRequestHandler.java
@@ -0,0 +1,54 @@
+/*
+ * 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 2016 ForgeRock AS.
+ *
+ */
+package org.forgerock.opendj.rest2ldap;
+
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_READ_ONLY_ENDPOINT;
+
+import org.forgerock.json.resource.BadRequestException;
+import org.forgerock.json.resource.QueryRequest;
+import org.forgerock.json.resource.QueryResourceHandler;
+import org.forgerock.json.resource.QueryResponse;
+import org.forgerock.json.resource.ReadRequest;
+import org.forgerock.json.resource.RequestHandler;
+import org.forgerock.json.resource.ResourceException;
+import org.forgerock.json.resource.ResourceResponse;
+import org.forgerock.services.context.Context;
+import org.forgerock.util.promise.Promise;
+
+/**
+ * Provides a read-only view of an underlying request handler.
+ */
+final class ReadOnlyRequestHandler extends AbstractRequestHandler {
+    private final RequestHandler delegate;
+
+    ReadOnlyRequestHandler(final RequestHandler delegate) {
+        super(new BadRequestException(ERR_READ_ONLY_ENDPOINT.get().toString()));
+        this.delegate = delegate;
+    }
+
+    @Override
+    public Promise<QueryResponse, ResourceException> handleQuery(
+            final Context context, final QueryRequest request, final QueryResourceHandler handler) {
+        return delegate.handleQuery(context, request, handler);
+    }
+
+    @Override
+    public Promise<ResourceResponse, ResourceException> handleRead(
+            final Context context, final ReadRequest request) {
+        return delegate.handleRead(context, request);
+    }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferencePropertyMapper.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferencePropertyMapper.java
index 52291a1..e01c530 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferencePropertyMapper.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ReferencePropertyMapper.java
@@ -15,12 +15,14 @@
  */
 package org.forgerock.opendj.rest2ldap;
 
+import static org.forgerock.opendj.ldap.ResultCode.ADMIN_LIMIT_EXCEEDED;
 import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
 import static org.forgerock.opendj.ldap.LdapException.newLdapException;
 import static org.forgerock.opendj.ldap.requests.Requests.newSearchRequest;
 import static org.forgerock.opendj.rest2ldap.Rest2Ldap.asResourceException;
-import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException;
 import static org.forgerock.util.Reject.checkNotNull;
+import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException;
+import static org.forgerock.util.promise.Promises.newResultPromise;
 
 import java.util.ArrayList;
 import java.util.LinkedHashSet;
@@ -44,7 +46,6 @@
 import org.forgerock.opendj.ldap.LdapException;
 import org.forgerock.opendj.ldap.LinkedAttribute;
 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.SearchRequest;
@@ -133,10 +134,11 @@
     }
 
     @Override
-    Promise<Filter, ResourceException> getLdapFilter(final Connection connection, final JsonPointer path,
-                                                     final JsonPointer subPath, final FilterType type,
-                                                     final String operator, final Object valueAssertion) {
-        return mapper.getLdapFilter(connection, path, subPath, type, operator, valueAssertion)
+    Promise<Filter, ResourceException> getLdapFilter(final Connection connection, final Resource resource,
+                                                     final JsonPointer path, final JsonPointer subPath,
+                                                     final FilterType type, final String operator,
+                                                     final Object valueAssertion) {
+        return mapper.getLdapFilter(connection, resource, path, subPath, type, operator, valueAssertion)
                 .thenAsync(new AsyncFunction<Filter, Filter, ResourceException>() {
                     @Override
                     public Promise<Filter, ResourceException> apply(final Filter result) {
@@ -164,8 +166,7 @@
                             @Override
                             public Filter apply(Result result) throws ResourceException {
                                 if (subFilters.size() >= SEARCH_MAX_CANDIDATES) {
-                                    throw asResourceException(
-                                            newLdapException(ResultCode.ADMIN_LIMIT_EXCEEDED));
+                                    throw asResourceException(newLdapException(ADMIN_LIMIT_EXCEEDED));
                                 } else if (subFilters.size() == 1) {
                                     return subFilters.get(0);
                                 } else {
@@ -183,8 +184,8 @@
     }
 
     @Override
-    Promise<Attribute, ResourceException> getNewLdapAttributes(final Connection connection, final JsonPointer path,
-                                                               final List<Object> newValues) {
+    Promise<Attribute, ResourceException> getNewLdapAttributes(final Connection connection, final Resource resource,
+                                                               final JsonPointer path, final List<Object> newValues) {
         /*
          * 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.
@@ -195,70 +196,75 @@
         final PromiseImpl<Attribute, ResourceException> promise = PromiseImpl.create();
 
         for (final Object value : newValues) {
-            mapper.create(connection, path, new JsonValue(value)).thenOnResult(new ResultHandler<List<Attribute>>() {
-                @Override
-                public void handleResult(List<Attribute> result) {
-                    Attribute primaryKeyAttribute = null;
-                    for (final Attribute attribute : result) {
-                        if (attribute.getAttributeDescription().equals(primaryKey)) {
-                            primaryKeyAttribute = attribute;
-                            break;
-                        }
-                    }
+            mapper.create(connection, resource, path, new JsonValue(value))
+                  .thenOnResult(new ResultHandler<List<Attribute>>() {
+                      @Override
+                      public void handleResult(List<Attribute> result) {
+                          Attribute primaryKeyAttribute = null;
+                          for (final Attribute attribute : result) {
+                              if (attribute.getAttributeDescription().equals(primaryKey)) {
+                                  primaryKeyAttribute = attribute;
+                                  break;
+                              }
+                          }
 
-                    if (primaryKeyAttribute == null || primaryKeyAttribute.isEmpty()) {
-                        promise.handleException(newBadRequestException(ERR_REFERENCE_FIELD_NO_PRIMARY_KEY.get(path)));
-                    }
+                          if (primaryKeyAttribute == null || primaryKeyAttribute.isEmpty()) {
+                              promise.handleException(newBadRequestException(
+                                      ERR_REFERENCE_FIELD_NO_PRIMARY_KEY.get(path)));
+                              return;
+                          }
 
-                    if (primaryKeyAttribute.size() > 1) {
-                        promise.handleException(
-                                newBadRequestException(ERR_REFERENCE_FIELD_MULTIPLE_PRIMARY_KEYS.get(path)));
-                    }
+                          if (primaryKeyAttribute.size() > 1) {
+                              promise.handleException(
+                                      newBadRequestException(ERR_REFERENCE_FIELD_MULTIPLE_PRIMARY_KEYS.get(path)));
+                              return;
+                          }
 
-                    // Now search for the referenced entry in to get its DN.
-                    final ByteString primaryKeyValue = primaryKeyAttribute.firstValue();
-                    final Filter filter = Filter.equality(primaryKey.toString(), primaryKeyValue);
-                    final SearchRequest search = createSearchRequest(filter);
-                    connection.searchSingleEntryAsync(search)
-                              .thenOnResult(new ResultHandler<SearchResultEntry>() {
-                                  @Override
-                                  public void handleResult(final SearchResultEntry result) {
-                                      synchronized (newLDAPAttribute) {
-                                          newLDAPAttribute.add(result.getName());
-                                      }
-                                      completeIfNecessary();
-                                  }
-                              }).thenOnException(new ExceptionHandler<LdapException>() {
-                                  @Override
-                                  public void handleException(final LdapException error) {
-                                      ResourceException re;
-                                      try {
-                                          throw error;
-                                      } catch (final EntryNotFoundException e) {
-                                          re = newBadRequestException(ERR_REFERENCE_FIELD_DOES_NOT_EXIST.get(
-                                                  primaryKeyValue.toString(), path));
-                                      } catch (final MultipleEntriesFoundException e) {
-                                          re = newBadRequestException(
-                                                  ERR_REFERENCE_FIELD_AMBIGUOUS.get(primaryKeyValue.toString(), path));
-                                      } catch (final LdapException e) {
-                                          re = asResourceException(e);
-                                      }
-                                      exception.compareAndSet(null, re);
-                                      completeIfNecessary();
-                                  }
-                              });
-                }
+                          // Now search for the referenced entry in to get its DN.
+                          final ByteString primaryKeyValue = primaryKeyAttribute.firstValue();
+                          final Filter filter = Filter.equality(primaryKey.toString(), primaryKeyValue);
+                          final SearchRequest search = createSearchRequest(filter);
+                          connection.searchSingleEntryAsync(search)
+                                    .thenOnResult(new ResultHandler<SearchResultEntry>() {
+                                        @Override
+                                        public void handleResult(final SearchResultEntry result) {
+                                            synchronized (newLDAPAttribute) {
+                                                newLDAPAttribute.add(result.getName());
+                                            }
+                                            completeIfNecessary();
+                                        }
+                                    })
+                                    .thenOnException(new ExceptionHandler<LdapException>() {
+                                        @Override
+                                        public void handleException(final LdapException error) {
+                                            ResourceException re;
+                                            try {
+                                                throw error;
+                                            } catch (final EntryNotFoundException e) {
+                                                re = newBadRequestException(
+                                                        ERR_REFERENCE_FIELD_DOES_NOT_EXIST.get(primaryKeyValue, path));
+                                            } catch (final MultipleEntriesFoundException e) {
+                                                re = newBadRequestException(
+                                                        ERR_REFERENCE_FIELD_AMBIGUOUS.get(primaryKeyValue, path));
+                                            } catch (final LdapException e) {
+                                                re = asResourceException(e);
+                                            }
+                                            exception.compareAndSet(null, re);
+                                            completeIfNecessary();
+                                        }
+                                    });
+                      }
 
-                private void completeIfNecessary() {
-                    if (pendingSearches.decrementAndGet() == 0) {
-                        if (exception.get() != null) {
-                            promise.handleException(exception.get());
-                        } else {
-                            promise.handleResult(newLDAPAttribute);
-                        }
-                    }
-                }
-            });
+                      private void completeIfNecessary() {
+                          if (pendingSearches.decrementAndGet() == 0) {
+                              if (exception.get() != null) {
+                                  promise.handleException(exception.get());
+                              } else {
+                                  promise.handleResult(newLDAPAttribute);
+                              }
+                          }
+                      }
+                  });
         }
         return promise;
     }
@@ -268,28 +274,30 @@
         return this;
     }
 
+    @SuppressWarnings("fallthrough")
     @Override
-    Promise<JsonValue, ResourceException> read(final Connection connection, final JsonPointer path, final Entry e) {
-        final Attribute attribute = e.getAttribute(ldapAttributeName);
-        if (attribute == null || attribute.isEmpty()) {
-            return Promises.newResultPromise(null);
-        } else if (attributeIsSingleValued()) {
-            try {
-                final DN dn = attribute.parse().usingSchema(schema).asDN();
-                return readEntry(connection, path, dn);
-            } catch (final Exception ex) {
-                // The LDAP attribute could not be decoded.
-                return Promises.newExceptionPromise(asResourceException(ex));
+    Promise<JsonValue, ResourceException> read(final Connection connection, final Resource resource,
+                                               final JsonPointer path, final Entry e) {
+        final Set<DN> dns = e.parseAttribute(ldapAttributeName).usingSchema(schema).asSetOfDN();
+        switch (dns.size()) {
+        case 0:
+            return newResultPromise(null);
+        case 1:
+            if (attributeIsSingleValued()) {
+                try {
+                    return readEntry(connection, resource, path, dns.iterator().next());
+                } catch (final Exception ex) {
+                    // The LDAP attribute could not be decoded.
+                    return Promises.newExceptionPromise(asResourceException(ex));
+                }
             }
-        } else {
+            // Fall-though: unexpectedly got multiple values. It's probably best to just return them.
+        default:
             try {
-                final Set<DN> dns = attribute.parse().usingSchema(schema).asSetOfDN();
-
                 final List<Promise<JsonValue, ResourceException>> promises = new ArrayList<>(dns.size());
                 for (final DN dn : dns) {
-                    promises.add(readEntry(connection, path, dn));
+                    promises.add(readEntry(connection, resource, path, dn));
                 }
-
                 return Promises.when(promises)
                                .then(new Function<List<JsonValue>, JsonValue, ResourceException>() {
                                    @Override
@@ -322,9 +330,9 @@
     }
 
     private Promise<JsonValue, ResourceException> readEntry(
-            final Connection connection, final JsonPointer path, final DN dn) {
+            final Connection connection, final Resource resource, final JsonPointer path, final DN dn) {
         final Set<String> requestedLDAPAttributes = new LinkedHashSet<>();
-        mapper.getLdapAttributes(connection, path, new JsonPointer(), requestedLDAPAttributes);
+        mapper.getLdapAttributes(path, new JsonPointer(), requestedLDAPAttributes);
 
         final Filter searchFilter = filter != null ? filter : Filter.alwaysTrue();
         final String[] attributes = requestedLDAPAttributes.toArray(new String[requestedLDAPAttributes.size()]);
@@ -335,7 +343,7 @@
                 .thenAsync(new AsyncFunction<SearchResultEntry, JsonValue, ResourceException>() {
                     @Override
                     public Promise<JsonValue, ResourceException> apply(final SearchResultEntry result) {
-                        return mapper.read(connection, path, result);
+                        return mapper.read(connection, resource, path, result);
                     }
                 }, new AsyncFunction<LdapException, JsonValue, ResourceException>() {
                     @Override
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Resource.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Resource.java
new file mode 100644
index 0000000..ab76ccd
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Resource.java
@@ -0,0 +1,447 @@
+/*
+ * 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 2016 ForgeRock AS.
+ *
+ */
+package org.forgerock.opendj.rest2ldap;
+
+import static java.util.Arrays.asList;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_ABSTRACT_TYPE_IN_CREATE;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_MISSING_TYPE_PROPERTY_IN_CREATE;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_UNRECOGNIZED_RESOURCE_SUPER_TYPE;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_UNRECOGNIZED_TYPE_IN_CREATE;
+import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException;
+import static org.forgerock.util.Utils.joinAsString;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.forgerock.i18n.LocalizedIllegalArgumentException;
+import org.forgerock.json.JsonPointer;
+import org.forgerock.json.JsonValue;
+import org.forgerock.json.resource.RequestHandler;
+import org.forgerock.json.resource.ResourceException;
+import org.forgerock.json.resource.Router;
+import org.forgerock.opendj.ldap.Attribute;
+import org.forgerock.opendj.ldap.Entry;
+import org.forgerock.opendj.ldap.LinkedAttribute;
+
+/**
+ * Defines the characteristics of a resource, including its properties, inheritance, and sub-resources.
+ */
+public final class Resource {
+    /** The resource ID. */
+    private final String id;
+    /** {@code true} if only sub-types of this resource can be created. */
+    private boolean isAbstract;
+    /** The ID of the super-type of this resource, may be {@code null}. */
+    private String superTypeId;
+    /** The LDAP object classes associated with this resource. */
+    private final Attribute objectClasses = new LinkedAttribute("objectClass");
+    /** The possibly empty set of sub-resources. */
+    private final Set<SubResource> subResources = new LinkedHashSet<>();
+    /** The set of property mappers associated with this resource, excluding inherited properties. */
+    private final Map<String, PropertyMapper> declaredProperties = new LinkedHashMap<>();
+    /** The set of property mappers associated with this resource, including inherited properties. */
+    private final Map<String, PropertyMapper> allProperties = new LinkedHashMap<>();
+    /**
+     * A JSON pointer to the primitive JSON property that will be used to convey type information. May be {@code
+     * null} if the type property is defined in a super type or if this resource does not have any sub-types.
+     */
+    private JsonPointer resourceTypeProperty;
+    /** Set to {@code true} once this Resource has been built. */
+    private boolean isBuilt = false;
+    /** The resolved super-type. */
+    private Resource superType;
+    /** The resolved sub-resources (only immediate children). */
+    private final Set<Resource> subTypes = new LinkedHashSet<>();
+    /** The property mapper which will map all properties for this resource including inherited properties. */
+    private final ObjectPropertyMapper propertyMapper = new ObjectPropertyMapper();
+    /** Routes requests to sub-resources. */
+    private final Router subResourceRouter = new Router();
+    private volatile Boolean hasSubTypesWithSubResources = null;
+    /** The set of actions supported by this resource and its sub-types. */
+    private final Set<Action> supportedActions = new HashSet<>();
+
+    Resource(final String id) {
+        this.id = id;
+    }
+
+    /**
+     * Returns the resource ID of this resource.
+     *
+     * @return The resource ID of this resource.
+     */
+    @Override
+    public String toString() {
+        return id;
+    }
+
+    /**
+     * Returns {@code true} if the provided parameter is a {@code Resource} having the same resource ID as this
+     * resource.
+     *
+     * @param o
+     *         The object to compare.
+     * @return {@code true} if the provided parameter is a {@code Resource} having the same resource ID as this
+     * resource.
+     */
+    @Override
+    public boolean equals(final Object o) {
+        return this == o || (o instanceof Resource && id.equals(((Resource) o).id));
+    }
+
+    @Override
+    public int hashCode() {
+        return id.hashCode();
+    }
+
+    /**
+     * Specifies the resource ID of the resource which is a super-type of this resource. This resource will inherit
+     * the properties and sub-resources of the super-type, and may optionally override them.
+     *
+     * @param resourceId
+     *         The resource ID of the resource which is a super-type of this resource, or {@code null} if there is no
+     *         super-type.
+     * @return A reference to this object.
+     */
+    public Resource superType(final String resourceId) {
+        this.superTypeId = resourceId;
+        return this;
+    }
+
+    /**
+     * Specifies whether this resource is an abstract type and therefore cannot be created. Only non-abstract
+     * sub-types can be created.
+     *
+     * @param isAbstract
+     *         {@code true} if this resource is abstract.
+     * @return A reference to this object.
+     */
+    public Resource isAbstract(final boolean isAbstract) {
+        this.isAbstract = isAbstract;
+        return this;
+    }
+
+    /**
+     * Specifies a mapping for a property contained in this JSON resource. Properties are inherited and sub-types may
+     * override them. Properties are optional: a resource that does not have any properties cannot be created, read,
+     * or modified, and may only be used for accessing sub-resources. These resources usually represent API
+     * "endpoints".
+     *
+     * @param name
+     *         The name of the JSON property to be mapped.
+     * @param mapper
+     *         The property mapper responsible for mapping the JSON property to LDAP attribute(s).
+     * @return A reference to this object.
+     */
+    public Resource property(final String name, final PropertyMapper mapper) {
+        declaredProperties.put(name, mapper);
+        return this;
+    }
+
+    /**
+     * Specifies whether all LDAP user attributes should be mapped by default using the default schema based mapping
+     * rules. Individual attributes can be excluded using {@link #excludedDefaultUserAttributes} in order to prevent
+     * attributes with explicit mappings being mapped twice.
+     *
+     * @param include {@code true} if all LDAP user attributes be mapped by default.
+     * @return A reference to this object.
+     */
+    public Resource includeAllUserAttributesByDefault(final boolean include) {
+        propertyMapper.includeAllUserAttributesByDefault(include);
+        return this;
+    }
+
+    /**
+     * Specifies zero or more user attributes which will be excluded from the default user attribute mappings when
+     * enabled using {@link #includeAllUserAttributesByDefault}. Attributes which have explicit mappings should be
+     * excluded in order to prevent duplication.
+     *
+     * @param attributeNames The list of attributes to be excluded.
+     * @return A reference to this object.
+     */
+    public Resource excludedDefaultUserAttributes(final String... attributeNames) {
+        return excludedDefaultUserAttributes(Arrays.asList(attributeNames));
+    }
+
+    /**
+     * Specifies zero or more user attributes which will be excluded from the default user attribute mappings when
+     * enabled using {@link #includeAllUserAttributesByDefault}. Attributes which have explicit mappings should be
+     * excluded in order to prevent duplication.
+     *
+     * @param attributeNames The list of attributes to be excluded.
+     * @return A reference to this object.
+     */
+    public Resource excludedDefaultUserAttributes(final Collection<String> attributeNames) {
+        propertyMapper.excludedDefaultUserAttributes(attributeNames);
+        return this;
+    }
+
+    /**
+     * Specifies the name of the JSON property which contains the resource's type, whose value is the
+     * resource ID. The resource type property is inherited by sub-types and must be available to any resources
+     * referenced from {@link SubResource sub-resources}.
+     *
+     * @param resourceTypeProperty
+     *         The name of the JSON property which contains the resource's type, or {@code null} if this resource does
+     *         not have a resource type property or if it should be inherited from a super-type.
+     * @return A reference to this object.
+     */
+    public Resource resourceTypeProperty(final JsonPointer resourceTypeProperty) {
+        this.resourceTypeProperty = resourceTypeProperty;
+        return this;
+    }
+
+    /**
+     * Specifies an LDAP object class which is to be associated with this resource. Multiple object classes may be
+     * specified. The object classes are used for determining the type of resource being accessed during all requests
+     * other than create. Object classes are inherited by sub-types and must be defined for any resources that are
+     * non-abstract and which can be created.
+     *
+     * @param objectClass
+     *         An LDAP object class associated with this resource's LDAP representation.
+     * @return A reference to this object.
+     */
+    public Resource objectClass(final String objectClass) {
+        this.objectClasses.add(objectClass);
+        return this;
+    }
+
+    /**
+     * Specifies LDAP object classes which are to be associated with this resource. Multiple object classes may be
+     * specified. The object classes are used for determining the type of resource being accessed during all requests
+     * other than create. Object classes are inherited by sub-types and must be defined for any resources that are
+     * non-abstract and which can be created.
+     *
+     * @param objectClasses
+     *         The LDAP object classes associated with this resource's LDAP representation.
+     * @return A reference to this object.
+     */
+    public Resource objectClasses(final String... objectClasses) {
+        this.objectClasses.add((Object[]) objectClasses);
+        return this;
+    }
+
+    /**
+     * Registers an action which should be supported by this resource. By default, no actions are supported.
+     *
+     * @param action
+     *         The action supported by this resource.
+     * @return A reference to this object.
+     */
+    public Resource supportedAction(final Action action) {
+        this.supportedActions.add(action);
+        return this;
+    }
+
+    /**
+     * Registers zero or more actions which should be supported by this resource. By default, no actions are supported.
+     *
+     * @param actions
+     *         The actions supported by this resource.
+     * @return A reference to this object.
+     */
+    public Resource supportedActions(final Action... actions) {
+        this.supportedActions.addAll(Arrays.asList(actions));
+        return this;
+    }
+
+    /**
+     * Specifies a parent-child relationship with another resource. Sub-resources are inherited by sub-types and may
+     * be overridden.
+     *
+     * @param subResource
+     *         The sub-resource definition.
+     * @return A reference to this object.
+     */
+    public Resource subResource(final SubResource subResource) {
+        this.subResources.add(subResource);
+        return this;
+    }
+
+    /**
+     * Specifies a parent-child relationship with zero or more resources. Sub-resources are inherited by sub-types and
+     * may be overridden.
+     *
+     * @param subResources
+     *         The sub-resource definitions.
+     * @return A reference to this object.
+     */
+    public Resource subResources(final SubResource... subResources) {
+        this.subResources.addAll(asList(subResources));
+        return this;
+    }
+
+    boolean hasSupportedAction(final Action action) {
+        return supportedActions.contains(action);
+    }
+
+    boolean hasSubTypes() {
+        return !subTypes.isEmpty();
+    }
+
+    boolean mayHaveSubResources() {
+        return !subResources.isEmpty() || hasSubTypesWithSubResources();
+    }
+
+    boolean hasSubTypesWithSubResources() {
+        if (hasSubTypesWithSubResources == null) {
+            for (final Resource subType : subTypes) {
+                if (!subType.subResources.isEmpty() || subType.hasSubTypesWithSubResources()) {
+                    hasSubTypesWithSubResources = true;
+                    return true;
+                }
+            }
+            hasSubTypesWithSubResources = false;
+        }
+        return hasSubTypesWithSubResources;
+    }
+
+    Set<Resource> getSubTypes() {
+        return subTypes;
+    }
+
+    Resource resolveSubTypeFromJson(final JsonValue content) throws ResourceException {
+        if (!hasSubTypes()) {
+            // The resource type is implied because this resource does not have sub-types. In particular, resources
+            // are not required to have type information if they don't have sub-types.
+            return this;
+        }
+        final JsonValue jsonType = content.get(resourceTypeProperty);
+        if (jsonType == null || !jsonType.isString()) {
+            throw newBadRequestException(ERR_MISSING_TYPE_PROPERTY_IN_CREATE.get(resourceTypeProperty));
+        }
+        final String type = jsonType.asString();
+        final Resource subType = resolveSubTypeFromString(type);
+        if (subType == null) {
+            throw newBadRequestException(ERR_UNRECOGNIZED_TYPE_IN_CREATE.get(type, getAllowedResourceTypes()));
+        }
+        if (subType.isAbstract) {
+            throw newBadRequestException(ERR_ABSTRACT_TYPE_IN_CREATE.get(type, getAllowedResourceTypes()));
+        }
+        return subType;
+    }
+
+    private String getAllowedResourceTypes() {
+        final List<String> allowedTypes = new ArrayList<>();
+        getAllowedResourceTypes(allowedTypes);
+        return joinAsString(", ", allowedTypes);
+    }
+
+    private void getAllowedResourceTypes(final List<String> allowedTypes) {
+        if (!isAbstract) {
+            allowedTypes.add(id);
+        }
+        for (final Resource subType : subTypes) {
+            subType.getAllowedResourceTypes(allowedTypes);
+        }
+    }
+
+    Resource resolveSubTypeFromString(final String type) {
+        if (id.equalsIgnoreCase(type)) {
+            return this;
+        }
+        for (final Resource subType : subTypes) {
+            final Resource resolvedSubType = subType.resolveSubTypeFromString(type);
+            if (resolvedSubType != null) {
+                return resolvedSubType;
+            }
+        }
+        return null;
+    }
+
+    Resource resolveSubTypeFromObjectClasses(final Entry entry) {
+        if (!hasSubTypes()) {
+            // This resource does not have sub-types.
+            return this;
+        }
+        final Attribute objectClassesFromEntry = entry.getAttribute("objectClass");
+        final Resource subType = resolveSubTypeFromObjectClasses(objectClassesFromEntry);
+        if (subType == null) {
+            // Best effort.
+            return this;
+        }
+        return subType;
+    }
+
+    private Resource resolveSubTypeFromObjectClasses(final Attribute objectClassesFromEntry) {
+        if (!objectClassesFromEntry.containsAll(objectClasses)) {
+            return null;
+        }
+        // This resource is a potential match, but sub-types may be better.
+        for (final Resource subType : subTypes) {
+            final Resource resolvedSubType = subType.resolveSubTypeFromObjectClasses(objectClassesFromEntry);
+            if (resolvedSubType != null) {
+                return resolvedSubType;
+            }
+        }
+        return this;
+    }
+
+    Attribute getObjectClassAttribute() {
+        return objectClasses;
+    }
+
+    RequestHandler getSubResourceRouter() {
+        return subResourceRouter;
+    }
+
+    String getResourceId() {
+        return id;
+    }
+
+    void build(final Rest2Ldap rest2Ldap) {
+        // Prevent re-entrant calls.
+        if (isBuilt) {
+            return;
+        }
+        isBuilt = true;
+
+        if (superTypeId != null) {
+            superType = rest2Ldap.getResource(superTypeId);
+            if (superType == null) {
+                throw new LocalizedIllegalArgumentException(ERR_UNRECOGNIZED_RESOURCE_SUPER_TYPE.get(id, superTypeId));
+            }
+            // Inherit content from super-type.
+            superType.build(rest2Ldap);
+            superType.subTypes.add(this);
+            if (resourceTypeProperty == null) {
+                resourceTypeProperty = superType.resourceTypeProperty;
+            }
+            objectClasses.addAll(superType.objectClasses);
+            subResourceRouter.addAllRoutes(superType.subResourceRouter);
+            allProperties.putAll(superType.allProperties);
+        }
+        allProperties.putAll(declaredProperties);
+        for (final Map.Entry<String, PropertyMapper> property : allProperties.entrySet()) {
+            propertyMapper.property(property.getKey(), property.getValue());
+        }
+        for (final SubResource subResource : subResources) {
+            subResource.build(rest2Ldap, id);
+            subResource.addRoutes(subResourceRouter);
+        }
+    }
+
+    PropertyMapper getPropertyMapper() {
+        return propertyMapper;
+    }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ResourceTypePropertyMapper.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ResourceTypePropertyMapper.java
new file mode 100644
index 0000000..845f821
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/ResourceTypePropertyMapper.java
@@ -0,0 +1,123 @@
+/*
+ * 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 2016 ForgeRock AS.
+ *
+ */
+package org.forgerock.opendj.rest2ldap;
+
+import static java.util.Collections.singletonList;
+import static org.forgerock.opendj.ldap.Filter.alwaysFalse;
+import static org.forgerock.opendj.ldap.Filter.alwaysTrue;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_ILLEGAL_FILTER_ASSERTION_VALUE;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_MODIFY_READ_ONLY_FIELD;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_PATCH_READ_ONLY_FIELD;
+import static org.forgerock.opendj.rest2ldap.Utils.isNullOrEmpty;
+import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException;
+import static org.forgerock.util.promise.Promises.newResultPromise;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import org.forgerock.json.JsonPointer;
+import org.forgerock.json.JsonValue;
+import org.forgerock.json.resource.PatchOperation;
+import org.forgerock.json.resource.ResourceException;
+import org.forgerock.opendj.ldap.Attribute;
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.Connection;
+import org.forgerock.opendj.ldap.Entry;
+import org.forgerock.opendj.ldap.Filter;
+import org.forgerock.opendj.ldap.Modification;
+import org.forgerock.util.promise.Promise;
+
+/**
+ * A property mapper which maps a single JSON property containing the resource type to the resource's object classes.
+ */
+final class ResourceTypePropertyMapper extends PropertyMapper {
+    static final ResourceTypePropertyMapper INSTANCE = new ResourceTypePropertyMapper();
+
+    private ResourceTypePropertyMapper() { }
+
+    @Override
+    public String toString() {
+        return "type()";
+    }
+
+    @Override
+    Promise<List<Attribute>, ResourceException> create(final Connection connection,
+                                                       final Resource resource, final JsonPointer path,
+                                                       final JsonValue v) {
+        return newResultPromise(singletonList(resource.getObjectClassAttribute()));
+    }
+
+    @Override
+    void getLdapAttributes(final JsonPointer path, final JsonPointer subPath, final Set<String> ldapAttributes) {
+        ldapAttributes.add("objectClass");
+    }
+
+    @Override
+    Promise<Filter, ResourceException> getLdapFilter(final Connection connection, final Resource resource,
+                                                     final JsonPointer path, final JsonPointer subPath,
+                                                     final FilterType type, final String operator,
+                                                     final Object valueAssertion) {
+        if (subPath.isEmpty()) {
+            switch (type) {
+            case PRESENT:
+                return newResultPromise(alwaysTrue());
+            case EQUAL_TO:
+                if (valueAssertion instanceof String) {
+                    final Resource subType = resource.resolveSubTypeFromString((String) valueAssertion);
+                    if (subType == null) {
+                        return newResultPromise(alwaysFalse());
+                    }
+                    final List<Filter> subFilters = new ArrayList<>();
+                    for (final ByteString objectClass : subType.getObjectClassAttribute()) {
+                        subFilters.add(Filter.equality("objectClass", objectClass));
+                    }
+                    return newResultPromise(Filter.and(subFilters));
+                }
+                return newBadRequestException(ERR_ILLEGAL_FILTER_ASSERTION_VALUE.get(valueAssertion, path)).asPromise();
+            default:
+                return newResultPromise(alwaysFalse()); // Not supported.
+            }
+        } else {
+            // This property mapper does not support partial filtering.
+            return newResultPromise(alwaysFalse());
+        }
+    }
+
+    @Override
+    Promise<List<Modification>, ResourceException> patch(final Connection connection, final Resource resource,
+                                                         final JsonPointer path, final PatchOperation operation) {
+        return newBadRequestException(ERR_PATCH_READ_ONLY_FIELD.get(path)).asPromise();
+    }
+
+    @Override
+    Promise<JsonValue, ResourceException> read(final Connection connection, final Resource resource,
+                                               final JsonPointer path, final Entry e) {
+        return newResultPromise(new JsonValue(resource.getResourceId()));
+    }
+
+    @Override
+    Promise<List<Modification>, ResourceException> update(final Connection connection, final Resource resource,
+                                                          final JsonPointer path, final Entry e, final JsonValue v) {
+        if (!isNullOrEmpty(v) && !v.getObject().equals(resource.getResourceId())) {
+            return newBadRequestException(ERR_MODIFY_READ_ONLY_FIELD.get("update", path)).asPromise();
+        } else {
+            return newResultPromise(Collections.<Modification>emptyList());
+        }
+    }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2Ldap.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2Ldap.java
index e7fa676..e0f8920 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2Ldap.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2Ldap.java
@@ -11,845 +11,206 @@
  * Header, with the fields enclosed by brackets [] replaced by your own identifying
  * information: "Portions copyright [year] [name of copyright owner]".
  *
- * Copyright 2013-2016 ForgeRock AS.
+ * Copyright 2016 ForgeRock AS.
+ *
  */
 package org.forgerock.opendj.rest2ldap;
 
-import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
-import static java.util.Arrays.asList;
-import static org.forgerock.json.resource.ResourceException.newResourceException;
-import static org.forgerock.opendj.ldap.Connections.newCachedConnectionPool;
-import static org.forgerock.opendj.ldap.Connections.newFailoverLoadBalancer;
-import static org.forgerock.opendj.ldap.Connections.newRoundRobinLoadBalancer;
-import static org.forgerock.opendj.ldap.LDAPConnectionFactory.*;
-import static org.forgerock.opendj.ldap.Connections.LOAD_BALANCER_MONITORING_INTERVAL;
-import static org.forgerock.opendj.ldap.requests.Requests.newSearchRequest;
-import static org.forgerock.opendj.ldap.schema.CoreSchema.getEntryUUIDAttributeType;
+import static org.forgerock.opendj.ldap.ResultCode.ADMIN_LIMIT_EXCEEDED;
+import static org.forgerock.opendj.ldap.ResultCode.ENTRY_ALREADY_EXISTS;
+import static org.forgerock.opendj.ldap.ResultCode.SIZE_LIMIT_EXCEEDED;
 import static org.forgerock.opendj.rest2ldap.ReadOnUpdatePolicy.CONTROLS;
-import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException;
-import static org.forgerock.opendj.rest2ldap.Utils.newLocalizedIllegalArgumentException;
-import static org.forgerock.opendj.rest2ldap.Utils.newJsonValueException;
-import static org.forgerock.util.Reject.checkNotNull;
-import static org.forgerock.util.time.Duration.*;
-import static org.forgerock.opendj.ldap.KeyManagers.useSingleCertificate;
 
-import java.security.GeneralSecurityException;
-import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
 import java.util.LinkedHashMap;
-import java.util.LinkedList;
-import java.util.List;
 import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
 
-import javax.net.ssl.TrustManager;
-import javax.net.ssl.X509KeyManager;
-
-import org.forgerock.json.JsonValue;
-import org.forgerock.json.resource.CollectionResourceProvider;
+import org.forgerock.json.resource.BadRequestException;
+import org.forgerock.json.resource.ForbiddenException;
+import org.forgerock.json.resource.InternalServerErrorException;
+import org.forgerock.json.resource.NotFoundException;
+import org.forgerock.json.resource.PermanentException;
+import org.forgerock.json.resource.PreconditionFailedException;
+import org.forgerock.json.resource.RequestHandler;
 import org.forgerock.json.resource.ResourceException;
+import org.forgerock.json.resource.RetryableException;
+import org.forgerock.json.resource.Router;
+import org.forgerock.json.resource.ServiceUnavailableException;
 import org.forgerock.opendj.ldap.AssertionFailureException;
-import org.forgerock.opendj.ldap.Attribute;
 import org.forgerock.opendj.ldap.AttributeDescription;
 import org.forgerock.opendj.ldap.AuthenticationException;
 import org.forgerock.opendj.ldap.AuthorizationException;
-import org.forgerock.opendj.ldap.ByteString;
-import org.forgerock.opendj.ldap.Connection;
 import org.forgerock.opendj.ldap.ConnectionException;
-import org.forgerock.opendj.ldap.ConnectionFactory;
 import org.forgerock.opendj.ldap.ConstraintViolationException;
 import org.forgerock.opendj.ldap.DN;
-import org.forgerock.opendj.ldap.Entry;
+import org.forgerock.opendj.ldap.DecodeOptions;
 import org.forgerock.opendj.ldap.EntryNotFoundException;
-import org.forgerock.opendj.ldap.Filter;
-import org.forgerock.opendj.ldap.LDAPConnectionFactory;
 import org.forgerock.opendj.ldap.LdapException;
-import org.forgerock.opendj.ldap.LinkedAttribute;
 import org.forgerock.opendj.ldap.MultipleEntriesFoundException;
-import org.forgerock.opendj.ldap.RDN;
 import org.forgerock.opendj.ldap.ResultCode;
-import org.forgerock.opendj.ldap.SSLContextBuilder;
-import org.forgerock.opendj.ldap.SearchScope;
 import org.forgerock.opendj.ldap.TimeoutResultException;
-import org.forgerock.opendj.ldap.requests.BindRequest;
-import org.forgerock.opendj.ldap.requests.Requests;
-import org.forgerock.opendj.ldap.requests.SearchRequest;
-import org.forgerock.opendj.ldap.schema.AttributeType;
 import org.forgerock.opendj.ldap.schema.Schema;
+import org.forgerock.util.Option;
 import org.forgerock.util.Options;
 import org.forgerock.util.Reject;
-import org.forgerock.util.time.Duration;
 
-/** Provides core factory methods and builders for constructing LDAP resource collections. */
+/**
+ * Provides methods for constructing Rest2Ldap protocol gateways. Applications construct a new Rest2Ldap
+ * instance by calling {@link #rest2Ldap} passing in a list of {@link Resource resources} which together define
+ * the data model being exposed by the gateway. Call {@link #newRequestHandlerFor(String)} in order to obtain
+ * a request handler for a specific resource. The methods in this class can be categorized as follows:
+ * <p/>
+ * Creating Rest2Ldap gateways:
+ * <ul>
+ * <li>{@link #rest2Ldap} - creates a gateway for a given set of resources</li>
+ * <li>{@link #newRequestHandlerFor} - obtains a request handler for the specified endpoint resource.</li>
+ * </ul>
+ * <p/>
+ * Defining resource types, e.g. users, groups, devices, etc:
+ * <ul>
+ * <li>{@link #resource} - creates a resource having a fluent API for defining additional characteristics
+ * such as the resource's inheritance, sub-resources, and properties</li>
+ * </ul>
+ * <p/>
+ * Defining a resource's sub-resources. A sub-resource is a resource which is subordinate to another resource. Or, to
+ * put it another way, sub-resources define parent child relationships where the life-cycle of a child resource is
+ * constrained by the life-cycle of the parent: deleting the parent implies that all children are deleted as well. An
+ * example of a sub-resource is a subscriber having one or more devices:
+ * <ul>
+ * <li>{@link #collectionOf} - creates a one-to-many relationship. Collections support creation, deletion,
+ * and querying of child resources</li>
+ * <li>{@link #singletonOf} - creates a one-to-one relationship. Singletons cannot be created or destroyed,
+ * although they may be modified if they have properties which are modifiable. Singletons are usually only used as
+ * top-level entry points into REST APIs.
+ * </li>
+ * </ul>
+ * <p/>
+ * Defining a resource's properties:
+ * <ul>
+ * <li>{@link #resourceType} - defines a property whose JSON value will be the name of the resource, e.g. "user"</li>
+ * <li>{@link #simple} - defines a property which maps a JSON value to a single LDAP attribute</li>
+ * <li>{@link #object} - defines a property which is a JSON object having zero or more nested properties</li>
+ * <li>{@link #reference} - defines a property whose JSON value is a reference to another resource. Use these for
+ * mapping LDAP attributes which contain the DN of another LDAP entry exposed by Rest2Ldap. For example, a user's
+ * "manager" attribute or the members of a group.</li>
+ * </ul>
+ */
 public final class Rest2Ldap {
-    /** Indicates whether LDAP client connections should use SSL or StartTLS. */
-    private enum ConnectionSecurity {
-        NONE, SSL, STARTTLS
-    }
-
     /**
-     * Specifies the mechanism which should be used for trusting certificates
-     * presented by the LDAP server.
+     * Specifies the LDAP decoding options which should be used when decoding LDAP DNs, attribute types, and controls.
+     * By default Rest2Ldap will use a set of options of will always use the default schema.
      */
-    enum TrustManagerType {
-        TRUSTALL, JVM, FILE
-    }
-
+    public static final Option<DecodeOptions> DECODE_OPTIONS = Option.withDefault(new DecodeOptions());
     /**
-     * Specifies the mechanism which manage which X509 certificate-based key pairs should be used to authenticate the
-     * local side of a secure socket.
+     * Specifies whether Rest2Ldap should support multi-version concurrency control (MVCC) through the use of an MVCC
+     * LDAP {@link #MVCC_ATTRIBUTE attribute} such as "etag". By default Rest2Ldap will use MVCC.
      */
-    enum KeyManagerType {
-        JVM, KEYSTORE, PKCS11
-    }
-
-    /** A builder for incrementally constructing LDAP resource collections. */
-    public static final class Builder {
-        private final List<Attribute> additionalLDAPAttributes = new LinkedList<>();
-        private DN baseDN; // TODO: support template variables.
-        private AttributeDescription etagAttribute;
-        private NamingStrategy namingStrategy;
-        private ReadOnUpdatePolicy readOnUpdatePolicy = CONTROLS;
-        private PropertyMapper rootMapper;
-        private Schema schema = Schema.getDefaultSchema();
-        private boolean usePermissiveModify;
-        private boolean useSubtreeDelete;
-
-        private Builder() {
-            useEtagAttribute();
-            useClientDNNaming("uid");
-        }
-
-        /**
-         * Specifies an additional LDAP attribute which should be included with
-         * new LDAP entries when they are created. Use this method to specify
-         * the LDAP objectClass attribute.
-         *
-         * @param attribute
-         *            The additional LDAP attribute to be included with new LDAP
-         *            entries.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder additionalLDAPAttribute(final Attribute attribute) {
-            additionalLDAPAttributes.add(attribute);
-            return this;
-        }
-
-        /**
-         * Specifies an additional LDAP attribute which should be included with
-         * new LDAP entries when they are created. Use this method to specify
-         * the LDAP objectClass attribute.
-         *
-         * @param attribute
-         *            The name of the additional LDAP attribute to be included
-         *            with new LDAP entries.
-         * @param values
-         *            The value(s) of the additional LDAP attribute.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder additionalLDAPAttribute(final String attribute, final Object... values) {
-            return additionalLDAPAttribute(new LinkedAttribute(ad(attribute), values));
-        }
-
-        /**
-         * Sets the base DN beneath which LDAP entries (resources) are to be found.
-         *
-         * @param dn
-         *            The base DN.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder baseDN(final DN dn) {
-            Reject.ifNull(dn);
-            this.baseDN = dn;
-            return this;
-        }
-
-        /**
-         * Sets the base DN beneath which LDAP entries (resources) are to be found.
-         *
-         * @param dn
-         *            The base DN.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder baseDN(final String dn) {
-            return baseDN(DN.valueOf(dn, schema));
-        }
-
-        /**
-         * Creates a new LDAP resource collection configured using this builder.
-         *
-         * @return The new LDAP resource collection.
-         */
-        public CollectionResourceProvider build() {
-            Reject.ifNull(baseDN);
-            if (rootMapper == null) {
-                throw new IllegalStateException(ERR_CONFIG_NO_MAPPINGS_PROVIDED.get().toString());
-            }
-            return new SubResourceImpl(baseDN, rootMapper, namingStrategy, etagAttribute,
-                                       new Config(readOnUpdatePolicy, useSubtreeDelete, usePermissiveModify, schema),
-                                       additionalLDAPAttributes);
-        }
-
-        /**
-         * Configures the JSON to LDAP mapping using the provided JSON
-         * configuration. The caller is still required to set the connection
-         * factory. See the sample configuration file for a detailed description
-         * of its content.
-         *
-         * @param configuration
-         *            The JSON configuration.
-         * @return A reference to this LDAP resource collection builder.
-         * @throws IllegalArgumentException
-         *             If the configuration is invalid.
-         */
-        public Builder configureMapping(final JsonValue configuration) {
-            baseDN(configuration.get("baseDN").required().asString());
-
-            final JsonValue readOnUpdatePolicy = configuration.get("readOnUpdatePolicy");
-            if (!readOnUpdatePolicy.isNull()) {
-                readOnUpdatePolicy(readOnUpdatePolicy.asEnum(ReadOnUpdatePolicy.class));
-            }
-
-            for (final JsonValue v : configuration.get("additionalLDAPAttributes")) {
-                final String type = v.get("type").required().asString();
-                final List<Object> values = v.get("values").required().asList();
-                additionalLDAPAttribute(new LinkedAttribute(type, values));
-            }
-
-            final JsonValue namingStrategy = configuration.get("namingStrategy");
-            if (!namingStrategy.isNull()) {
-                final String name = namingStrategy.get("strategy").required().asString();
-                if (name.equalsIgnoreCase("clientDNNaming")) {
-                    useClientDNNaming(namingStrategy.get("dnAttribute").required().asString());
-                } else if (name.equalsIgnoreCase("clientNaming")) {
-                    useClientNaming(namingStrategy.get("dnAttribute").required().asString(),
-                            namingStrategy.get("idAttribute").required().asString());
-                } else if (name.equalsIgnoreCase("serverNaming")) {
-                    useServerNaming(namingStrategy.get("dnAttribute").required().asString(),
-                            namingStrategy.get("idAttribute").required().asString());
-                } else {
-                    throw newLocalizedIllegalArgumentException(ERR_CONFIG_UNKNOWN_NAMING_CONFIGURATION.get(
-                            namingStrategy.asString(), "clientDNNaming, clientNaming or serverNaming"));
-                }
-            }
-
-            final JsonValue etagAttribute = configuration.get("etagAttribute");
-            if (!etagAttribute.isNull()) {
-                useEtagAttribute(etagAttribute.asString());
-            }
-
-            /*
-             * Default to false, even though it is supported by OpenDJ, because
-             * it requires additional permissions.
-             */
-            if (configuration.get("useSubtreeDelete").defaultTo(false).asBoolean()) {
-                useSubtreeDelete();
-            }
-
-            /*
-             * Default to true because it is supported by OpenDJ and does not
-             * require additional permissions.
-             */
-            if (configuration.get("usePermissiveModify").defaultTo(true).asBoolean()) {
-                usePermissiveModify();
-            }
-
-            mapper(configureObjectMapper(configuration.get("attributes").required()));
-
-            return this;
-        }
-
-        /**
-         * Sets the property mapper which should be used for mapping JSON
-         * resources to and from LDAP entries.
-         *
-         * @param mapper
-         *            The property mapper.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder mapper(final PropertyMapper mapper) {
-            this.rootMapper = mapper;
-            return this;
-        }
-
-        /**
-         * Sets the policy which should be used in order to read an entry before
-         * it is deleted, or after it is added or modified. The default read on
-         * update policy is to use {@link ReadOnUpdatePolicy#CONTROLS controls}.
-         *
-         * @param policy
-         *            The policy which should be used in order to read an entry
-         *            before it is deleted, or after it is added or modified.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder readOnUpdatePolicy(final ReadOnUpdatePolicy policy) {
-            this.readOnUpdatePolicy = checkNotNull(policy);
-            return this;
-        }
-
-        /**
-         * Sets the schema which should be used when attribute types and
-         * controls.
-         *
-         * @param schema
-         *            The schema which should be used when attribute types and
-         *            controls.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder schema(final Schema schema) {
-            this.schema = checkNotNull(schema);
-            return this;
-        }
-
-        /**
-         * Indicates that the JSON resource ID must be provided by the user, and
-         * will be used for naming the associated LDAP entry. More specifically,
-         * LDAP entry names will be derived by appending a single RDN to the
-         * {@link #baseDN(String) base DN} composed of the specified attribute
-         * type and LDAP value taken from the LDAP entry once attribute mapping
-         * has been performed.
-         * <p>
-         * Note that this naming policy requires that the user provides the
-         * resource name when creating new resources, which means it must be
-         * included in the resource content when not specified explicitly in the
-         * create request.
-         *
-         * @param attribute
-         *            The LDAP attribute which will be used for naming.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder useClientDNNaming(final AttributeType attribute) {
-            this.namingStrategy = new DNNamingStrategy(attribute);
-            return this;
-        }
-
-        /**
-         * Indicates that the JSON resource ID must be provided by the user, and
-         * will be used for naming the associated LDAP entry. More specifically,
-         * LDAP entry names will be derived by appending a single RDN to the
-         * {@link #baseDN(String) base DN} composed of the specified attribute
-         * type and LDAP value taken from the LDAP entry once attribute mapping
-         * has been performed.
-         * <p>
-         * Note that this naming policy requires that the user provides the
-         * resource name when creating new resources, which means it must be
-         * included in the resource content when not specified explicitly in the
-         * create request.
-         *
-         * @param attribute
-         *            The LDAP attribute which will be used for naming.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder useClientDNNaming(final String attribute) {
-            return useClientDNNaming(at(attribute));
-        }
-
-        /**
-         * Indicates that the JSON resource ID must be provided by the user, but
-         * will not be used for naming the associated LDAP entry. Instead the
-         * JSON resource ID will be taken from the {@code idAttribute} in the
-         * LDAP entry, and the LDAP entry name will be derived by appending a
-         * single RDN to the {@link #baseDN(String) base DN} composed of the
-         * {@code dnAttribute} taken from the LDAP entry once attribute mapping
-         * has been performed.
-         * <p>
-         * Note that this naming policy requires that the user provides the
-         * resource name when creating new resources, which means it must be
-         * included in the resource content when not specified explicitly in the
-         * create request.
-         *
-         * @param dnAttribute
-         *            The attribute which will be used for naming LDAP entries.
-         * @param idAttribute
-         *            The attribute which will be used for JSON resource IDs.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder useClientNaming(final AttributeType dnAttribute,
-                final AttributeDescription idAttribute) {
-            this.namingStrategy = new AttributeNamingStrategy(dnAttribute, idAttribute, false);
-            return this;
-        }
-
-        /**
-         * Indicates that the JSON resource ID must be provided by the user, but
-         * will not be used for naming the associated LDAP entry. Instead the
-         * JSON resource ID will be taken from the {@code idAttribute} in the
-         * LDAP entry, and the LDAP entry name will be derived by appending a
-         * single RDN to the {@link #baseDN(String) base DN} composed of the
-         * {@code dnAttribute} taken from the LDAP entry once attribute mapping
-         * has been performed.
-         * <p>
-         * Note that this naming policy requires that the user provides the
-         * resource name when creating new resources, which means it must be
-         * included in the resource content when not specified explicitly in the
-         * create request.
-         *
-         * @param dnAttribute
-         *            The attribute which will be used for naming LDAP entries.
-         * @param idAttribute
-         *            The attribute which will be used for JSON resource IDs.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder useClientNaming(final String dnAttribute, final String idAttribute) {
-            return useClientNaming(at(dnAttribute), ad(idAttribute));
-        }
-
-        /**
-         * Indicates that the "etag" LDAP attribute should be used for resource
-         * versioning. This is the default behavior.
-         *
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder useEtagAttribute() {
-            return useEtagAttribute("etag");
-        }
-
-        /**
-         * Indicates that the provided LDAP attribute should be used for
-         * resource versioning. The "etag" attribute will be used by default.
-         *
-         * @param attribute
-         *            The name of the attribute to use for versioning, or
-         *            {@code null} if resource versioning will not supported.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder useEtagAttribute(final AttributeDescription attribute) {
-            this.etagAttribute = attribute;
-            return this;
-        }
-
-        /**
-         * Indicates that the provided LDAP attribute should be used for
-         * resource versioning. The "etag" attribute will be used by default.
-         *
-         * @param attribute
-         *            The name of the attribute to use for versioning, or
-         *            {@code null} if resource versioning will not supported.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder useEtagAttribute(final String attribute) {
-            return useEtagAttribute(attribute != null ? ad(attribute) : null);
-        }
-
-        /**
-         * Indicates that all LDAP modify operations should be performed using
-         * the LDAP permissive modify control. The default behavior is to not
-         * use the permissive modify control. Use of the control is strongly
-         * recommended.
-         *
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder usePermissiveModify() {
-            this.usePermissiveModify = true;
-            return this;
-        }
-
-        /**
-         * Indicates that the JSON resource ID will be derived from the server
-         * provided "entryUUID" LDAP attribute. The LDAP entry name will be
-         * derived by appending a single RDN to the {@link #baseDN(String) base
-         * DN} composed of the {@code dnAttribute} taken from the LDAP entry
-         * once attribute mapping has been performed.
-         * <p>
-         * Note that this naming policy requires that the server provides the
-         * resource name when creating new resources, which means it must not be
-         * specified in the create request, nor included in the resource
-         * content.
-         *
-         * @param dnAttribute
-         *            The attribute which will be used for naming LDAP entries.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder useServerEntryUUIDNaming(final AttributeType dnAttribute) {
-            return useServerNaming(dnAttribute, AttributeDescription
-                    .create(getEntryUUIDAttributeType()));
-        }
-
-        /**
-         * Indicates that the JSON resource ID will be derived from the server
-         * provided "entryUUID" LDAP attribute. The LDAP entry name will be
-         * derived by appending a single RDN to the {@link #baseDN(String) base
-         * DN} composed of the {@code dnAttribute} taken from the LDAP entry
-         * once attribute mapping has been performed.
-         * <p>
-         * Note that this naming policy requires that the server provides the
-         * resource name when creating new resources, which means it must not be
-         * specified in the create request, nor included in the resource
-         * content.
-         *
-         * @param dnAttribute
-         *            The attribute which will be used for naming LDAP entries.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder useServerEntryUUIDNaming(final String dnAttribute) {
-            return useServerEntryUUIDNaming(at(dnAttribute));
-        }
-
-        /**
-         * Indicates that the JSON resource ID must not be provided by the user,
-         * and will not be used for naming the associated LDAP entry. Instead
-         * the JSON resource ID will be taken from the {@code idAttribute} in
-         * the LDAP entry, and the LDAP entry name will be derived by appending
-         * a single RDN to the {@link #baseDN(String) base DN} composed of the
-         * {@code dnAttribute} taken from the LDAP entry once attribute mapping
-         * has been performed.
-         * <p>
-         * Note that this naming policy requires that the server provides the
-         * resource name when creating new resources, which means it must not be
-         * specified in the create request, nor included in the resource
-         * content.
-         *
-         * @param dnAttribute
-         *            The attribute which will be used for naming LDAP entries.
-         * @param idAttribute
-         *            The attribute which will be used for JSON resource IDs.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder useServerNaming(final AttributeType dnAttribute,
-                final AttributeDescription idAttribute) {
-            this.namingStrategy = new AttributeNamingStrategy(dnAttribute, idAttribute, true);
-            return this;
-        }
-
-        /**
-         * Indicates that the JSON resource ID must not be provided by the user,
-         * and will not be used for naming the associated LDAP entry. Instead
-         * the JSON resource ID will be taken from the {@code idAttribute} in
-         * the LDAP entry, and the LDAP entry name will be derived by appending
-         * a single RDN to the {@link #baseDN(String) base DN} composed of the
-         * {@code dnAttribute} taken from the LDAP entry once attribute mapping
-         * has been performed.
-         * <p>
-         * Note that this naming policy requires that the server provides the
-         * resource name when creating new resources, which means it must not be
-         * specified in the create request, nor included in the resource
-         * content.
-         *
-         * @param dnAttribute
-         *            The attribute which will be used for naming LDAP entries.
-         * @param idAttribute
-         *            The attribute which will be used for JSON resource IDs.
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder useServerNaming(final String dnAttribute, final String idAttribute) {
-            return useServerNaming(at(dnAttribute), ad(idAttribute));
-        }
-
-        /**
-         * Indicates that all LDAP delete operations should be performed using
-         * the LDAP subtree delete control. The default behavior is to not use
-         * the subtree delete control.
-         *
-         * @return A reference to this LDAP resource collection builder.
-         */
-        public Builder useSubtreeDelete() {
-            this.useSubtreeDelete = true;
-            return this;
-        }
-
-        private AttributeDescription ad(final String attribute) {
-            return AttributeDescription.valueOf(attribute, schema);
-        }
-
-        private AttributeType at(final String attribute) {
-            return schema.getAttributeType(attribute);
-        }
-
-        private PropertyMapper configureMapper(final JsonValue mapper) {
-            if (mapper.isDefined("constant")) {
-                return constant(mapper.get("constant").getObject());
-            } else if (mapper.isDefined("simple")) {
-                final JsonValue config = mapper.get("simple");
-                final SimplePropertyMapper s =
-                        simple(ad(config.get("ldapAttribute").required().asString()));
-                if (config.isDefined("defaultJSONValue")) {
-                    s.defaultJsonValue(config.get("defaultJSONValue").getObject());
-                }
-                if (config.get("isBinary").defaultTo(false).asBoolean()) {
-                    s.isBinary();
-                }
-                if (config.get("isRequired").defaultTo(false).asBoolean()) {
-                    s.isRequired();
-                }
-                if (config.get("isSingleValued").defaultTo(false).asBoolean()) {
-                    s.isSingleValued();
-                }
-                s.writability(parseWritability(mapper, config));
-                return s;
-            } else if (mapper.isDefined("reference")) {
-                final JsonValue config = mapper.get("reference");
-                final AttributeDescription ldapAttribute =
-                        ad(config.get("ldapAttribute").required().asString());
-                final DN baseDN = DN.valueOf(config.get("baseDN").required().asString(), schema);
-                final AttributeDescription primaryKey =
-                        ad(config.get("primaryKey").required().asString());
-                final PropertyMapper m = configureMapper(config.get("mapper").required());
-                final ReferencePropertyMapper r = reference(ldapAttribute, baseDN, primaryKey, m);
-                if (config.get("isRequired").defaultTo(false).asBoolean()) {
-                    r.isRequired();
-                }
-                if (config.get("isSingleValued").defaultTo(false).asBoolean()) {
-                    r.isSingleValued();
-                }
-                if (config.isDefined("searchFilter")) {
-                    r.searchFilter(config.get("searchFilter").asString());
-                }
-                r.writability(parseWritability(mapper, config));
-                return r;
-            } else if (mapper.isDefined("object")) {
-                return configureObjectMapper(mapper.get("object"));
-            } else {
-                throw newJsonValueException(mapper, ERR_CONFIG_NO_MAPPING_IN_CONFIGURATION.get(
-                        "constant, simple, reference or object"));
-            }
-        }
-
-        private ObjectPropertyMapper configureObjectMapper(final JsonValue mapper) {
-            final ObjectPropertyMapper object = object();
-            for (final String attribute : mapper.keys()) {
-                object.attribute(attribute, configureMapper(mapper.get(attribute)));
-            }
-            return object;
-        }
-
-        private WritabilityPolicy parseWritability(final JsonValue mapper, final JsonValue config) {
-            if (config.isDefined("writability")) {
-                final String writability = config.get("writability").asString();
-                if (writability.equalsIgnoreCase("readOnly")) {
-                    return WritabilityPolicy.READ_ONLY;
-                } else if (writability.equalsIgnoreCase("readOnlyDiscardWrites")) {
-                    return WritabilityPolicy.READ_ONLY_DISCARD_WRITES;
-                } else if (writability.equalsIgnoreCase("createOnly")) {
-                    return WritabilityPolicy.CREATE_ONLY;
-                } else if (writability.equalsIgnoreCase("createOnlyDiscardWrites")) {
-                    return WritabilityPolicy.CREATE_ONLY_DISCARD_WRITES;
-                } else if (writability.equalsIgnoreCase("readWrite")) {
-                    return WritabilityPolicy.READ_WRITE;
-                } else {
-                    throw newJsonValueException(mapper, ERR_CONFIG_UNKNOWN_WRITABILITY.get(writability,
-                                "readOnly, readOnlyDiscardWrites, createOnly, createOnlyDiscardWrites, or readWrite"));
-                }
-            } else {
-                return WritabilityPolicy.READ_WRITE;
-            }
-        }
-    }
-
-    private static final class AttributeNamingStrategy extends NamingStrategy {
-        private final AttributeDescription dnAttribute;
-        private final AttributeDescription idAttribute;
-        private final boolean isServerProvided;
-
-        private AttributeNamingStrategy(final AttributeType dnAttribute,
-                                        final AttributeDescription idAttribute, final boolean isServerProvided) {
-            this.dnAttribute = AttributeDescription.create(dnAttribute);
-            if (this.dnAttribute.equals(idAttribute)) {
-                throw newLocalizedIllegalArgumentException(ERR_CONFIG_NAMING_STRATEGY_DN_AND_ID_NOT_DIFFERENT.get());
-            }
-            this.idAttribute = checkNotNull(idAttribute);
-            this.isServerProvided = isServerProvided;
-        }
-
-        @Override
-        SearchRequest createSearchRequest(final Connection connection, final DN baseDN, final String resourceId) {
-            return newSearchRequest(baseDN, SearchScope.SINGLE_LEVEL, Filter.equality(idAttribute
-                    .toString(), resourceId));
-        }
-
-        @Override
-        void getLdapAttributes(final Connection connection, final Set<String> ldapAttributes) {
-            ldapAttributes.add(idAttribute.toString());
-        }
-
-        @Override
-        String getResourceId(final Connection connection, final Entry entry) {
-            return entry.parseAttribute(idAttribute).asString();
-        }
-
-        @Override
-        void setResourceId(final Connection connection, final DN baseDN, final String resourceId,
-                final Entry entry) throws ResourceException {
-            if (isServerProvided) {
-                if (resourceId != null) {
-                    throw newBadRequestException(ERR_CLIENT_PROVIDER_RESOURCE_ID_MISSING.get());
-                }
-            } else {
-                entry.addAttribute(new LinkedAttribute(idAttribute, ByteString.valueOfUtf8(resourceId)));
-            }
-            final String rdnValue = entry.parseAttribute(dnAttribute).asString();
-            final RDN rdn = new RDN(dnAttribute.getAttributeType(), rdnValue);
-            entry.setName(baseDN.child(rdn));
-        }
-    }
-
-    private static final class DNNamingStrategy extends NamingStrategy {
-        private final AttributeDescription attribute;
-
-        private DNNamingStrategy(final AttributeType attribute) {
-            this.attribute = AttributeDescription.create(attribute);
-        }
-
-        @Override
-        SearchRequest createSearchRequest(final Connection connection, final DN baseDN, final String resourceId) {
-            return newSearchRequest(baseDN.child(rdn(resourceId)), SearchScope.BASE_OBJECT, Filter
-                    .objectClassPresent());
-        }
-
-        @Override
-        void getLdapAttributes(final Connection connection, final Set<String> ldapAttributes) {
-            ldapAttributes.add(attribute.toString());
-        }
-
-        @Override
-        String getResourceId(final Connection connection, final Entry entry) {
-            return entry.parseAttribute(attribute).asString();
-        }
-
-        @Override
-        void setResourceId(final Connection connection, final DN baseDN, final String resourceId,
-                final Entry entry) throws ResourceException {
-            if (resourceId != null) {
-                entry.setName(baseDN.child(rdn(resourceId)));
-                entry.addAttribute(new LinkedAttribute(attribute, ByteString.valueOfUtf8(resourceId)));
-            } else if (entry.getAttribute(attribute) != null) {
-                entry.setName(baseDN.child(rdn(entry.parseAttribute(attribute).asString())));
-            } else {
-                throw newBadRequestException(ERR_CLIENT_PROVIDER_RESOURCE_ID_MISSING.get());
-            }
-        }
-
-        private RDN rdn(final String resourceId) {
-            return new RDN(attribute.getAttributeType(), resourceId);
-        }
-    }
+    public static final Option<Boolean> USE_MVCC = Option.withDefault(true);
+    /**
+     * Specifies the name of the LDAP attribute which should be used for multi-version concurrency control (MVCC) if
+     * {@link #USE_MVCC enabled}. By default Rest2Ldap will use the "etag" operational attribute.
+     */
+    public static final Option<String> MVCC_ATTRIBUTE = Option.withDefault("etag");
+    /**
+     * Specifies the policy which should be used in order to read an entry before it is deleted, or after it is added or
+     * modified. By default Rest2Ldap will use the {@link ReadOnUpdatePolicy#CONTROLS controls} read on update policy.
+     */
+    public static final Option<ReadOnUpdatePolicy> READ_ON_UPDATE_POLICY = Option.withDefault(CONTROLS);
+    /**
+     * Specifies whether Rest2Ldap should perform LDAP modify operations using the LDAP permissive modify
+     * control. By default Rest2Ldap will use the permissive modify control and use of the control is strongly
+     * recommended.
+     */
+    public static final Option<Boolean> USE_PERMISSIVE_MODIFY = Option.withDefault(true);
+    /**
+     * Specifies whether Rest2Ldap should perform LDAP delete operations using the LDAP subtree delete control. By
+     * default Rest2Ldap will use the subtree delete control and use of the control is strongly recommended.
+     */
+    public static final Option<Boolean> USE_SUBTREE_DELETE = Option.withDefault(true);
 
     /**
-     * Adapts a {@code Throwable} to a {@code ResourceException}. If the
-     * {@code Throwable} is an LDAP {@link LdapException} then an
-     * appropriate {@code ResourceException} is returned, otherwise an
-     * {@code InternalServerErrorException} is returned.
+     * Creates a new {@link Rest2Ldap} instance using the provided options and {@link Resource resources}.
+     * Applications should call {@link #newRequestHandlerFor(String)} to obtain a request handler for a specific
+     * resource.
+     * <p>
+     * The supported options are defined in this class.
      *
-     * @param t
-     *            The {@code Throwable} to be converted.
-     * @return The equivalent resource exception.
+     * @param options The configuration options for interactions with the backend LDAP server. The set of available
+     *                options are provided in this class.
+     * @param resources The list of resources.
+     * @return A new Rest2Ldap instance from which REST request handlers can be obtained.
      */
-    public static ResourceException asResourceException(final Throwable t) {
-        int resourceResultCode;
-        try {
-            throw t;
-        } catch (final ResourceException e) {
-            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) {
-            resourceResultCode = ResourceException.FORBIDDEN;
-        } catch (final ConnectionException e) {
-            resourceResultCode = ResourceException.UNAVAILABLE;
-        } catch (final EntryNotFoundException e) {
-            resourceResultCode = ResourceException.NOT_FOUND;
-        } catch (final MultipleEntriesFoundException e) {
-            resourceResultCode = ResourceException.INTERNAL_ERROR;
-        } catch (final TimeoutResultException e) {
-            resourceResultCode = 408;
-        } catch (final LdapException e) {
-            final ResultCode rc = e.getResult().getResultCode();
-            if (rc.equals(ResultCode.ADMIN_LIMIT_EXCEEDED)) {
-                resourceResultCode = 413; // Request Entity Too Large
-            } else if (rc.equals(ResultCode.SIZE_LIMIT_EXCEEDED)) {
-                resourceResultCode = 413; // Request Entity Too Large
-            } else {
-                resourceResultCode = ResourceException.INTERNAL_ERROR;
-            }
-        } catch (final Throwable tmp) {
-            resourceResultCode = ResourceException.INTERNAL_ERROR;
-        }
-        return newResourceException(resourceResultCode, t.getMessage(), t);
+    public static Rest2Ldap rest2Ldap(final Options options, final Collection<Resource> resources) {
+        return new Rest2Ldap(options, resources);
     }
 
     /**
-     * Returns a builder for incrementally constructing LDAP resource
-     * collections.
+     * Creates a new {@link Rest2Ldap} instance using the provided options and {@link Resource resources}.
+     * Applications should call {@link #newRequestHandlerFor(String)} to obtain a request handler for a specific
+     * resource.
+     * <p>
+     * The supported options are defined in this class.
      *
-     * @return An LDAP resource collection builder.
+     * @param options The configuration options for interactions with the backend LDAP server. The set of available
+     *                options are provided in this class.
+     * @param resources The list of resources.
+     * @return A new Rest2Ldap instance from which REST request handlers can be obtained.
      */
-    public static Builder builder() {
-        return new Builder();
+    public static Rest2Ldap rest2Ldap(final Options options, final Resource... resources) {
+        return rest2Ldap(options, Arrays.asList(resources));
     }
 
     /**
-     * Creates a new connection factory using the named configuration in the
-     * provided JSON list of factory configurations. See the sample
-     * configuration file for a detailed description of its content.
+     * Creates a new {@link Resource resource} definition with the provided resource ID.
      *
-     * @param configuration
-     *            The JSON configuration.
-     * @param name
-     *            The name of the connection factory configuration to be parsed.
-     * @param trustManager
-     *            The trust manager to use for secure connection. Can be {@code null}
-     *            to use the default JVM trust manager.
-     * @param keyManager
-     *            The key manager to use for secure connection. Can be {@code null}
-     *            to use the default JVM key manager.
-     * @param providerClassLoader
-     *            The {@link ClassLoader} used to fetch the
-     *            {@link org.forgerock.opendj.ldap.spi.TransportProvider}.
-     *            This can be useful in OSGI environments.
-     * @return A new connection factory using the provided JSON configuration.
-     * @throws IllegalArgumentException
-     *             If the configuration is invalid.
+     * @param resourceId
+     *         The resource ID.
+     * @return A new resource definition with the provided resource ID.
      */
-    public static ConnectionFactory configureConnectionFactory(final JsonValue configuration,
-                                                               final String name,
-                                                               final TrustManager trustManager,
-                                                               final X509KeyManager keyManager,
-                                                               final ClassLoader providerClassLoader) {
-        final JsonValue normalizedConfiguration =
-                normalizeConnectionFactory(configuration, name, 0);
-        return configureConnectionFactory(normalizedConfiguration, trustManager, keyManager, providerClassLoader);
+    public static Resource resource(final String resourceId) {
+        return new Resource(resourceId);
     }
 
     /**
-     * Creates a new connection factory using the named configuration in the
-     * provided JSON list of factory configurations. See the sample
-     * configuration file for a detailed description of its content.
+     * Creates a new {@link SubResourceCollection collection} sub-resource definition whose members will be resources
+     * having the provided resource ID or its sub-types.
      *
-     * @param configuration
-     *            The JSON configuration.
-     * @param name
-     *            The name of the connection factory configuration to be parsed.
-     * @param trustManager
-     *            The trust manager to use for secure connection. Can be {@code null}
-     *            to use the default JVM trust manager.
-     * @param keyManager
-     *            The key manager to use for secure connection. Can be {@code null}
-     *            to use the default JVM key manager.
-     * @return A new connection factory using the provided JSON configuration.
-     * @throws IllegalArgumentException
-     *             If the configuration is invalid.
+     * @param resourceId
+     *         The type of resource contained in the sub-resource collection.
+     * @return A new sub-resource definition with the provided resource ID.
      */
-    public static ConnectionFactory configureConnectionFactory(final JsonValue configuration,
-            final String name, final TrustManager trustManager, final X509KeyManager keyManager) {
-        return configureConnectionFactory(configuration, name, trustManager, keyManager, null);
+    public static SubResourceCollection collectionOf(final String resourceId) {
+        return new SubResourceCollection(resourceId);
     }
 
     /**
-     * Returns an property mapper which maps a single JSON attribute to a JSON
-     * constant.
+     * Creates a new {@link SubResourceSingleton singleton} sub-resource definition which will reference a single
+     * resource having the specified resource ID.
+     *
+     * @param resourceId
+     *         The type of resource referenced by the sub-resource singleton.
+     * @return A new sub-resource definition with the provided resource ID.
+     */
+    public static SubResourceSingleton singletonOf(final String resourceId) {
+        return new SubResourceSingleton(resourceId);
+    }
+
+    /**
+     * Returns a property mapper which maps a JSON property containing the resource type to its associated LDAP
+     * object classes.
+     *
+     * @return The property mapper.
+     */
+    public static PropertyMapper resourceType() {
+        return ResourceTypePropertyMapper.INSTANCE;
+    }
+
+    /**
+     * Returns a property mapper which maps a single JSON attribute to a JSON constant.
      *
      * @param value
-     *            The constant JSON value (a Boolean, Number, String, Map, or
-     *            List).
+     *         The constant JSON value (a Boolean, Number, String, Map, or List).
      * @return The property mapper.
      */
     public static PropertyMapper constant(final Object value) {
@@ -857,7 +218,7 @@
     }
 
     /**
-     * Returns an property mapper which maps JSON objects to LDAP attributes.
+     * Returns a property mapper which maps JSON objects to LDAP attributes.
      *
      * @return The property mapper.
      */
@@ -866,55 +227,50 @@
     }
 
     /**
-     * Returns an property mapper which provides a mapping from a JSON value to
-     * a single DN valued LDAP attribute.
+     * Returns a property mapper which provides a mapping from a JSON value to a single DN valued LDAP attribute.
      *
      * @param attribute
-     *            The DN valued LDAP attribute to be mapped.
+     *         The DN valued LDAP attribute to be mapped.
      * @param baseDN
-     *            The search base DN for performing reverse lookups.
+     *         The search base DN for performing reverse lookups.
      * @param primaryKey
-     *            The search primary key LDAP attribute to use for performing
-     *            reverse lookups.
+     *         The search primary key LDAP attribute to use for performing reverse lookups.
      * @param mapper
-     *            An property mapper which will be used to map LDAP attributes
-     *            in the referenced entry.
+     *         An property mapper which will be used to map LDAP attributes in the referenced entry.
      * @return The property mapper.
      */
-    public static ReferencePropertyMapper reference(final AttributeDescription attribute,
-                                                    final DN baseDN, final AttributeDescription primaryKey,
+    public static ReferencePropertyMapper reference(final AttributeDescription attribute, final DN baseDN,
+                                                    final AttributeDescription primaryKey,
                                                     final PropertyMapper mapper) {
         return new ReferencePropertyMapper(Schema.getDefaultSchema(), attribute, baseDN, primaryKey, mapper);
     }
 
     /**
-     * Returns an property mapper which provides a mapping from a JSON value to
-     * a single DN valued LDAP attribute.
+     * Returns a property mapper which provides a mapping from a JSON value to a single DN valued LDAP attribute.
      *
      * @param attribute
-     *            The DN valued LDAP attribute to be mapped.
+     *         The DN valued LDAP attribute to be mapped.
      * @param baseDN
-     *            The search base DN for performing reverse lookups.
+     *         The search base DN for performing reverse lookups.
      * @param primaryKey
-     *            The search primary key LDAP attribute to use for performing
-     *            reverse lookups.
+     *         The search primary key LDAP attribute to use for performing reverse lookups.
      * @param mapper
-     *            An property mapper which will be used to map LDAP attributes
-     *            in the referenced entry.
+     *         An property mapper which will be used to map LDAP attributes in the referenced entry.
      * @return The property mapper.
      */
     public static ReferencePropertyMapper reference(final String attribute, final String baseDN,
                                                     final String primaryKey, final PropertyMapper mapper) {
-        return reference(AttributeDescription.valueOf(attribute), DN.valueOf(baseDN),
-                AttributeDescription.valueOf(primaryKey), mapper);
+        return reference(AttributeDescription.valueOf(attribute),
+                         DN.valueOf(baseDN),
+                         AttributeDescription.valueOf(primaryKey),
+                         mapper);
     }
 
     /**
-     * Returns an property mapper which provides a simple mapping from a JSON
-     * value to a single LDAP attribute.
+     * Returns a property mapper which provides a simple mapping from a JSON value to a single LDAP attribute.
      *
      * @param attribute
-     *            The LDAP attribute to be mapped.
+     *         The LDAP attribute to be mapped.
      * @return The property mapper.
      */
     public static SimplePropertyMapper simple(final AttributeDescription attribute) {
@@ -922,142 +278,95 @@
     }
 
     /**
-     * Returns an property mapper which provides a simple mapping from a JSON
-     * value to a single LDAP attribute.
+     * Returns a property mapper which provides a simple mapping from a JSON value to a single LDAP attribute.
      *
      * @param attribute
-     *            The LDAP attribute to be mapped.
+     *         The LDAP attribute to be mapped.
      * @return The property mapper.
      */
     public static SimplePropertyMapper simple(final String attribute) {
         return simple(AttributeDescription.valueOf(attribute));
     }
 
-    private static ConnectionFactory configureConnectionFactory(final JsonValue configuration,
-                                                                final TrustManager trustManager,
-                                                                final X509KeyManager keyManager,
-                                                                final ClassLoader providerClassLoader) {
-        final long heartBeatIntervalSeconds = configuration.get("heartBeatIntervalSeconds").defaultTo(30L).asLong();
-        final Duration heartBeatInterval = duration(Math.max(heartBeatIntervalSeconds, 1L), TimeUnit.SECONDS);
-
-        final long heartBeatTimeoutMillis = configuration.get("heartBeatTimeoutMilliSeconds").defaultTo(500L).asLong();
-        final Duration heartBeatTimeout = duration(Math.max(heartBeatTimeoutMillis, 100L), TimeUnit.MILLISECONDS);
-
-        final Options options = Options.defaultOptions()
-                                       .set(TRANSPORT_PROVIDER_CLASS_LOADER, providerClassLoader)
-                                       .set(HEARTBEAT_ENABLED, true)
-                                       .set(HEARTBEAT_INTERVAL, heartBeatInterval)
-                                       .set(HEARTBEAT_TIMEOUT, heartBeatTimeout)
-                                       .set(LOAD_BALANCER_MONITORING_INTERVAL, heartBeatInterval);
-
-        // Parse pool parameters,
-        final int connectionPoolSize =
-                Math.max(configuration.get("connectionPoolSize").defaultTo(10).asInteger(), 1);
-
-        // Parse authentication parameters.
-        if (configuration.isDefined("authentication")) {
-            final JsonValue authn = configuration.get("authentication");
-            if (authn.isDefined("simple")) {
-                final JsonValue simple = authn.get("simple");
-                final BindRequest bindRequest =
-                        Requests.newSimpleBindRequest(simple.get("bindDN").required().asString(),
-                                simple.get("bindPassword").required().asString().toCharArray());
-                options.set(AUTHN_BIND_REQUEST, bindRequest);
+    /**
+     * Adapts a {@code Throwable} to a {@code ResourceException}. If the {@code Throwable} is an LDAP
+     * {@link LdapException} then an appropriate {@code ResourceException} is returned, otherwise an {@code
+     * InternalServerErrorException} is returned.
+     * @param t
+     *         The {@code Throwable} to be converted.
+     * @return The equivalent resource exception.
+     */
+    public static ResourceException asResourceException(final Throwable t) {
+        try {
+            throw t;
+        } catch (final ResourceException e) {
+            return e;
+        } catch (final AssertionFailureException e) {
+            return new PreconditionFailedException(e);
+        } catch (final ConstraintViolationException e) {
+            final ResultCode rc = e.getResult().getResultCode();
+            if (rc.equals(ENTRY_ALREADY_EXISTS)) {
+                return new PreconditionFailedException(e); // Consistent with MVCC.
             } else {
-                throw newLocalizedIllegalArgumentException(ERR_CONFIG_INVALID_AUTHENTICATION.get());
+                return new BadRequestException(e); // Schema violation, etc.
             }
-        }
-
-        // Parse SSL/StartTLS parameters.
-        final ConnectionSecurity connectionSecurity =
-                configuration.get("connectionSecurity").defaultTo(ConnectionSecurity.NONE).asEnum(
-                        ConnectionSecurity.class);
-        if (connectionSecurity != ConnectionSecurity.NONE) {
-            try {
-                // Configure SSL.
-                final SSLContextBuilder builder = new SSLContextBuilder();
-                builder.setTrustManager(trustManager);
-                final String sslCertAlias = configuration.get("sslCertAlias").asString();
-                builder.setKeyManager(sslCertAlias != null
-                        ? useSingleCertificate(sslCertAlias, keyManager)
-                        : keyManager);
-                options.set(SSL_CONTEXT, builder.getSSLContext());
-                options.set(SSL_USE_STARTTLS, connectionSecurity == ConnectionSecurity.STARTTLS);
-            } catch (GeneralSecurityException e) {
-                // Rethrow as unchecked exception.
-                throw new IllegalArgumentException(e);
-            }
-        }
-
-        // Parse primary data center.
-        final JsonValue primaryLDAPServers = configuration.get("primaryLDAPServers");
-        if (!primaryLDAPServers.isList() || primaryLDAPServers.size() == 0) {
-            throw new IllegalArgumentException("No primaryLDAPServers");
-        }
-        final ConnectionFactory primary = parseLDAPServers(primaryLDAPServers, connectionPoolSize, options);
-
-        // Parse secondary data center(s).
-        final JsonValue secondaryLDAPServers = configuration.get("secondaryLDAPServers");
-        ConnectionFactory secondary = null;
-        if (secondaryLDAPServers.isList()) {
-            if (secondaryLDAPServers.size() > 0) {
-                secondary = parseLDAPServers(secondaryLDAPServers, connectionPoolSize, options);
-            }
-        } else if (!secondaryLDAPServers.isNull()) {
-            throw newLocalizedIllegalArgumentException(ERR_CONFIG_INVALID_SECONDARY_LDAP_SERVER.get());
-        }
-
-        // Create fail-over.
-        if (secondary != null) {
-            return newFailoverLoadBalancer(asList(primary, secondary), options);
-        } else {
-            return primary;
-        }
-    }
-
-    private static JsonValue normalizeConnectionFactory(final JsonValue configuration,
-            final String name, final int depth) {
-        // Protect against infinite recursion in the configuration.
-        if (depth > 100) {
-            throw newLocalizedIllegalArgumentException(ERR_CONFIG_SERVER_CIRCULAR_DEPENDENCIES.get(name));
-        }
-
-        final JsonValue current = configuration.get(name).required();
-        if (current.isDefined("inheritFrom")) {
-            // Inherit missing fields from inherited configuration.
-            final JsonValue parent =
-                    normalizeConnectionFactory(configuration,
-                            current.get("inheritFrom").asString(), depth + 1);
-            final Map<String, Object> normalized = new LinkedHashMap<>(parent.asMap());
-            normalized.putAll(current.asMap());
-            normalized.remove("inheritFrom");
-            return new JsonValue(normalized);
-        } else {
-            // No normalization required.
-            return current;
-        }
-    }
-
-    private static ConnectionFactory parseLDAPServers(JsonValue config, int poolSize, Options options) {
-        final List<ConnectionFactory> servers = new ArrayList<>(config.size());
-        for (final JsonValue server : config) {
-            final String host = server.get("hostname").required().asString();
-            final int port = server.get("port").required().asInteger();
-            final ConnectionFactory factory = new LDAPConnectionFactory(host, port, options);
-            if (poolSize > 1) {
-                servers.add(newCachedConnectionPool(factory, 0, poolSize, 60L, TimeUnit.SECONDS));
+        } catch (final AuthenticationException e) {
+            return new PermanentException(401, null, e); // Unauthorized
+        } catch (final AuthorizationException e) {
+            return new ForbiddenException(e);
+        } catch (final ConnectionException e) {
+            return new ServiceUnavailableException(e);
+        } catch (final EntryNotFoundException e) {
+            return new NotFoundException(e);
+        } catch (final MultipleEntriesFoundException e) {
+            return new InternalServerErrorException(e);
+        } catch (final TimeoutResultException e) {
+            return new RetryableException(408, null, e); // Request Timeout
+        } catch (final LdapException e) {
+            final ResultCode rc = e.getResult().getResultCode();
+            if (rc.equals(ADMIN_LIMIT_EXCEEDED) || rc.equals(SIZE_LIMIT_EXCEEDED)) {
+                return new PermanentException(413, null, e); // Payload Too Large (Request Entity Too Large)
             } else {
-                servers.add(factory);
+                return new InternalServerErrorException(e);
             }
-        }
-        if (servers.size() > 1) {
-            return newRoundRobinLoadBalancer(servers, options);
-        } else {
-            return servers.get(0);
+        } catch (final Throwable tmp) {
+            return new InternalServerErrorException(t);
         }
     }
 
-    private Rest2Ldap() {
-        // Prevent instantiation.
+    private final Map<String, Resource> resources = new LinkedHashMap<>();
+    private final Options options;
+
+    private Rest2Ldap(final Options options, final Collection<Resource> resources) {
+        this.options = options;
+        for (final Resource resource : resources) {
+            this.resources.put(resource.getResourceId(), resource);
+        }
+        // Now build the model.
+        for (final Resource resource : resources) {
+            resource.build(this);
+        }
+    }
+
+    /**
+     * Returns a {@link RequestHandler} which will handle requests to the named resource and any of its sub-resources.
+     *
+     * @param resourceId
+     *         The resource ID.
+     * @return A {@link RequestHandler} which will handle requests to the named resource.
+     */
+    public RequestHandler newRequestHandlerFor(final String resourceId) {
+        Reject.ifTrue(!resources.containsKey(resourceId), "unrecognized resource '" + resourceId + "'");
+        final SubResourceSingleton root = singletonOf(resourceId);
+        root.build(this, null);
+        return root.addRoutes(new Router());
+    }
+
+    Options getOptions() {
+        return options;
+    }
+
+    Resource getResource(final String resourceId) {
+        return resources.get(resourceId);
     }
 }
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java
index d5f56be..7953a1e 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapHttpApplication.java
@@ -16,39 +16,37 @@
 
 package org.forgerock.opendj.rest2ldap;
 
-import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
-import static org.forgerock.http.handler.HttpClientHandler.*;
-import static org.forgerock.opendj.ldap.KeyManagers.*;
-import static org.forgerock.opendj.ldap.TrustManagers.checkUsingTrustStore;
-import static org.forgerock.opendj.ldap.TrustManagers.trustAll;
-import static org.forgerock.http.util.Json.readJsonLenient;
+import static org.forgerock.http.handler.HttpClientHandler.OPTION_KEY_MANAGERS;
+import static org.forgerock.http.handler.HttpClientHandler.OPTION_TRUST_MANAGERS;
 import static org.forgerock.json.JsonValueFunctions.duration;
 import static org.forgerock.json.JsonValueFunctions.enumConstant;
 import static org.forgerock.json.JsonValueFunctions.setOf;
-import static org.forgerock.opendj.rest2ldap.Rest2Ldap.configureConnectionFactory;
-import static org.forgerock.opendj.rest2ldap.Utils.newLocalizedIllegalArgumentException;
+import static org.forgerock.json.resource.http.CrestHttp.newHttpHandler;
+import static org.forgerock.opendj.ldap.KeyManagers.useSingleCertificate;
+import static org.forgerock.opendj.rest2ldap.Rest2LdapJsonConfigurator.*;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
 import static org.forgerock.opendj.rest2ldap.Utils.newJsonValueException;
-import static org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategies.*;
+import static org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategies.newSASLPlainStrategy;
+import static org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategies.newSearchThenBindStrategy;
+import static org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategies.newSimpleBindStrategy;
 import static org.forgerock.opendj.rest2ldap.authz.Authorizations.*;
-import static org.forgerock.opendj.rest2ldap.authz.ConditionalFilters.*;
-import static org.forgerock.opendj.rest2ldap.authz.CredentialExtractors.*;
+import static org.forgerock.opendj.rest2ldap.authz.ConditionalFilters.newConditionalFilter;
+import static org.forgerock.opendj.rest2ldap.authz.CredentialExtractors.httpBasicExtractor;
+import static org.forgerock.opendj.rest2ldap.authz.CredentialExtractors.newCustomHeaderExtractor;
 import static org.forgerock.util.Reject.checkNotNull;
 import static org.forgerock.util.Utils.closeSilently;
 import static org.forgerock.util.Utils.joinAsString;
-import static org.forgerock.opendj.rest2ldap.Utils.readPasswordFromFile;
 
+import java.io.File;
 import java.io.IOException;
-import java.io.InputStream;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
-import java.security.GeneralSecurityException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 
@@ -56,11 +54,6 @@
 import javax.net.ssl.TrustManager;
 import javax.net.ssl.X509KeyManager;
 
-import org.forgerock.openig.oauth2.AccessTokenInfo;
-import org.forgerock.openig.oauth2.AccessTokenException;
-import org.forgerock.openig.oauth2.AccessTokenResolver;
-import org.forgerock.openig.oauth2.resolver.CachingAccessTokenResolver;
-import org.forgerock.openig.oauth2.resolver.OpenAmAccessTokenResolver;
 import org.forgerock.http.Filter;
 import org.forgerock.http.Handler;
 import org.forgerock.http.HttpApplication;
@@ -71,20 +64,22 @@
 import org.forgerock.http.io.Buffer;
 import org.forgerock.http.protocol.Headers;
 import org.forgerock.i18n.LocalizableMessage;
+import org.forgerock.i18n.LocalizedIllegalArgumentException;
 import org.forgerock.i18n.slf4j.LocalizedLogger;
 import org.forgerock.json.JsonValue;
 import org.forgerock.json.resource.RequestHandler;
-import org.forgerock.json.resource.Router;
-import org.forgerock.json.resource.http.CrestHttp;
 import org.forgerock.opendj.ldap.Connection;
 import org.forgerock.opendj.ldap.ConnectionFactory;
 import org.forgerock.opendj.ldap.DN;
 import org.forgerock.opendj.ldap.SearchScope;
 import org.forgerock.opendj.ldap.schema.Schema;
-import org.forgerock.opendj.rest2ldap.Rest2LDAP.KeyManagerType;
-import org.forgerock.opendj.rest2ldap.Rest2LDAP.TrustManagerType;
 import org.forgerock.opendj.rest2ldap.authz.AuthenticationStrategy;
 import org.forgerock.opendj.rest2ldap.authz.ConditionalFilters.ConditionalFilter;
+import org.forgerock.openig.oauth2.AccessTokenException;
+import org.forgerock.openig.oauth2.AccessTokenInfo;
+import org.forgerock.openig.oauth2.AccessTokenResolver;
+import org.forgerock.openig.oauth2.resolver.CachingAccessTokenResolver;
+import org.forgerock.openig.oauth2.resolver.OpenAmAccessTokenResolver;
 import org.forgerock.services.context.SecurityContext;
 import org.forgerock.util.Factory;
 import org.forgerock.util.Function;
@@ -116,8 +111,8 @@
 
     private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
 
-    /** URL to the JSON configuration file. */
-    protected final URL configurationUrl;
+    /** The name of the JSON configuration directory in which config.json and rest2ldap/rest2ldap.json are located. */
+    protected final File configDirectory;
 
     /** Schema used to perform DN validations. */
     protected final Schema schema;
@@ -166,37 +161,47 @@
     }
 
     /**
-     * Default constructor called by the HTTP Framework which will use the default configuration file location.
+     * Default constructor called by the HTTP Framework which will use the default configuration directory.
      */
     public Rest2LdapHttpApplication() {
-        this.configurationUrl = getClass().getResource("/opendj-rest2ldap-config.json");
+        try {
+            // The null check is required for unit test mocks because the resource does not exist.
+            final URL configUrl = getClass().getResource("/config.json");
+            this.configDirectory = configUrl != null ? new File(configUrl.toURI()).getParentFile() : null;
+        } catch (final URISyntaxException e) {
+            throw new IllegalStateException(e);
+        }
         this.schema = Schema.getDefaultSchema();
     }
 
     /**
-     * Creates a new Rest2LDAP HTTP application using the provided configuration URL.
+     * Creates a new Rest2LDAP HTTP application using the provided configuration directory.
      *
-     * @param configurationURL
-     *            The URL to the JSON configuration file
+     * @param configDirectory
+     *         The name of the JSON configuration directory in which config.json and rest2ldap/rest2ldap.json are
+     *         located.
      * @param schema
-     *            The {@link Schema} used to perform DN validations
+     *         The {@link Schema} used to perform DN validations
      */
-    public Rest2LdapHttpApplication(final URL configurationURL, final Schema schema) {
-        this.configurationUrl = checkNotNull(configurationURL, "configurationURL cannot be null");
+    public Rest2LdapHttpApplication(final File configDirectory, final Schema schema) {
+        this.configDirectory = checkNotNull(configDirectory, "configDirectory cannot be null");
         this.schema = checkNotNull(schema, "schema cannot be null");
     }
 
     @Override
     public final Handler start() throws HttpApplicationException {
         try {
-            final JsonValue configuration = readJson(configurationUrl);
+            logger.info(INFO_REST2LDAP_STARTING.get(configDirectory));
+
             executorService = Executors.newSingleThreadScheduledExecutor();
-            configureSecurity(configuration.get("security"));
-            configureConnectionFactories(configuration.get("ldapConnectionFactories"));
-            return Handlers.chainOf(
-                    CrestHttp.newHttpHandler(configureRest2Ldap(configuration)),
-                    new ErrorLoggerFilter(),
-                    buildAuthorizationFilter(configuration.get("authorization").required()));
+
+            final JsonValue config = readJson(new File(configDirectory, "config.json"));
+            configureSecurity(config.get("security"));
+            configureConnectionFactories(config.get("ldapConnectionFactories"));
+            final Filter authorizationFilter = buildAuthorizationFilter(config.get("authorization").required());
+            return Handlers.chainOf(newHttpHandler(configureRest2Ldap(configDirectory)),
+                                    new ErrorLoggerFilter(),
+                                    authorizationFilter);
         } catch (final Exception e) {
             final LocalizableMessage errorMsg = ERR_FAIL_PARSE_CONFIGURATION.get(e.getLocalizedMessage());
             logger.error(errorMsg, e);
@@ -205,86 +210,16 @@
         }
     }
 
-    private static JsonValue readJson(final URL resource) throws IOException {
-        try (InputStream in = resource.openStream()) {
-            return new JsonValue(readJsonLenient(in));
-        }
-    }
-
-    private static RequestHandler configureRest2Ldap(final JsonValue configuration) {
-        final JsonValue mappings = configuration.get("mappings").required();
-        final Router router = new Router();
-        for (final String mappingUrl : mappings.keys()) {
-            final JsonValue mapping = mappings.get(mappingUrl);
-            router.addRoute(Router.uriTemplate(mappingUrl), Rest2Ldap.builder().configureMapping(mapping).build());
-        }
-        return router;
+    private static RequestHandler configureRest2Ldap(final File configDirectory) throws IOException {
+        final File rest2LdapConfigDirectory = new File(configDirectory, "rest2ldap");
+        final Options options = configureOptions(readJson(new File(rest2LdapConfigDirectory, "rest2ldap.json")));
+        final File endpointsDirectory = new File(rest2LdapConfigDirectory, "endpoints");
+        return configureEndpoints(endpointsDirectory, options);
     }
 
     private void configureSecurity(final JsonValue configuration) {
-        try {
-            trustManager = configureTrustManager(configuration, TrustManagerType.JVM);
-        } catch (GeneralSecurityException | IOException e) {
-            throw new IllegalArgumentException(ERR_CONFIG_INVALID_TRUST_MANAGER
-                    .get(configuration.getPointer(), e.getLocalizedMessage()).toString(), e);
-        }
-
-        try {
-            keyManager = configureKeyManager(configuration, KeyManagerType.JVM);
-        } catch (GeneralSecurityException | IOException e) {
-            throw new IllegalArgumentException(ERR_CONFIG_INVALID_KEY_MANAGER
-                    .get(configuration.getPointer(), e.getLocalizedMessage()).toString(), e);
-        }
-    }
-
-    private TrustManager configureTrustManager(JsonValue config, TrustManagerType defaultIfMissing)
-            throws GeneralSecurityException, IOException {
-        // Parse trust store configuration.
-        final TrustManagerType trustManagerType =
-                config.get("trustManager").defaultTo(defaultIfMissing).as(enumConstant(TrustManagerType.class));
-        switch (trustManagerType) {
-        case TRUSTALL:
-            return trustAll();
-        case JVM:
-            return null;
-        case FILE:
-            final String fileName = config.get("fileBasedTrustManagerFile").required().asString();
-            final String passwordFile = config.get("fileBasedTrustManagerPasswordFile").asString();
-            final String password = passwordFile != null
-                    ? readPasswordFromFile(passwordFile)
-                    : config.get("fileBasedTrustManagerPassword").asString();
-            final String type = config.get("fileBasedTrustManagerType").asString();
-            return checkUsingTrustStore(fileName, password != null ? password.toCharArray() : null, type);
-        default:
-            throw new IllegalArgumentException("Unsupported trust-manager type: " + trustManagerType);
-        }
-    }
-
-    private X509KeyManager configureKeyManager(JsonValue config, KeyManagerType defaultIfMissing)
-            throws GeneralSecurityException, IOException {
-        // Parse trust store configuration.
-        final KeyManagerType keyManagerType = config.get("keyManager").defaultTo(defaultIfMissing)
-                .as(enumConstant(KeyManagerType.class));
-        switch (keyManagerType) {
-        case JVM:
-            return useJvmDefaultKeyStore();
-        case KEYSTORE:
-            final String fileName = config.get("keyStoreFile").required().asString();
-            final String passwordFile = config.get("keyStorePasswordFile").asString();
-            final String password = passwordFile != null
-                    ? readPasswordFromFile(passwordFile)
-                    : config.get("keyStorePassword").asString();
-            final String format = config.get("keyStoreFormat").asString();
-            final String provider = config.get("keyStoreProvider").asString();
-            return useKeyStoreFile(fileName, password != null ? password.toCharArray() : null, format, provider);
-        case PKCS11:
-            final String pkcs11PasswordFile = config.get("pkcs11PasswordFile").asString();
-            return usePKCS11Token(pkcs11PasswordFile != null
-                    ? readPasswordFromFile(pkcs11PasswordFile).toCharArray()
-                    : null);
-        default:
-            throw new IllegalArgumentException("Unsupported key-manager type: " + keyManagerType);
-        }
+        trustManager = configureTrustManager(configuration);
+        keyManager = configureKeyManager(configuration);
     }
 
     private void configureConnectionFactories(final JsonValue config) {
@@ -505,8 +440,8 @@
         case SASL_PLAIN:
             return buildSaslBindStrategy(config);
         default:
-            throw newLocalizedIllegalArgumentException(ERR_CONFIG_UNSUPPORTED_BIND_STRATEGY.get(
-                    strategy, BindStrategy.listValues()));
+            throw new LocalizedIllegalArgumentException(
+                    ERR_CONFIG_UNSUPPORTED_BIND_STRATEGY.get(strategy, BindStrategy.listValues()));
         }
     }
 
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java
new file mode 100644
index 0000000..33d9d44
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java
@@ -0,0 +1,658 @@
+/*
+ * 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 2016 ForgeRock AS.
+ *
+ */
+package org.forgerock.opendj.rest2ldap;
+
+import static java.util.Arrays.asList;
+import static java.util.Collections.emptyList;
+import static org.forgerock.http.routing.RoutingMode.STARTS_WITH;
+import static org.forgerock.http.routing.Version.version;
+import static org.forgerock.http.util.Json.readJsonLenient;
+import static org.forgerock.json.JsonValueFunctions.enumConstant;
+import static org.forgerock.json.JsonValueFunctions.pointer;
+import static org.forgerock.json.JsonValueFunctions.setOf;
+import static org.forgerock.json.resource.RouteMatchers.requestUriMatcher;
+import static org.forgerock.opendj.ldap.Connections.LOAD_BALANCER_MONITORING_INTERVAL;
+import static org.forgerock.opendj.ldap.Connections.newCachedConnectionPool;
+import static org.forgerock.opendj.ldap.Connections.newFailoverLoadBalancer;
+import static org.forgerock.opendj.ldap.Connections.newRoundRobinLoadBalancer;
+import static org.forgerock.opendj.ldap.KeyManagers.useJvmDefaultKeyStore;
+import static org.forgerock.opendj.ldap.KeyManagers.useKeyStoreFile;
+import static org.forgerock.opendj.ldap.KeyManagers.usePKCS11Token;
+import static org.forgerock.opendj.ldap.KeyManagers.useSingleCertificate;
+import static org.forgerock.opendj.ldap.LDAPConnectionFactory.*;
+import static org.forgerock.opendj.ldap.TrustManagers.checkUsingTrustStore;
+import static org.forgerock.opendj.ldap.TrustManagers.trustAll;
+import static org.forgerock.opendj.rest2ldap.ReadOnUpdatePolicy.CONTROLS;
+import static org.forgerock.opendj.rest2ldap.Rest2Ldap.*;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
+import static org.forgerock.opendj.rest2ldap.Utils.newJsonValueException;
+import static org.forgerock.util.time.Duration.duration;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FileInputStream;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.GeneralSecurityException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509KeyManager;
+
+import org.forgerock.i18n.LocalizedIllegalArgumentException;
+import org.forgerock.i18n.slf4j.LocalizedLogger;
+import org.forgerock.json.JsonValue;
+import org.forgerock.json.resource.RequestHandler;
+import org.forgerock.json.resource.Router;
+import org.forgerock.opendj.ldap.ConnectionFactory;
+import org.forgerock.opendj.ldap.LDAPConnectionFactory;
+import org.forgerock.opendj.ldap.SSLContextBuilder;
+import org.forgerock.opendj.ldap.requests.BindRequest;
+import org.forgerock.opendj.ldap.requests.Requests;
+import org.forgerock.util.Options;
+import org.forgerock.util.time.Duration;
+
+/** Provides core factory methods and builders for constructing Rest2Ldap endpoints from JSON configuration. */
+public final class Rest2LdapJsonConfigurator {
+    private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
+
+    /**
+     * Parses Rest2Ldap configuration options. The JSON configuration must have the following format:
+     * <p>
+     * <pre>
+     * {
+     *      "readOnUpdatePolicy": "controls",
+     *      "useSubtreeDelete": true,
+     *      "usePermissiveModify": true,
+     *      "useMvcc": true
+     *      "mvccAttribute": "etag"
+     * }
+     * </pre>
+     * <p>
+     * See the sample configuration file for a detailed description of its content.
+     *
+     * @param config
+     *         The JSON configuration.
+     * @return The parsed Rest2Ldap configuration options.
+     * @throws IllegalArgumentException
+     *         If the configuration is invalid.
+     */
+    public static Options configureOptions(final JsonValue config) {
+        final Options options = Options.defaultOptions();
+
+        options.set(READ_ON_UPDATE_POLICY,
+                    config.get("readOnUpdatePolicy").defaultTo(CONTROLS).as(enumConstant(ReadOnUpdatePolicy.class)));
+
+        // Default to false, even though it is supported by OpenDJ, because it requires additional permissions.
+        options.set(USE_SUBTREE_DELETE, config.get("useSubtreeDelete").defaultTo(false).asBoolean());
+
+        // Default to true because it is supported by OpenDJ and does not require additional permissions.
+        options.set(USE_PERMISSIVE_MODIFY, config.get("usePermissiveModify").defaultTo(false).asBoolean());
+
+        options.set(USE_MVCC, config.get("useMvcc").defaultTo(true).asBoolean());
+        options.set(MVCC_ATTRIBUTE, config.get("mvccAttribute").defaultTo("etag").asString());
+
+        return options;
+    }
+
+    /**
+     * Parses a list of Rest2Ldap resource definitions. The JSON configuration must have the following format:
+     * <p>
+     * <pre>
+     * "top": {
+     *     "isAbstract": true,
+     *     "properties": {
+     *         "_rev": {
+     *             "type": "simple"
+     *             "ldapAttribute": "etag",
+     *             "writability": "readOnly"
+     *         },
+     *         ...
+     *     },
+     *     ...
+     * },
+     * ...
+     * </pre>
+     * <p>
+     * See the sample configuration file for a detailed description of its content.
+     *
+     * @param config
+     *         The JSON configuration.
+     * @return The parsed list of Rest2Ldap resource definitions.
+     * @throws IllegalArgumentException
+     *         If the configuration is invalid.
+     */
+    public static List<Resource> configureResources(final JsonValue config) {
+        final JsonValue resourcesConfig = config.required().expect(Map.class);
+        final List<Resource> resources = new LinkedList<>();
+        for (final String resourceId : resourcesConfig.keys()) {
+            resources.add(configureResource(resourceId, resourcesConfig.get(resourceId)));
+        }
+        return resources;
+    }
+
+    /**
+     * Creates a new CREST {@link Router} using the provided endpoints configuration directory and Rest2Ldap options.
+     * The Rest2Ldap configuration typically has the following structure on disk:
+     * <ul>
+     * <li> config.json - contains the configuration for the LDAP connection factories and authorization
+     * <li> rest2ldap/rest2ldap.json - defines Rest2Ldap configuration options
+     * <li> rest2ldap/endpoints/{api} - a directory containing the endpoint's resource definitions for endpoint {api}
+     * <li> rest2ldap/endpoints/{api}/{resource-id}.json - the resource definitions for a specific version of API {api}.
+     * The name of the file, {resource-id}, determines which resource type definition in the mapping file will be
+     * used as the root resource.
+     * </ul>
+     *
+     * @param endpointsDirectory The directory representing the Rest2Ldap "endpoints" directory.
+     * @param options The Rest2Ldap configuration options.
+     * @return A new CREST {@link Router} configured using the provided options and endpoints.
+     * @throws IOException If the endpoints configuration cannot be read.
+     * @throws IllegalArgumentException
+     *         If the configuration is invalid.
+     */
+    public static Router configureEndpoints(final File endpointsDirectory, final Options options) throws IOException {
+        final Router pathRouter = new Router();
+
+        final File[] endpoints = endpointsDirectory.listFiles(new FileFilter() {
+            @Override
+            public boolean accept(final File pathname) {
+                return pathname.isDirectory() && pathname.canRead();
+            }
+        });
+
+        if (endpoints == null) {
+            throw new LocalizedIllegalArgumentException(ERR_INVALID_ENDPOINTS_DIRECTORY.get(endpointsDirectory));
+        }
+
+        for (final File endpoint : endpoints) {
+            final Router router = configureEndpoint(endpoint, options);
+            pathRouter.addRoute(requestUriMatcher(STARTS_WITH, endpoint.getName()), router);
+        }
+        return pathRouter;
+    }
+
+    /**
+     * Creates a new CREST {@link Router} representing a single endpoint whose configuration is defined in the
+     * provided {@code endpointDirectory} parameter. The directory should contain a separate file for each supported
+     * version of the REST endpoint. The name of the file, excluding the suffix, identifies the resource definition
+     * which acts as the entry point into the endpoint.
+     *
+     * @param endpointDirectory The directory containing the endpoint's resource definitions, e.g.
+     *                          rest2ldap/routes/api would contain definitions for the "api" endpoint.
+     * @param options The Rest2Ldap configuration options.
+     * @return A new CREST {@link Router} configured using the provided options and endpoint mappings.
+     * @throws IOException If the endpoint configuration cannot be read.
+     * @throws IllegalArgumentException
+     *         If the configuration is invalid.
+     */
+    public static Router configureEndpoint(final File endpointDirectory, final Options options) throws IOException {
+        final Router versionRouter = new Router();
+
+        final File[] endpointVersions = endpointDirectory.listFiles(new FileFilter() {
+            @Override
+            public boolean accept(final File pathname) {
+                return pathname.isFile() && pathname.canRead() && pathname.getName().endsWith(".json");
+            }
+        });
+
+        if (endpointVersions == null) {
+            throw new LocalizedIllegalArgumentException(ERR_INVALID_ENDPOINT_DIRECTORY.get(endpointDirectory));
+        }
+
+        for (final File endpointVersion : endpointVersions) {
+            final JsonValue mappingConfig = readJson(endpointVersion);
+            final String version = mappingConfig.get("version").defaultTo("*").asString();
+            final List<Resource> resourceTypes = configureResources(mappingConfig.get("resourceTypes"));
+            final Rest2Ldap rest2Ldap = rest2Ldap(options, resourceTypes);
+
+            final String endpointVersionFileName = endpointVersion.getName();
+            final int endIndex = endpointVersionFileName.lastIndexOf('.');
+            final String rootResourceType = endpointVersionFileName.substring(0, endIndex);
+            final RequestHandler handler = rest2Ldap.newRequestHandlerFor(rootResourceType);
+
+            if (version.equals("*")) {
+                versionRouter.setDefaultRoute(handler);
+            } else {
+                versionRouter.addRoute(version(version), handler);
+            }
+
+            logger.debug(INFO_REST2LDAP_CREATING_ENDPOINT.get(endpointDirectory.getName(), version));
+        }
+
+        return versionRouter;
+    }
+
+    static JsonValue readJson(final File resource) throws IOException {
+        try (InputStream in = new FileInputStream(resource)) {
+            return new JsonValue(readJsonLenient(in));
+        }
+    }
+
+    private static Resource configureResource(final String resourceId, final JsonValue config) {
+        final Resource resource = resource(resourceId)
+                .isAbstract(config.get("isAbstract").defaultTo(false).asBoolean())
+                .superType(config.get("superType").asString())
+                .objectClasses(config.get("objectClasses")
+                                     .defaultTo(emptyList()).asList(String.class).toArray(new String[0]))
+                .supportedActions(config.get("supportedActions")
+                                        .defaultTo(emptyList())
+                                        .as(setOf(enumConstant(Action.class))).toArray(new Action[0]))
+                .resourceTypeProperty(config.get("resourceTypeProperty").as(pointer()))
+                .includeAllUserAttributesByDefault(config.get("includeAllUserAttributesByDefault")
+                                                         .defaultTo(false).asBoolean())
+                .excludedDefaultUserAttributes(config.get("excludedDefaultUserAttributes")
+                                                     .defaultTo(Collections.emptyList()).asList(String.class));
+
+        final JsonValue properties = config.get("properties").expect(Map.class);
+        for (final String property : properties.keys()) {
+            resource.property(property, configurePropertyMapper(properties.get(property), property));
+        }
+
+        final JsonValue subResources = config.get("subResources").expect(Map.class);
+        for (final String urlTemplate : subResources.keys()) {
+            resource.subResource(configureSubResource(urlTemplate, subResources.get(urlTemplate)));
+        }
+
+        return resource;
+    }
+
+    private enum NamingStrategyType { CLIENTDNNAMING, CLIENTNAMING, SERVERNAMING }
+    private enum SubResourceType { COLLECTION, SINGLETON }
+
+    private static SubResource configureSubResource(final String urlTemplate, final JsonValue config) {
+        final String dnTemplate = config.get("dnTemplate").defaultTo("").asString();
+        final Boolean isReadOnly = config.get("isReadOnly").defaultTo(false).asBoolean();
+        final String resourceId = config.get("resource").required().asString();
+
+        if (config.get("type").required().as(enumConstant(SubResourceType.class)) == SubResourceType.COLLECTION) {
+            final String[] glueObjectClasses = config.get("glueObjectClasses")
+                                                     .defaultTo(emptyList())
+                                                     .asList(String.class)
+                                                     .toArray(new String[0]);
+
+            final SubResourceCollection collection = collectionOf(resourceId).urlTemplate(urlTemplate)
+                                                                             .dnTemplate(dnTemplate)
+                                                                             .isReadOnly(isReadOnly)
+                                                                             .glueObjectClasses(glueObjectClasses);
+
+            final JsonValue namingStrategy = config.get("namingStrategy").required();
+            switch (namingStrategy.get("type").required().as(enumConstant(NamingStrategyType.class))) {
+            case CLIENTDNNAMING:
+                collection.useClientDnNaming(namingStrategy.get("dnAttribute").required().asString());
+                break;
+            case CLIENTNAMING:
+                collection.useClientNaming(namingStrategy.get("dnAttribute").required().asString(),
+                                           namingStrategy.get("idAttribute").required().asString());
+                break;
+            case SERVERNAMING:
+                collection.useServerNaming(namingStrategy.get("dnAttribute").required().asString(),
+                                           namingStrategy.get("idAttribute").required().asString());
+                break;
+            }
+
+            return collection;
+        } else {
+            return singletonOf(resourceId).urlTemplate(urlTemplate).dnTemplate(dnTemplate).isReadOnly(isReadOnly);
+        }
+    }
+
+    private static PropertyMapper configurePropertyMapper(final JsonValue mapper, final String defaultLdapAttribute) {
+        switch (mapper.get("type").required().asString()) {
+        case "resourceType":
+            return resourceType();
+        case "constant":
+            return constant(mapper.get("value").getObject());
+        case "simple":
+            return simple(mapper.get("ldapAttribute").defaultTo(defaultLdapAttribute).required().asString())
+                    .defaultJsonValue(mapper.get("defaultJsonValue").getObject())
+                    .isBinary(mapper.get("isBinary").defaultTo(false).asBoolean())
+                    .isRequired(mapper.get("isRequired").defaultTo(false).asBoolean())
+                    .isMultiValued(mapper.get("isMultiValued").defaultTo(false).asBoolean())
+                    .writability(parseWritability(mapper));
+        case "reference":
+            final String ldapAttribute = mapper.get("ldapAttribute")
+                                               .defaultTo(defaultLdapAttribute).required().asString();
+            final String baseDN = mapper.get("baseDn").required().asString();
+            final String primaryKey = mapper.get("primaryKey").required().asString();
+            final PropertyMapper m = configurePropertyMapper(mapper.get("mapper").required(), primaryKey);
+            return reference(ldapAttribute, baseDN, primaryKey, m)
+                    .isRequired(mapper.get("isRequired").defaultTo(false).asBoolean())
+                    .isMultiValued(mapper.get("isMultiValued").defaultTo(false).asBoolean())
+                    .searchFilter(mapper.get("searchFilter").defaultTo("(objectClass=*)").asString())
+                    .writability(parseWritability(mapper));
+        case "object":
+            final JsonValue properties = mapper.get("properties");
+            final ObjectPropertyMapper object = object();
+            for (final String attribute : properties.keys()) {
+                object.property(attribute, configurePropertyMapper(properties.get(attribute), attribute));
+            }
+            return object;
+        default:
+            throw newJsonValueException(mapper, ERR_CONFIG_NO_MAPPING_IN_CONFIGURATION.get(
+                    "constant, simple, reference, object"));
+        }
+    }
+
+    private static WritabilityPolicy parseWritability(final JsonValue mapper) {
+        if (mapper.isDefined("writability")) {
+            final String writability = mapper.get("writability").asString();
+            if (writability.equalsIgnoreCase("readOnly")) {
+                return WritabilityPolicy.READ_ONLY;
+            } else if (writability.equalsIgnoreCase("readOnlyDiscardWrites")) {
+                return WritabilityPolicy.READ_ONLY_DISCARD_WRITES;
+            } else if (writability.equalsIgnoreCase("createOnly")) {
+                return WritabilityPolicy.CREATE_ONLY;
+            } else if (writability.equalsIgnoreCase("createOnlyDiscardWrites")) {
+                return WritabilityPolicy.CREATE_ONLY_DISCARD_WRITES;
+            } else if (writability.equalsIgnoreCase("readWrite")) {
+                return WritabilityPolicy.READ_WRITE;
+            } else {
+                throw newJsonValueException(mapper, ERR_CONFIG_UNKNOWN_WRITABILITY.get(writability,
+                            "readOnly, readOnlyDiscardWrites, createOnly, createOnlyDiscardWrites, readWrite"));
+            }
+        } else {
+            return WritabilityPolicy.READ_WRITE;
+        }
+    }
+
+    /** Indicates whether LDAP client connections should use SSL or StartTLS. */
+    private enum ConnectionSecurity { NONE, SSL, STARTTLS }
+
+    /** Specifies the mechanism which will be used for trusting certificates presented by the LDAP server. */
+    private enum TrustManagerType { TRUSTALL, JVM, FILE }
+
+    /** Specifies the type of key-store to use when performing SSL client authentication. */
+    private enum KeyManagerType { JVM, KEYSTORE, PKCS11 }
+
+    /**
+     * Configures a {@link X509KeyManager} using the provided JSON configuration.
+     *
+     * @param configuration
+     *         The JSON object containing the key manager configuration.
+     * @return The configured key manager.
+     */
+    public static X509KeyManager configureKeyManager(final JsonValue configuration) {
+        try {
+            return configureKeyManager(configuration, KeyManagerType.JVM);
+        } catch (GeneralSecurityException | IOException e) {
+            throw new IllegalArgumentException(ERR_CONFIG_INVALID_KEY_MANAGER.get(
+                    configuration.getPointer(), e.getLocalizedMessage()).toString(), e);
+        }
+    }
+
+    private static X509KeyManager configureKeyManager(JsonValue config, KeyManagerType defaultIfMissing)
+            throws GeneralSecurityException, IOException {
+        final KeyManagerType keyManagerType = config.get("keyManager")
+                                                    .defaultTo(defaultIfMissing)
+                                                    .as(enumConstant(KeyManagerType.class));
+        switch (keyManagerType) {
+        case JVM:
+            return useJvmDefaultKeyStore();
+        case KEYSTORE:
+            final String fileName = config.get("keyStoreFile").required().asString();
+            final String passwordFile = config.get("keyStorePasswordFile").asString();
+            final String password = passwordFile != null
+                    ? readPasswordFromFile(passwordFile)
+                    : config.get("keyStorePassword").asString();
+            final String format = config.get("keyStoreFormat").asString();
+            final String provider = config.get("keyStoreProvider").asString();
+            return useKeyStoreFile(fileName, password != null ? password.toCharArray() : null, format, provider);
+        case PKCS11:
+            final String pkcs11PasswordFile = config.get("pkcs11PasswordFile").asString();
+            return usePKCS11Token(pkcs11PasswordFile != null
+                                          ? readPasswordFromFile(pkcs11PasswordFile).toCharArray()
+                                          : null);
+        default:
+            throw new IllegalArgumentException("Unsupported key-manager type: " + keyManagerType);
+        }
+    }
+
+    private static String readPasswordFromFile(String fileName) throws IOException {
+        try (final BufferedReader reader = new BufferedReader(new FileReader(new File(fileName)))) {
+            return reader.readLine();
+        }
+    }
+
+    /**
+     * Configures a {@link TrustManager} using the provided JSON configuration.
+     *
+     * @param configuration
+     *         The JSON object containing the trust manager configuration.
+     * @return The configured trust manager.
+     */
+    public static TrustManager configureTrustManager(final JsonValue configuration) {
+        try {
+            return configureTrustManager(configuration, TrustManagerType.JVM);
+        } catch (GeneralSecurityException | IOException e) {
+            throw new IllegalArgumentException(ERR_CONFIG_INVALID_TRUST_MANAGER.get(
+                    configuration.getPointer(), e.getLocalizedMessage()).toString(), e);
+        }
+    }
+
+    private static TrustManager configureTrustManager(JsonValue config, TrustManagerType defaultIfMissing)
+            throws GeneralSecurityException, IOException {
+        final TrustManagerType trustManagerType = config.get("trustManager")
+                                                        .defaultTo(defaultIfMissing)
+                                                        .as(enumConstant(TrustManagerType.class));
+        switch (trustManagerType) {
+        case TRUSTALL:
+            return trustAll();
+        case JVM:
+            return null;
+        case FILE:
+            final String fileName = config.get("fileBasedTrustManagerFile").required().asString();
+            final String passwordFile = config.get("fileBasedTrustManagerPasswordFile").asString();
+            final String password = passwordFile != null
+                    ? readPasswordFromFile(passwordFile) : config.get("fileBasedTrustManagerPassword").asString();
+            final String type = config.get("fileBasedTrustManagerType").asString();
+            return checkUsingTrustStore(fileName, password != null ? password.toCharArray() : null, type);
+        default:
+            throw new IllegalArgumentException("Unsupported trust-manager type: " + trustManagerType);
+        }
+    }
+
+    /**
+     * Creates a new connection factory using the named configuration in the provided JSON list of factory
+     * configurations. See the sample configuration file for a detailed description of its content.
+     *
+     * @param configuration
+     *         The JSON configuration.
+     * @param name
+     *         The name of the connection factory configuration to be parsed.
+     * @param trustManager
+     *            The trust manager to use for secure connection. Can be {@code null}
+     *            to use the default JVM trust manager.
+     * @param keyManager
+     *            The key manager to use for secure connection. Can be {@code null}
+     *            to use the default JVM key manager.
+     * @param providerClassLoader
+     *         The {@link ClassLoader} used to fetch the {@link org.forgerock.opendj.ldap.spi.TransportProvider}. This
+     *         can be useful in OSGI environments.
+     * @return A new connection factory using the provided JSON configuration.
+     * @throws IllegalArgumentException
+     *         If the configuration is invalid.
+     */
+    public static ConnectionFactory configureConnectionFactory(final JsonValue configuration,
+                                                               final String name,
+                                                               final TrustManager trustManager,
+                                                               final X509KeyManager keyManager,
+                                                               final ClassLoader providerClassLoader) {
+        final JsonValue normalizedConfiguration = normalizeConnectionFactory(configuration, name, 0);
+        return configureConnectionFactory(normalizedConfiguration, trustManager, keyManager, providerClassLoader);
+    }
+
+    /**
+     * Creates a new connection factory using the named configuration in the provided JSON list of factory
+     * configurations. See the sample configuration file for a detailed description of its content.
+     *
+     * @param configuration
+     *         The JSON configuration.
+     * @param name
+     *         The name of the connection factory configuration to be parsed.
+     * @param trustManager
+     *            The trust manager to use for secure connection. Can be {@code null}
+     *            to use the default JVM trust manager.
+     * @param keyManager
+     *            The key manager to use for secure connection. Can be {@code null}
+     *            to use the default JVM key manager.
+     * @return A new connection factory using the provided JSON configuration.
+     * @throws IllegalArgumentException
+     *         If the configuration is invalid.
+     */
+    public static ConnectionFactory configureConnectionFactory(final JsonValue configuration,
+                                                               final String name,
+                                                               final TrustManager trustManager,
+                                                               final X509KeyManager keyManager) {
+        return configureConnectionFactory(configuration, name, trustManager, keyManager, null);
+    }
+
+    private static ConnectionFactory configureConnectionFactory(final JsonValue configuration,
+                                                                final TrustManager trustManager,
+                                                                final X509KeyManager keyManager,
+                                                                final ClassLoader providerClassLoader) {
+        final long heartBeatIntervalSeconds = configuration.get("heartBeatIntervalSeconds").defaultTo(30L).asLong();
+        final Duration heartBeatInterval = duration(Math.max(heartBeatIntervalSeconds, 1L), TimeUnit.SECONDS);
+
+        final long heartBeatTimeoutMillis = configuration.get("heartBeatTimeoutMilliSeconds").defaultTo(500L).asLong();
+        final Duration heartBeatTimeout = duration(Math.max(heartBeatTimeoutMillis, 100L), TimeUnit.MILLISECONDS);
+
+        final Options options = Options.defaultOptions()
+                                       .set(TRANSPORT_PROVIDER_CLASS_LOADER, providerClassLoader)
+                                       .set(HEARTBEAT_ENABLED, true)
+                                       .set(HEARTBEAT_INTERVAL, heartBeatInterval)
+                                       .set(HEARTBEAT_TIMEOUT, heartBeatTimeout)
+                                       .set(LOAD_BALANCER_MONITORING_INTERVAL, heartBeatInterval);
+
+        // Parse pool parameters,
+        final int connectionPoolSize =
+                Math.max(configuration.get("connectionPoolSize").defaultTo(10).asInteger(), 1);
+
+        // Parse authentication parameters.
+        if (configuration.isDefined("authentication")) {
+            final JsonValue authn = configuration.get("authentication");
+            if (authn.isDefined("simple")) {
+                final JsonValue simple = authn.get("simple");
+                final BindRequest bindRequest =
+                        Requests.newSimpleBindRequest(simple.get("bindDN").required().asString(),
+                                                      simple.get("bindPassword").required().asString().toCharArray());
+                options.set(AUTHN_BIND_REQUEST, bindRequest);
+            } else {
+                throw new LocalizedIllegalArgumentException(ERR_CONFIG_INVALID_AUTHENTICATION.get());
+            }
+        }
+
+        // Parse SSL/StartTLS parameters.
+        final ConnectionSecurity connectionSecurity = configuration.get("connectionSecurity")
+                                                                   .defaultTo(ConnectionSecurity.NONE)
+                                                                   .as(enumConstant(ConnectionSecurity.class));
+        if (connectionSecurity != ConnectionSecurity.NONE) {
+            try {
+                // Configure SSL.
+                final SSLContextBuilder builder = new SSLContextBuilder();
+                builder.setTrustManager(trustManager);
+                final String sslCertAlias = configuration.get("sslCertAlias").asString();
+                builder.setKeyManager(sslCertAlias != null
+                                              ? useSingleCertificate(sslCertAlias, keyManager)
+                                              : keyManager);
+                options.set(SSL_CONTEXT, builder.getSSLContext());
+                options.set(SSL_USE_STARTTLS, connectionSecurity == ConnectionSecurity.STARTTLS);
+            } catch (GeneralSecurityException e) {
+                // Rethrow as unchecked exception.
+                throw new IllegalArgumentException(e);
+            }
+        }
+
+        // Parse primary data center.
+        final JsonValue primaryLdapServers = configuration.get("primaryLDAPServers");
+        if (!primaryLdapServers.isList() || primaryLdapServers.size() == 0) {
+            throw new IllegalArgumentException("No primaryLDAPServers");
+        }
+        final ConnectionFactory primary = parseLdapServers(primaryLdapServers, connectionPoolSize, options);
+
+        // Parse secondary data center(s).
+        final JsonValue secondaryLdapServers = configuration.get("secondaryLDAPServers");
+        ConnectionFactory secondary = null;
+        if (secondaryLdapServers.isList()) {
+            if (secondaryLdapServers.size() > 0) {
+                secondary = parseLdapServers(secondaryLdapServers, connectionPoolSize, options);
+            }
+        } else if (!secondaryLdapServers.isNull()) {
+            throw new LocalizedIllegalArgumentException(ERR_CONFIG_INVALID_SECONDARY_LDAP_SERVER.get());
+        }
+
+        // Create fail-over.
+        if (secondary != null) {
+            return newFailoverLoadBalancer(asList(primary, secondary), options);
+        } else {
+            return primary;
+        }
+    }
+
+    private static JsonValue normalizeConnectionFactory(final JsonValue configuration,
+                                                        final String name, final int depth) {
+        // Protect against infinite recursion in the configuration.
+        if (depth > 100) {
+            throw new LocalizedIllegalArgumentException(ERR_CONFIG_SERVER_CIRCULAR_DEPENDENCIES.get(name));
+        }
+
+        final JsonValue current = configuration.get(name).required();
+        if (current.isDefined("inheritFrom")) {
+            // Inherit missing fields from inherited configuration.
+            final JsonValue parent =
+                    normalizeConnectionFactory(configuration,
+                                               current.get("inheritFrom").asString(), depth + 1);
+            final Map<String, Object> normalized = new LinkedHashMap<>(parent.asMap());
+            normalized.putAll(current.asMap());
+            normalized.remove("inheritFrom");
+            return new JsonValue(normalized);
+        } else {
+            // No normalization required.
+            return current;
+        }
+    }
+
+    private static ConnectionFactory parseLdapServers(JsonValue config, int poolSize, Options options) {
+        final List<ConnectionFactory> servers = new ArrayList<>(config.size());
+        for (final JsonValue server : config) {
+            final String host = server.get("hostname").required().asString();
+            final int port = server.get("port").required().asInteger();
+            final ConnectionFactory factory = new LDAPConnectionFactory(host, port, options);
+            if (poolSize > 1) {
+                servers.add(newCachedConnectionPool(factory, 0, poolSize, 60L, TimeUnit.SECONDS));
+            } else {
+                servers.add(factory);
+            }
+        }
+        if (servers.size() > 1) {
+            return newRoundRobinLoadBalancer(servers, options);
+        } else {
+            return servers.get(0);
+        }
+    }
+
+    private Rest2LdapJsonConfigurator() {
+        // Prevent instantiation.
+    }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/RoutingContext.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/RoutingContext.java
new file mode 100644
index 0000000..fb2a3ae
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/RoutingContext.java
@@ -0,0 +1,43 @@
+/*
+ * 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 2016 ForgeRock AS.
+ *
+ */
+package org.forgerock.opendj.rest2ldap;
+
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.services.context.AbstractContext;
+import org.forgerock.services.context.Context;
+
+/**
+ * A {@link Context} which communicates the current Rest2Ldap routing state to downstream handlers.
+ */
+final class RoutingContext extends AbstractContext {
+    private final DN dn;
+    private final Resource resource;
+
+    RoutingContext(final Context parent, final DN dn, final Resource resource) {
+        super(parent, "routing context");
+        this.dn = dn;
+        this.resource = resource;
+    }
+
+    DN getDn() {
+        return dn;
+    }
+
+    Resource getType() {
+        return resource;
+    }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SimplePropertyMapper.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SimplePropertyMapper.java
index 403056d..3abe8cd 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SimplePropertyMapper.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SimplePropertyMapper.java
@@ -18,6 +18,7 @@
 import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
 
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 import java.util.Set;
 
@@ -37,15 +38,11 @@
 import static java.util.Collections.*;
 
 import static org.forgerock.opendj.ldap.Filter.*;
-import static org.forgerock.opendj.rest2ldap.Rest2Ldap.*;
+import static org.forgerock.opendj.rest2ldap.Rest2Ldap.asResourceException;
 import static org.forgerock.opendj.rest2ldap.Utils.*;
-import static org.forgerock.util.promise.Promises.newExceptionPromise;
 import static org.forgerock.util.promise.Promises.newResultPromise;
 
-/**
- * An property mapper which provides a simple mapping from a JSON value to a
- * single LDAP attribute.
- */
+/** An property mapper which provides a simple mapping from a JSON value to a single LDAP attribute. */
 public final class SimplePropertyMapper extends AbstractLdapPropertyMapper<SimplePropertyMapper> {
     private Function<ByteString, ?, NeverThrowsException> decoder;
     private Function<Object, ByteString, NeverThrowsException> encoder;
@@ -68,8 +65,7 @@
     }
 
     /**
-     * Sets the default JSON value which should be substituted when the LDAP
-     * attribute is not found in the LDAP entry.
+     * Sets the default JSON value which should be substituted when the LDAP attribute is not found in the LDAP entry.
      *
      * @param defaultValue
      *            The default JSON value.
@@ -81,6 +77,18 @@
     }
 
     /**
+     * Sets the default JSON values which should be substituted when the LDAP attribute is not found in the LDAP entry.
+     *
+     * @param defaultValues
+     *            The default JSON values.
+     * @return This property mapper.
+     */
+    public SimplePropertyMapper defaultJsonValues(final Collection<?> defaultValues) {
+        this.defaultJsonValues = defaultValues != null ? new ArrayList<>(defaultValues) : emptyList();
+        return this;
+    }
+
+    /**
      * Sets the encoder which will be used for converting JSON values to LDAP
      * attribute values.
      *
@@ -95,18 +103,27 @@
 
     /**
      * Indicates that JSON values are base 64 encodings of binary data. Calling
-     * this method is equivalent to the following:
+     * this method with the value {@code true} is equivalent to the following:
      *
      * <pre>
      * mapper.decoder(...); // function that converts binary data to base 64
      * mapper.encoder(...); // function that converts base 64 to binary data
      * </pre>
      *
+     * Passing in a value of {@code false} resets the encoding and decoding
+     * functions to the default.
+     *
+     * @param isBinary {@code true} if this property is binary.
      * @return This property mapper.
      */
-    public SimplePropertyMapper isBinary() {
-        decoder = byteStringToBase64();
-        encoder = base64ToByteString();
+    public SimplePropertyMapper isBinary(final boolean isBinary) {
+        if (isBinary) {
+            decoder = byteStringToBase64();
+            encoder = base64ToByteString();
+        } else {
+            decoder = null;
+            encoder = null;
+        }
         return this;
     }
 
@@ -116,18 +133,18 @@
     }
 
     @Override
-    Promise<Filter, ResourceException> getLdapFilter(final Connection connection, final JsonPointer path,
-                                                     final JsonPointer subPath, final FilterType type,
-                                                     final String operator, final Object valueAssertion) {
+    Promise<Filter, ResourceException> getLdapFilter(final Connection connection, final Resource resource,
+                                                     final JsonPointer path, final JsonPointer subPath,
+                                                     final FilterType type, final String operator,
+                                                     final Object valueAssertion) {
         if (subPath.isEmpty()) {
             try {
-                final ByteString va =
-                        valueAssertion != null ? encoder().apply(valueAssertion) : null;
+                final ByteString va = valueAssertion != null ? encoder().apply(valueAssertion) : null;
                 return newResultPromise(toFilter(type, ldapAttributeName.toString(), va));
             } catch (final Exception e) {
                 // Invalid assertion value - bad request.
-                return newExceptionPromise((ResourceException) newBadRequestException(
-                        ERR_ILLEGAL_FILTER_ASSERTION_VALUE.get(String.valueOf(valueAssertion), path), e));
+                return newBadRequestException(
+                        ERR_ILLEGAL_FILTER_ASSERTION_VALUE.get(String.valueOf(valueAssertion), path), e).asPromise();
             }
         } else {
             // This property mapper does not support partial filtering.
@@ -136,13 +153,12 @@
     }
 
     @Override
-    Promise<Attribute, ResourceException> getNewLdapAttributes(
-            final Connection connection, final JsonPointer path, final List<Object> newValues) {
+    Promise<Attribute, ResourceException> getNewLdapAttributes(final Connection connection, final Resource resource,
+                                                               final JsonPointer path, final List<Object> newValues) {
         try {
             return newResultPromise(jsonToAttribute(newValues, ldapAttributeName, encoder()));
         } catch (final Exception ex) {
-            return newExceptionPromise((ResourceException) newBadRequestException(
-                    ERR_ENCODING_VALUES_FOR_FIELD.get(path, ex.getMessage())));
+            return newBadRequestException(ERR_ENCODING_VALUES_FOR_FIELD.get(path, ex.getMessage())).asPromise();
         }
     }
 
@@ -151,21 +167,26 @@
         return this;
     }
 
+    @SuppressWarnings("fallthrough")
     @Override
-    Promise<JsonValue, ResourceException> read(final Connection connection, final JsonPointer path, final Entry e) {
+    Promise<JsonValue, ResourceException> read(final Connection connection, final Resource resource,
+                                               final JsonPointer path, final Entry e) {
         try {
-            final Object value;
-            if (attributeIsSingleValued()) {
-                value = e.parseAttribute(ldapAttributeName)
-                         .as(decoder(), defaultJsonValues.isEmpty() ? null : defaultJsonValues.get(0));
-            } else {
-                final Set<Object> s = e.parseAttribute(ldapAttributeName).asSetOf(decoder(), defaultJsonValues);
-                value = s.isEmpty() ? null : new ArrayList<>(s);
+            final Set<Object> s = e.parseAttribute(ldapAttributeName).asSetOf(decoder(), defaultJsonValues);
+            switch (s.size()) {
+            case 0:
+                return newResultPromise(null);
+            case 1:
+                if (attributeIsSingleValued()) {
+                    return newResultPromise(new JsonValue(s.iterator().next()));
+                }
+                // Fall-though: unexpectedly got multiple values. It's probably best to just return them.
+            default:
+                return newResultPromise(new JsonValue(new ArrayList<>(s)));
             }
-            return newResultPromise(value != null ? new JsonValue(value) : null);
         } catch (final Exception ex) {
             // The LDAP attribute could not be decoded.
-            return newExceptionPromise(asResourceException(ex));
+            return asResourceException(ex).asPromise();
         }
     }
 
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResource.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResource.java
new file mode 100644
index 0000000..8d8acbc
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResource.java
@@ -0,0 +1,151 @@
+/*
+ * 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 2016 ForgeRock AS.
+ *
+ */
+package org.forgerock.opendj.rest2ldap;
+
+import static org.forgerock.opendj.rest2ldap.Rest2Ldap.DECODE_OPTIONS;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_UNRECOGNIZED_SUB_RESOURCE_TYPE;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.forgerock.http.routing.UriRouterContext;
+import org.forgerock.i18n.LocalizableMessage;
+import org.forgerock.i18n.LocalizedIllegalArgumentException;
+import org.forgerock.json.resource.BadRequestException;
+import org.forgerock.json.resource.NotFoundException;
+import org.forgerock.json.resource.RequestHandler;
+import org.forgerock.json.resource.ResourceException;
+import org.forgerock.json.resource.Router;
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.schema.Schema;
+import org.forgerock.services.context.Context;
+import org.forgerock.util.Function;
+
+/**
+ * Defines a parent-child relationship between a parent resource and one or more child resource(s). Removal of the
+ * parent resource implies that the children (the sub-resources) are also removed. There are two types of
+ * sub-resource:
+ * <ul>
+ * <li>{@link SubResourceSingleton} represents a one-to-one relationship supporting read, update, patch, and action
+ *     requests</li>
+ * <li>{@link SubResourceCollection} represents a one-to-many relationship supporting all requests.</li>
+ * </ul>
+ */
+public abstract class SubResource {
+    private static final Pattern TEMPLATE_KEY_RE = Pattern.compile("\\{([^}]+)\\}");
+
+    private final String resourceId;
+    private final List<String> dnTemplateVariables = new ArrayList<>();
+    private String dnTemplateFormatString;
+
+    String urlTemplate = "";
+    String dnTemplate = "";
+    boolean isReadOnly = false;
+    Rest2Ldap rest2Ldap;
+    Resource resource;
+
+    SubResource(final String resourceId) {
+        this.resourceId = resourceId;
+    }
+
+    @Override
+    public final boolean equals(final Object o) {
+        return this == o || (o instanceof SubResource && urlTemplate.equals(((SubResource) o).urlTemplate));
+    }
+
+    @Override
+    public final int hashCode() {
+        return urlTemplate.hashCode();
+    }
+
+    @Override
+    public final String toString() {
+        return urlTemplate;
+    }
+
+    final Resource getResource() {
+        return resource;
+    }
+
+    final void build(final Rest2Ldap rest2Ldap, final String parent) {
+        this.rest2Ldap = rest2Ldap;
+        this.resource = rest2Ldap.getResource(resourceId);
+        if (resource == null) {
+            throw new LocalizedIllegalArgumentException(ERR_UNRECOGNIZED_SUB_RESOURCE_TYPE.get(parent, resourceId));
+        }
+        this.dnTemplateFormatString = formatTemplate(dnTemplate, dnTemplateVariables);
+    }
+
+    // Parse the template keys and replace them with %s for formatting.
+    private String formatTemplate(final String template, final List<String> templateVariables) {
+        final Matcher matcher = TEMPLATE_KEY_RE.matcher(template);
+        final StringBuffer buffer = new StringBuffer(template.length());
+        while (matcher.find()) {
+            matcher.appendReplacement(buffer, "%s");
+            templateVariables.add(matcher.group(1));
+        }
+        matcher.appendTail(buffer);
+        return buffer.toString();
+    }
+
+    abstract Router addRoutes(Router router);
+
+    /** A 404 indicates that this instance is not also a collection, so return a more helpful message. */
+    static <T> Function<ResourceException, T, ResourceException> convert404To400(final LocalizableMessage msg) {
+        return new Function<ResourceException, T, ResourceException>() {
+            @Override
+            public T apply(final ResourceException e) throws ResourceException {
+                if (e instanceof NotFoundException) {
+                    throw new BadRequestException(msg.toString());
+                }
+                throw e;
+            }
+        };
+    }
+
+    final RequestHandler readOnly(final RequestHandler handler) {
+        return isReadOnly ? new ReadOnlyRequestHandler(handler) : handler;
+    }
+
+    final DN dnFrom(final Context context) {
+        final DN baseDn = context.containsContext(RoutingContext.class)
+                ? context.asContext(RoutingContext.class).getDn() : DN.rootDN();
+
+        final Schema schema = rest2Ldap.getOptions().get(DECODE_OPTIONS).getSchemaResolver().resolveSchema(dnTemplate);
+        if (dnTemplateVariables.isEmpty()) {
+            final DN relativeDn = DN.valueOf(dnTemplate, schema);
+            return baseDn.child(relativeDn);
+        } else {
+            final UriRouterContext uriRouterContext = context.asContext(UriRouterContext.class);
+            final Map<String, String> uriTemplateVariables = uriRouterContext.getUriTemplateVariables();
+            final String[] values = new String[dnTemplateVariables.size()];
+            for (int i = 0; i < values.length; i++) {
+                final String key = dnTemplateVariables.get(i);
+                values[i] = uriTemplateVariables.get(key);
+            }
+            final DN relativeDn = DN.format(dnTemplateFormatString, schema, (Object[]) values);
+            return baseDn.child(relativeDn);
+        }
+    }
+
+    final RequestHandler subResourceRouterFrom(final RoutingContext context) {
+        return context.getType().getSubResourceRouter();
+    }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceCollection.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceCollection.java
new file mode 100644
index 0000000..b420b0d
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceCollection.java
@@ -0,0 +1,530 @@
+/*
+ * 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 2016 ForgeRock AS.
+ *
+ */
+package org.forgerock.opendj.rest2ldap;
+
+import static org.forgerock.http.routing.RoutingMode.EQUALS;
+import static org.forgerock.http.routing.RoutingMode.STARTS_WITH;
+import static org.forgerock.json.resource.RouteMatchers.requestUriMatcher;
+import static org.forgerock.opendj.ldap.Filter.objectClassPresent;
+import static org.forgerock.opendj.ldap.SearchScope.BASE_OBJECT;
+import static org.forgerock.opendj.ldap.SearchScope.SINGLE_LEVEL;
+import static org.forgerock.opendj.ldap.requests.Requests.newSearchRequest;
+import static org.forgerock.opendj.rest2ldap.Rest2Ldap.asResourceException;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
+import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException;
+import static org.forgerock.util.promise.Promises.newResultPromise;
+
+import org.forgerock.http.routing.UriRouterContext;
+import org.forgerock.i18n.LocalizedIllegalArgumentException;
+import org.forgerock.json.resource.ActionRequest;
+import org.forgerock.json.resource.ActionResponse;
+import org.forgerock.json.resource.BadRequestException;
+import org.forgerock.json.resource.CreateRequest;
+import org.forgerock.json.resource.DeleteRequest;
+import org.forgerock.json.resource.NotSupportedException;
+import org.forgerock.json.resource.PatchRequest;
+import org.forgerock.json.resource.QueryRequest;
+import org.forgerock.json.resource.QueryResourceHandler;
+import org.forgerock.json.resource.QueryResponse;
+import org.forgerock.json.resource.ReadRequest;
+import org.forgerock.json.resource.RequestHandler;
+import org.forgerock.json.resource.ResourceException;
+import org.forgerock.json.resource.ResourceResponse;
+import org.forgerock.json.resource.Router;
+import org.forgerock.json.resource.UpdateRequest;
+import org.forgerock.opendj.ldap.Attribute;
+import org.forgerock.opendj.ldap.AttributeDescription;
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.Connection;
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.Entry;
+import org.forgerock.opendj.ldap.Filter;
+import org.forgerock.opendj.ldap.LdapException;
+import org.forgerock.opendj.ldap.LinkedAttribute;
+import org.forgerock.opendj.ldap.RDN;
+import org.forgerock.opendj.ldap.requests.SearchRequest;
+import org.forgerock.opendj.ldap.responses.SearchResultEntry;
+import org.forgerock.services.context.Context;
+import org.forgerock.util.AsyncFunction;
+import org.forgerock.util.Function;
+import org.forgerock.util.promise.Promise;
+
+/**
+ * Defines a one-to-many relationship between a parent resource and its children. Removal of the parent resource
+ * implies that the children (the sub-resources) are also removed. Collections support all request types.
+ */
+public final class SubResourceCollection extends SubResource {
+    /** The LDAP object classes associated with the glue entries forming the DN template. */
+    private final Attribute glueObjectClasses = new LinkedAttribute("objectClass");
+
+    private NamingStrategy namingStrategy;
+
+    SubResourceCollection(final String resourceId) {
+        super(resourceId);
+        useClientDnNaming("uid");
+    }
+
+    /**
+     * Indicates that the JSON resource ID must be provided by the user, and will be used for naming the associated LDAP
+     * entry. More specifically, LDAP entry names will be derived by appending a single RDN to the collection's base DN
+     * composed of the specified attribute type and LDAP value taken from the LDAP entry once attribute mapping has been
+     * performed.
+     * <p>
+     * Note that this naming policy requires that the user provides the resource name when creating new resources, which
+     * means it must be included in the resource content when not specified explicitly in the create request.
+     *
+     * @param dnAttribute
+     *         The LDAP attribute which will be used for naming.
+     * @return A reference to this object.
+     */
+    public SubResourceCollection useClientDnNaming(final String dnAttribute) {
+        this.namingStrategy = new DnNamingStrategy(dnAttribute);
+        return this;
+    }
+
+    /**
+     * Indicates that the JSON resource ID must be provided by the user, but will not be used for naming the
+     * associated LDAP entry. Instead the JSON resource ID will be taken from the {@code idAttribute} in the LDAP
+     * entry, and the LDAP entry name will be derived by appending a single RDN to the collection's base DN composed
+     * of the {@code dnAttribute} taken from the LDAP entry once attribute mapping has been performed.
+     * <p>
+     * Note that this naming policy requires that the user provides the resource name when creating new resources, which
+     * means it must be included in the resource content when not specified explicitly in the create request.
+     *
+     * @param dnAttribute
+     *         The attribute which will be used for naming LDAP entries.
+     * @param idAttribute
+     *         The attribute which will be used for JSON resource IDs.
+     * @return A reference to this object.
+     */
+    public SubResourceCollection useClientNaming(final String dnAttribute, final String idAttribute) {
+        this.namingStrategy = new AttributeNamingStrategy(dnAttribute, idAttribute, false);
+        return this;
+    }
+
+    /**
+     * Indicates that the JSON resource ID will be derived from the server provided "entryUUID" LDAP attribute. The
+     * LDAP entry name will be derived by appending a single RDN to the collection's base DN composed of the {@code
+     * dnAttribute} taken from the LDAP entry once attribute mapping has been performed.
+     * <p>
+     * Note that this naming policy requires that the server provides the resource name when creating new resources,
+     * which means it must not be specified in the create request, nor included in the resource content.
+     *
+     * @param dnAttribute
+     *         The attribute which will be used for naming LDAP entries.
+     * @return A reference to this object.
+     */
+    public SubResourceCollection useServerEntryUuidNaming(final String dnAttribute) {
+        return useServerNaming(dnAttribute, "entryUUID");
+    }
+
+    /**
+     * Indicates that the JSON resource ID must not be provided by the user, and will not be used for naming the
+     * associated LDAP entry. Instead the JSON resource ID will be taken from the {@code idAttribute} in the LDAP
+     * entry, and the LDAP entry name will be derived by appending a single RDN to the collection's base DN composed
+     * of the {@code dnAttribute} taken from the LDAP entry once attribute mapping has been performed.
+     * <p>
+     * Note that this naming policy requires that the server provides the resource name when creating new resources,
+     * which means it must not be specified in the create request, nor included in the resource content.
+     *
+     * @param dnAttribute
+     *         The attribute which will be used for naming LDAP entries.
+     * @param idAttribute
+     *         The attribute which will be used for JSON resource IDs.
+     * @return A reference to this object.
+     */
+    public SubResourceCollection useServerNaming(final String dnAttribute, final String idAttribute) {
+        this.namingStrategy = new AttributeNamingStrategy(dnAttribute, idAttribute, true);
+        return this;
+    }
+
+    /**
+     * Sets the relative URL template beneath which the sub-resources will be located. The template may be empty
+     * indicating that the sub-resources will be located directly beneath the parent resource. Any URL template
+     * variables will be substituted into the {@link #dnTemplate(String) DN template}.
+     *
+     * @param urlTemplate
+     *         The relative URL template.
+     * @return A reference to this object.
+     */
+    public SubResourceCollection urlTemplate(final String urlTemplate) {
+        this.urlTemplate = urlTemplate;
+        return this;
+    }
+
+    /**
+     * Sets the relative DN template beneath which the sub-resource LDAP entries will be located. The template may be
+     * empty indicating that the LDAP entries will be located directly beneath the parent LDAP entry. Any DN template
+     * variables will be substituted using values extracted from the {@link #urlTemplate(String) URL template}.
+     *
+     * @param dnTemplate
+     *         The relative DN template.
+     * @return A reference to this object.
+     */
+    public SubResourceCollection dnTemplate(final String dnTemplate) {
+        this.dnTemplate = dnTemplate;
+        return this;
+    }
+
+    /**
+     * Specifies an LDAP object class which is to be associated with any intermediate "glue" entries forming the DN
+     * template. Multiple object classes may be specified.
+     *
+     * @param objectClass
+     *         An LDAP object class which is to be associated with any intermediate "glue" entries forming the DN
+     *         template.
+     * @return A reference to this object.
+     */
+    public SubResourceCollection glueObjectClass(final String objectClass) {
+        this.glueObjectClasses.add(objectClass);
+        return this;
+    }
+
+    /**
+     * Specifies one or more LDAP object classes which is to be associated with any intermediate "glue" entries
+     * forming the DN template. Multiple object classes may be specified.
+     *
+     * @param objectClasses
+     *         The LDAP object classes which is to be associated with any intermediate "glue" entries forming the DN
+     *         template.
+     * @return A reference to this object.
+     */
+    public SubResourceCollection glueObjectClasses(final String... objectClasses) {
+        this.glueObjectClasses.add((Object[]) objectClasses);
+        return this;
+    }
+
+    /**
+     * Indicates whether this sub-resource collection only supports read and query operations.
+     *
+     * @param readOnly
+     *         {@code true} if this sub-resource collection is read-only.
+     * @return A reference to this object.
+     */
+    public SubResourceCollection isReadOnly(final boolean readOnly) {
+        isReadOnly = readOnly;
+        return this;
+    }
+
+    @Override
+    Router addRoutes(final Router router) {
+        router.addRoute(requestUriMatcher(EQUALS, urlTemplate), readOnly(new CollectionHandler()));
+        router.addRoute(requestUriMatcher(EQUALS, urlTemplate + "/{id}"), readOnly(new InstanceHandler()));
+        router.addRoute(requestUriMatcher(STARTS_WITH, urlTemplate + "/{id}"), readOnly(new SubResourceHandler()));
+        return router;
+    }
+
+    private Promise<RoutingContext, ResourceException> route(final Context context) {
+        final Connection conn = context.asContext(AuthenticatedConnectionContext.class).getConnection();
+        final SearchRequest searchRequest = namingStrategy.createSearchRequest(dnFrom(context), idFrom(context));
+        if (searchRequest.getScope().equals(BASE_OBJECT) && !resource.hasSubTypesWithSubResources()) {
+            // There's no point in doing a search because we already know the DN and sub-resources.
+            return newResultPromise(new RoutingContext(context, searchRequest.getName(), resource));
+        }
+        searchRequest.addAttribute("objectClass");
+        return conn.searchSingleEntryAsync(searchRequest)
+                         .thenAsync(new AsyncFunction<SearchResultEntry, RoutingContext, ResourceException>() {
+                             @Override
+                             public Promise<RoutingContext, ResourceException> apply(SearchResultEntry entry)
+                                     throws ResourceException {
+                                 final Resource subType = resource.resolveSubTypeFromObjectClasses(entry);
+                                 return newResultPromise(new RoutingContext(context, entry.getName(), subType));
+                             }
+                         }, new AsyncFunction<LdapException, RoutingContext, ResourceException>() {
+                             @Override
+                             public Promise<RoutingContext, ResourceException> apply(LdapException e)
+                                     throws ResourceException {
+                                 return asResourceException(e).asPromise();
+                             }
+                         });
+    }
+
+    private SubResourceImpl collection(final Context context) {
+        return new SubResourceImpl(rest2Ldap,
+                                   dnFrom(context),
+                                   dnTemplate.isEmpty() ? null : glueObjectClasses,
+                                   namingStrategy,
+                                   resource);
+    }
+
+    private String idFrom(final Context context) {
+        return context.asContext(UriRouterContext.class).getUriTemplateVariables().get("id");
+    }
+
+    private static final class AttributeNamingStrategy implements NamingStrategy {
+        private final AttributeDescription dnAttribute;
+        private final AttributeDescription idAttribute;
+        private final boolean isServerProvided;
+
+        private AttributeNamingStrategy(final String dnAttribute, final String idAttribute,
+                                        final boolean isServerProvided) {
+            this.dnAttribute = AttributeDescription.valueOf(dnAttribute);
+            this.idAttribute = AttributeDescription.valueOf(idAttribute);
+            if (this.dnAttribute.equals(this.idAttribute)) {
+                throw new LocalizedIllegalArgumentException(ERR_CONFIG_NAMING_STRATEGY_DN_AND_ID_NOT_DIFFERENT.get());
+            }
+            this.isServerProvided = isServerProvided;
+        }
+
+        @Override
+        public SearchRequest createSearchRequest(final DN baseDn, final String resourceId) {
+            return newSearchRequest(baseDn, SINGLE_LEVEL, Filter.equality(idAttribute.toString(), resourceId));
+        }
+
+        @Override
+        public String getResourceIdLdapAttribute() {
+            return idAttribute.toString();
+        }
+
+        @Override
+        public String decodeResourceId(final Entry entry) {
+            return entry.parseAttribute(idAttribute).asString();
+        }
+
+        @Override
+        public void encodeResourceId(final DN baseDn, final String resourceId, final Entry entry)
+                throws ResourceException {
+            if (isServerProvided) {
+                if (resourceId != null) {
+                    throw newBadRequestException(ERR_SERVER_PROVIDED_RESOURCE_ID_UNEXPECTED.get());
+                }
+            } else {
+                entry.addAttribute(new LinkedAttribute(idAttribute, ByteString.valueOfUtf8(resourceId)));
+            }
+            final String rdnValue = entry.parseAttribute(dnAttribute).asString();
+            final RDN rdn = new RDN(dnAttribute.getAttributeType(), rdnValue);
+            entry.setName(baseDn.child(rdn));
+        }
+    }
+
+    private static final class DnNamingStrategy implements NamingStrategy {
+        private final AttributeDescription attribute;
+
+        private DnNamingStrategy(final String attribute) {
+            this.attribute = AttributeDescription.valueOf(attribute);
+        }
+
+        @Override
+        public SearchRequest createSearchRequest(final DN baseDn, final String resourceId) {
+            return newSearchRequest(baseDn.child(rdn(resourceId)), BASE_OBJECT, objectClassPresent());
+        }
+
+        @Override
+        public String getResourceIdLdapAttribute() {
+            return attribute.toString();
+        }
+
+        @Override
+        public String decodeResourceId(final Entry entry) {
+            return entry.parseAttribute(attribute).asString();
+        }
+
+        @Override
+        public void encodeResourceId(final DN baseDn, final String resourceId, final Entry entry)
+                throws ResourceException {
+            if (resourceId != null) {
+                entry.setName(baseDn.child(rdn(resourceId)));
+                entry.addAttribute(new LinkedAttribute(attribute, ByteString.valueOfUtf8(resourceId)));
+            } else if (entry.getAttribute(attribute) != null) {
+                entry.setName(baseDn.child(rdn(entry.parseAttribute(attribute).asString())));
+            } else {
+                throw newBadRequestException(ERR_CLIENT_PROVIDED_RESOURCE_ID_MISSING.get());
+            }
+        }
+
+        private RDN rdn(final String resourceId) {
+            return new RDN(attribute.getAttributeType(), resourceId);
+        }
+    }
+
+    /**
+     * Responsible for routing collection requests (CQ) to this collection. More specifically, given the
+     * URL template /collection/{id} then this handler processes requests against /collection.
+     */
+    private final class CollectionHandler extends AbstractRequestHandler {
+        private CollectionHandler() {
+            super(new BadRequestException(ERR_UNSUPPORTED_REQUEST_AGAINST_COLLECTION.get().toString()));
+        }
+
+        @Override
+        public Promise<ActionResponse, ResourceException> handleAction(final Context context,
+                                                                       final ActionRequest request) {
+            return new NotSupportedException(ERR_COLLECTION_ACTIONS_NOT_SUPPORTED.get().toString()).asPromise();
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleCreate(final Context context,
+                                                                         final CreateRequest request) {
+            return collection(context).create(context, request);
+        }
+
+        @Override
+        public Promise<QueryResponse, ResourceException> handleQuery(final Context context, final QueryRequest request,
+                                                                     final QueryResourceHandler handler) {
+            return collection(context).query(context, request, handler);
+        }
+    }
+
+    /**
+     * Responsible for processing instance requests (RUDPA) against this collection and collection requests (CQ) to
+     * any collections sharing the same base URL as an instance within this collection. More specifically, given the
+     * URL template /collection/{parent}/{child} then this handler processes requests against {parent} since it is
+     * both an instance within /collection and also a collection of {child}.
+     */
+    private final class InstanceHandler implements RequestHandler {
+        @Override
+        public Promise<ActionResponse, ResourceException> handleAction(final Context context,
+                                                                       final ActionRequest request) {
+            return collection(context).action(context, idFrom(context), request);
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleCreate(final Context context,
+                                                                         final CreateRequest request) {
+            return route(context)
+                    .thenAsync(new AsyncFunction<RoutingContext, ResourceResponse, ResourceException>() {
+                        @Override
+                        public Promise<ResourceResponse, ResourceException> apply(final RoutingContext context) {
+                            return subResourceRouterFrom(context).handleCreate(context, request);
+                        }
+                    }).thenCatch(this.<ResourceResponse>convert404To400());
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleDelete(final Context context,
+                                                                         final DeleteRequest request) {
+            return collection(context).delete(context, idFrom(context), request);
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handlePatch(final Context context,
+                                                                        final PatchRequest request) {
+            return collection(context).patch(context, idFrom(context), request);
+        }
+
+        @Override
+        public Promise<QueryResponse, ResourceException> handleQuery(final Context context, final QueryRequest request,
+                                                                     final QueryResourceHandler handler) {
+            return route(context)
+                    .thenAsync(new AsyncFunction<RoutingContext, QueryResponse, ResourceException>() {
+                        @Override
+                        public Promise<QueryResponse, ResourceException> apply(final RoutingContext context) {
+                            return subResourceRouterFrom(context).handleQuery(context, request, handler);
+                        }
+                    }).thenCatch(this.<QueryResponse>convert404To400());
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleRead(final Context context,
+                                                                       final ReadRequest request) {
+            return collection(context).read(context, idFrom(context), request);
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleUpdate(final Context context,
+                                                                         final UpdateRequest request) {
+            return collection(context).update(context, idFrom(context), request);
+        }
+
+        private <T> Function<ResourceException, T, ResourceException> convert404To400() {
+            return SubResource.convert404To400(ERR_UNSUPPORTED_REQUEST_AGAINST_INSTANCE.get());
+        }
+    }
+
+    /**
+     * Responsible for routing requests to sub-resources of instances within this collection. More specifically, given
+     * the URL template /collection/{id} then this handler processes all requests beneath /collection/{id}.
+     */
+    private final class SubResourceHandler implements RequestHandler {
+        @Override
+        public Promise<ActionResponse, ResourceException> handleAction(final Context context,
+                                                                       final ActionRequest request) {
+            return route(context).thenAsync(new AsyncFunction<RoutingContext, ActionResponse, ResourceException>() {
+                @Override
+                public Promise<ActionResponse, ResourceException> apply(final RoutingContext context) {
+                    return subResourceRouterFrom(context).handleAction(context, request);
+                }
+            });
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleCreate(final Context context,
+                                                                         final CreateRequest request) {
+            return route(context).thenAsync(new AsyncFunction<RoutingContext, ResourceResponse, ResourceException>() {
+                @Override
+                public Promise<ResourceResponse, ResourceException> apply(final RoutingContext context) {
+                    return subResourceRouterFrom(context).handleCreate(context, request);
+                }
+            });
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleDelete(final Context context,
+                                                                         final DeleteRequest request) {
+            return route(context).thenAsync(new AsyncFunction<RoutingContext, ResourceResponse, ResourceException>() {
+                @Override
+                public Promise<ResourceResponse, ResourceException> apply(final RoutingContext context) {
+                    return subResourceRouterFrom(context).handleDelete(context, request);
+                }
+            });
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handlePatch(final Context context,
+                                                                        final PatchRequest request) {
+            return route(context).thenAsync(new AsyncFunction<RoutingContext, ResourceResponse, ResourceException>() {
+                @Override
+                public Promise<ResourceResponse, ResourceException> apply(final RoutingContext context) {
+                    return subResourceRouterFrom(context).handlePatch(context, request);
+                }
+            });
+        }
+
+        @Override
+        public Promise<QueryResponse, ResourceException> handleQuery(final Context context, final QueryRequest request,
+                                                                     final QueryResourceHandler handler) {
+            return route(context).thenAsync(new AsyncFunction<RoutingContext, QueryResponse, ResourceException>() {
+                @Override
+                public Promise<QueryResponse, ResourceException> apply(final RoutingContext context) {
+                    return subResourceRouterFrom(context).handleQuery(context, request, handler);
+                }
+            });
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleRead(final Context context,
+                                                                       final ReadRequest request) {
+            return route(context).thenAsync(new AsyncFunction<RoutingContext, ResourceResponse, ResourceException>() {
+                @Override
+                public Promise<ResourceResponse, ResourceException> apply(final RoutingContext context) {
+                    return subResourceRouterFrom(context).handleRead(context, request);
+                }
+            });
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleUpdate(final Context context,
+                                                                         final UpdateRequest request) {
+            return route(context).thenAsync(new AsyncFunction<RoutingContext, ResourceResponse, ResourceException>() {
+                @Override
+                public Promise<ResourceResponse, ResourceException> apply(final RoutingContext context) {
+                    return subResourceRouterFrom(context).handleUpdate(context, request);
+                }
+            });
+        }
+    }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java
index 4f5403a..a1da30e 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceImpl.java
@@ -16,20 +16,30 @@
 package org.forgerock.opendj.rest2ldap;
 
 import static org.forgerock.i18n.LocalizableMessage.raw;
-import static org.forgerock.json.resource.Responses.newResourceResponse;
+import static org.forgerock.opendj.ldap.ResultCode.Enum.NOT_ALLOWED_ON_NONLEAF;
+import static org.forgerock.opendj.ldap.SearchScope.BASE_OBJECT;
+import static org.forgerock.opendj.ldap.responses.Responses.newResult;
+import static org.forgerock.opendj.ldap.spi.LdapPromises.newSuccessfulLdapPromise;
+import static org.forgerock.opendj.rest2ldap.FilterType.*;
+import static org.forgerock.opendj.rest2ldap.Rest2Ldap.*;
 import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
-import static java.util.Arrays.asList;
+import static org.forgerock.json.resource.ResourceException.FORBIDDEN;
+import static org.forgerock.json.resource.ResourceException.newResourceException;
+import static org.forgerock.json.resource.Responses.newActionResponse;
+import static org.forgerock.json.resource.Responses.newQueryResponse;
+import static org.forgerock.json.resource.Responses.newResourceResponse;
+import static org.forgerock.opendj.ldap.ByteString.valueOfBytes;
 import static org.forgerock.opendj.ldap.Filter.alwaysFalse;
 import static org.forgerock.opendj.ldap.Filter.alwaysTrue;
-import static org.forgerock.opendj.ldap.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.ldap.SearchScope.SINGLE_LEVEL;
+import static org.forgerock.opendj.ldap.requests.Requests.*;
 import static org.forgerock.opendj.rest2ldap.ReadOnUpdatePolicy.CONTROLS;
-import static org.forgerock.opendj.rest2ldap.Rest2Ldap.asResourceException;
 import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException;
 import static org.forgerock.opendj.rest2ldap.Utils.newNotSupportedException;
 import static org.forgerock.opendj.rest2ldap.Utils.toFilter;
+import static org.forgerock.util.Utils.asEnum;
+import static org.forgerock.util.promise.Promises.newResultPromise;
+import static org.forgerock.util.promise.Promises.when;
 
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
@@ -40,6 +50,7 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
 
 import org.forgerock.i18n.LocalizableMessage;
 import org.forgerock.i18n.slf4j.LocalizedLogger;
@@ -48,7 +59,7 @@
 import org.forgerock.json.JsonValueException;
 import org.forgerock.json.resource.ActionRequest;
 import org.forgerock.json.resource.ActionResponse;
-import org.forgerock.json.resource.CollectionResourceProvider;
+import org.forgerock.json.resource.BadRequestException;
 import org.forgerock.json.resource.CreateRequest;
 import org.forgerock.json.resource.DeleteRequest;
 import org.forgerock.json.resource.NotSupportedException;
@@ -61,7 +72,6 @@
 import org.forgerock.json.resource.ReadRequest;
 import org.forgerock.json.resource.ResourceException;
 import org.forgerock.json.resource.ResourceResponse;
-import org.forgerock.json.resource.Responses;
 import org.forgerock.json.resource.UncategorizedException;
 import org.forgerock.json.resource.UpdateRequest;
 import org.forgerock.opendj.ldap.Attribute;
@@ -72,9 +82,12 @@
 import org.forgerock.opendj.ldap.DecodeException;
 import org.forgerock.opendj.ldap.DecodeOptions;
 import org.forgerock.opendj.ldap.Entry;
+import org.forgerock.opendj.ldap.EntryNotFoundException;
 import org.forgerock.opendj.ldap.Filter;
 import org.forgerock.opendj.ldap.LdapException;
+import org.forgerock.opendj.ldap.LdapPromise;
 import org.forgerock.opendj.ldap.Modification;
+import org.forgerock.opendj.ldap.ResultCode;
 import org.forgerock.opendj.ldap.SearchResultHandler;
 import org.forgerock.opendj.ldap.SearchScope;
 import org.forgerock.opendj.ldap.controls.AssertionRequestControl;
@@ -88,7 +101,6 @@
 import org.forgerock.opendj.ldap.requests.AddRequest;
 import org.forgerock.opendj.ldap.requests.ModifyRequest;
 import org.forgerock.opendj.ldap.requests.PasswordModifyExtendedRequest;
-import org.forgerock.opendj.ldap.requests.Requests;
 import org.forgerock.opendj.ldap.requests.SearchRequest;
 import org.forgerock.opendj.ldap.responses.PasswordModifyExtendedResult;
 import org.forgerock.opendj.ldap.responses.Result;
@@ -104,72 +116,69 @@
 import org.forgerock.util.promise.ExceptionHandler;
 import org.forgerock.util.promise.Promise;
 import org.forgerock.util.promise.PromiseImpl;
-import org.forgerock.util.promise.Promises;
 import org.forgerock.util.promise.ResultHandler;
 import org.forgerock.util.query.QueryFilter;
 import org.forgerock.util.query.QueryFilterVisitor;
 
-/**
- * A {@code CollectionResourceProvider} implementation which maps a JSON
- * resource collection to LDAP entries beneath a base DN.
- */
-final class SubResourceImpl implements CollectionResourceProvider {
-
+/** Implements the core CREST operations supported by singleton and collection sub-resources. */
+final class SubResourceImpl {
     private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
 
     /** Dummy exception used for signalling search success. */
     private static final ResourceException SUCCESS = new UncategorizedException(0, null, null);
 
-    /** Empty decode options required for decoding response controls. */
-    private static final DecodeOptions DECODE_OPTIONS = new DecodeOptions();
+    private static final JsonPointer ROOT = new JsonPointer();
 
-    private final List<Attribute> additionalLDAPAttributes;
-    private final PropertyMapper propertyMapper;
-    private final DN baseDn; // TODO: support template variables.
-    private final Config config;
+    private final DN baseDn;
     private final AttributeDescription etagAttribute;
     private final NamingStrategy namingStrategy;
+    private final DecodeOptions decodeOptions;
+    private final ReadOnUpdatePolicy readOnUpdatePolicy;
+    private final boolean useSubtreeDelete;
+    private final boolean usePermissiveModify;
+    private final Resource resource;
+    private final Attribute glueObjectClasses;
 
-    SubResourceImpl(final DN baseDn, final PropertyMapper mapper,
-                    final NamingStrategy namingStrategy, final AttributeDescription etagAttribute,
-                    final Config config, final List<Attribute> additionalLDAPAttributes) {
+    SubResourceImpl(final Rest2Ldap rest2Ldap, final DN baseDn, final Attribute glueObjectClasses,
+                    final NamingStrategy namingStrategy, final Resource resource) {
+        this.readOnUpdatePolicy = rest2Ldap.getOptions().get(READ_ON_UPDATE_POLICY);
+        this.useSubtreeDelete = rest2Ldap.getOptions().get(USE_SUBTREE_DELETE);
+        this.usePermissiveModify = rest2Ldap.getOptions().get(USE_PERMISSIVE_MODIFY);
+        this.etagAttribute = rest2Ldap.getOptions().get(USE_MVCC)
+                ? AttributeDescription.valueOf(rest2Ldap.getOptions().get(MVCC_ATTRIBUTE)) : null;
+        this.decodeOptions = rest2Ldap.getOptions().get(DECODE_OPTIONS);
         this.baseDn = baseDn;
-        this.propertyMapper = mapper;
-        this.config = config;
+        this.glueObjectClasses = glueObjectClasses;
         this.namingStrategy = namingStrategy;
-        this.etagAttribute = etagAttribute;
-        this.additionalLDAPAttributes = additionalLDAPAttributes;
+        this.resource = resource;
     }
 
-    @Override
-    public Promise<ActionResponse, ResourceException> actionCollection(
-            final Context context, final ActionRequest request) {
-        return Promises.<ActionResponse, ResourceException> newExceptionPromise(
-                                                            newNotSupportedException(ERR_NOT_YET_IMPLEMENTED.get()));
-    }
-
-    @Override
-    public Promise<ActionResponse, ResourceException> actionInstance(
+    Promise<ActionResponse, ResourceException> action(
             final Context context, final String resourceId, final ActionRequest request) {
-        String actionId = request.getAction();
-        if (actionId.equals("passwordModify")) {
-            return passwordModify(context, resourceId, request);
+        try {
+            final Action action = asEnum(request.getAction(), Action.class);
+            if (resource.hasSupportedAction(action)) {
+                switch (action) {
+                case PASSWORDMODIFY:
+                    return passwordModify(context, resourceId, request);
+                }
+            }
+        } catch (final IllegalArgumentException ignored) {
+            // fall-through
         }
-        return Promises.<ActionResponse, ResourceException> newExceptionPromise(
-                newNotSupportedException(ERR_ACTION_NOT_SUPPORTED.get(actionId)));
+        return newNotSupportedException(ERR_ACTION_NOT_SUPPORTED.get(request.getAction())).asPromise();
+
     }
 
     private Promise<ActionResponse, ResourceException> passwordModify(
             final Context context, final String resourceId, final ActionRequest request) {
         if (!context.containsContext(ClientContext.class)
                 || !context.asContext(ClientContext.class).isSecure()) {
-            return Promises.newExceptionPromise(ResourceException.newResourceException(
-                    ResourceException.FORBIDDEN, ERR_PASSWORD_MODIFY_SECURE_CONNECTION.get().toString()));
+            return newResourceException(FORBIDDEN, ERR_PASSWORD_MODIFY_SECURE_CONNECTION.get().toString()).asPromise();
         }
         if (!context.containsContext(SecurityContext.class)
                 || context.asContext(SecurityContext.class).getAuthenticationId() == null) {
-            return Promises.newExceptionPromise(ResourceException.newResourceException(
-                    ResourceException.FORBIDDEN, ERR_PASSWORD_MODIFY_USER_AUTHENTICATED.get().toString()));
+            return newResourceException(FORBIDDEN, ERR_PASSWORD_MODIFY_USER_AUTHENTICATED.get().toString()).asPromise();
         }
 
         final JsonValue jsonContent = request.getContent();
@@ -182,231 +191,307 @@
             final LocalizableMessage msg = ERR_PASSWORD_MODIFY_REQUEST_IS_INVALID.get();
             final ResourceException ex = newBadRequestException(msg, e);
             logger.error(msg, e);
-            return Promises.newExceptionPromise(ex);
+            return ex.asPromise();
         }
 
-        final Connection connection = context.asContext(AuthenticatedConnectionContext.class).getConnection();
-        List<JsonPointer> attrs = Collections.emptyList();
-        return connection.searchSingleEntryAsync(searchRequest(connection, resourceId, attrs))
-                         .thenAsync(new AsyncFunction<SearchResultEntry, ActionResponse, ResourceException>() {
-                             @Override
-                             public Promise<ActionResponse, ResourceException> apply(
-                                       final SearchResultEntry entry) {
-                                 PasswordModifyExtendedRequest pwdModifyRequest =
-                                         Requests.newPasswordModifyExtendedRequest();
-                                 pwdModifyRequest.setUserIdentity("dn: " + entry.getName());
-                                 pwdModifyRequest.setOldPassword(asBytes(oldPassword));
-                                 pwdModifyRequest.setNewPassword(asBytes(newPassword));
-                                 return connection.extendedRequestAsync(pwdModifyRequest)
-                                     .thenAsync(new AsyncFunction<PasswordModifyExtendedResult,
-                                             ActionResponse, ResourceException>() {
-                                         @Override
-                                         public Promise<ActionResponse, ResourceException> apply(
-                                                 PasswordModifyExtendedResult value) throws ResourceException {
-                                             JsonValue result = new JsonValue(new LinkedHashMap<>());
-                                             byte[] generatedPwd = value.getGeneratedPassword();
-                                             if (generatedPwd != null) {
-                                                 result = result.put("generatedPassword",
-                                                         ByteString.valueOfBytes(generatedPwd).toString());
-                                             }
-                                             return Responses.newActionResponse(result).asPromise();
-                                         }
-                                     }, Exceptions.<ActionResponse>toResourceException());
-                             }
-                         }, Exceptions.<ActionResponse>toResourceException());
+        final Connection connection = connectionFrom(context);
+        return resolveResourceDnAndType(context, connection, resourceId, null)
+                .thenAsync(new AsyncFunction<RoutingContext, PasswordModifyExtendedResult, ResourceException>() {
+                    @Override
+                    public Promise<PasswordModifyExtendedResult, ResourceException> apply(RoutingContext dnAndType) {
+                        final PasswordModifyExtendedRequest pwdModifyRequest = newPasswordModifyExtendedRequest()
+                                .setUserIdentity("dn: " + dnAndType.getDn())
+                                .setOldPassword(asBytes(oldPassword))
+                                .setNewPassword(asBytes(newPassword));
+                        return connection.extendedRequestAsync(pwdModifyRequest)
+                                         .thenCatchAsync(adaptLdapException(PasswordModifyExtendedResult.class));
+                    }
+                }).thenAsync(new AsyncFunction<PasswordModifyExtendedResult, ActionResponse, ResourceException>() {
+                    @Override
+                    public Promise<ActionResponse, ResourceException> apply(PasswordModifyExtendedResult r) {
+                        final JsonValue result = new JsonValue(new LinkedHashMap<>());
+                        final byte[] generatedPwd = r.getGeneratedPassword();
+                        if (generatedPwd != null) {
+                            result.put("generatedPassword", valueOfBytes(generatedPwd).toString());
+                        }
+                        return newActionResponse(result).asPromise();
+                    }
+                });
     }
 
     private byte[] asBytes(final String s) {
         return s != null ? s.getBytes(StandardCharsets.UTF_8) : null;
     }
 
-    @Override
-    public Promise<ResourceResponse, ResourceException> createInstance(final Context context,
-            final CreateRequest request) {
-        final Connection connection = context.asContext(AuthenticatedConnectionContext.class).getConnection();
-        // Calculate entry content.
-        return propertyMapper
-                .create(connection, new JsonPointer(), request.getContent())
-                .thenAsync(new AsyncFunction<List<Attribute>, ResourceResponse, ResourceException>() {
-                    @Override
-                    public Promise<ResourceResponse, ResourceException> apply(final List<Attribute> attributes) {
-                        // Perform add operation.
-                        final AddRequest addRequest = newAddRequest(DN.rootDN());
-                        for (final Attribute attribute : additionalLDAPAttributes) {
-                            addRequest.addAttribute(attribute);
-                        }
-                        for (final Attribute attribute : attributes) {
-                            addRequest.addAttribute(attribute);
-                        }
-                        try {
-                            namingStrategy.setResourceId(connection, getBaseDn(),
-                                                         request.getNewResourceId(),
-                                                         addRequest);
-                        } catch (final ResourceException e) {
-                            logger.error(raw(e.getLocalizedMessage()), e);
-                            return Promises.newExceptionPromise(e);
-                        }
-                        if (config.readOnUpdatePolicy() == CONTROLS) {
-                            addRequest.addControl(PostReadRequestControl.newControl(
-                                    false, getLdapAttributes(connection, request.getFields())));
-                        }
-                        return connection.applyChangeAsync(addRequest)
-                                         .thenAsync(
-                                                 postUpdateResultAsyncFunction(connection),
-                                                 Exceptions.<ResourceResponse>toResourceException());
-                    }
-                });
-    }
-
-    @Override
-    public Promise<ResourceResponse, ResourceException> deleteInstance(
-            final Context context, final String resourceId, final DeleteRequest request) {
-        final Connection connection = context.asContext(AuthenticatedConnectionContext.class).getConnection();
-        return doUpdateFunction(connection, resourceId, request.getRevision())
-                .thenAsync(new AsyncFunction<DN, ResourceResponse, ResourceException>() {
-                    @Override
-                    public Promise<ResourceResponse, ResourceException> apply(DN dn) throws ResourceException {
-                        try {
-                            final ChangeRecord deleteRequest = newDeleteRequest(dn);
-                            if (config.readOnUpdatePolicy() == CONTROLS) {
-                                final String[] attributes = getLdapAttributes(connection, request.getFields());
-                                deleteRequest.addControl(PreReadRequestControl.newControl(false, attributes));
-                            }
-                            if (config.useSubtreeDelete()) {
-                                deleteRequest.addControl(SubtreeDeleteRequestControl.newControl(true));
-                            }
-                            addAssertionControl(deleteRequest, request.getRevision());
-                            return connection.applyChangeAsync(deleteRequest)
-                                             .thenAsync(
-                                                     postUpdateResultAsyncFunction(connection),
-                                                     Exceptions.<ResourceResponse>toResourceException());
-
-                        } catch (final Exception e) {
-                            return Promises.newExceptionPromise(asResourceException(e));
-                        }
-                    }
-                });
-    }
-
-    @Override
-    public Promise<ResourceResponse, ResourceException> patchInstance(
-            final Context context, final String resourceId, final PatchRequest request) {
-        final Connection connection = context.asContext(AuthenticatedConnectionContext.class).getConnection();
-        if (request.getPatchOperations().isEmpty()) {
-            return emptyPatchInstance(connection, resourceId, request);
+    Promise<ResourceResponse, ResourceException> create(final Context context, final CreateRequest  request) {
+        // First determine the type of resource being created.
+        final Resource subType;
+        try {
+            subType = resource.resolveSubTypeFromJson(request.getContent());
+        } catch (final ResourceException e) {
+            return e.asPromise();
         }
-        return doUpdateFunction(connection, resourceId, request.getRevision())
-                .thenAsync(new AsyncFunction<DN, ResourceResponse, ResourceException>() {
+
+        // Now build the LDAP representation and add it.
+        final Connection connection = connectionFrom(context);
+        return subType.getPropertyMapper()
+                      .create(connection, subType, ROOT, request.getContent())
+                      .thenAsync(new AsyncFunction<List<Attribute>, ResourceResponse, ResourceException>() {
+                          @Override
+                          public Promise<ResourceResponse, ResourceException> apply(final List<Attribute> attributes) {
+                              // Perform add operation.
+                              final AddRequest addRequest = newAddRequest(DN.rootDN());
+                              addRequest.addAttribute(subType.getObjectClassAttribute());
+                              for (final Attribute attribute : attributes) {
+                                  addRequest.addAttribute(attribute);
+                              }
+                              try {
+                                  namingStrategy.encodeResourceId(baseDn, request.getNewResourceId(), addRequest);
+                              } catch (final ResourceException e) {
+                                  logger.error(raw(e.getLocalizedMessage()), e);
+                                  return e.asPromise();
+                              }
+                              if (readOnUpdatePolicy == CONTROLS) {
+                                  final Set<String> ldapAttributes =
+                                          getLdapAttributesForKnownType(request.getFields(), subType);
+                                  addRequest.addControl(PostReadRequestControl.newControl(false, ldapAttributes));
+                              }
+                              return connection.addAsync(addRequest)
+                                               .thenCatchAsync(lazilyAddGlueEntry(connection, addRequest))
+                                               .thenAsync(encodeUpdateResourceResponse(connection, subType),
+                                                          adaptLdapException(ResourceResponse.class));
+                          }
+                      });
+    }
+
+    /**
+     * A resource and sub-resource may be separated by a "glue" entry in LDAP. This method detects when a glue entry
+     * is missing, creates it, and then retries the original add operation. As a concrete example, consider the
+     * backend configuration entry "ds-cfg-backend-id=userRoot,cn=backends,cn=config". Since its indexes are located
+     * beneath "cn=Indexes,ds-cfg-backend-id=userRoot,cn=backends,cn=config" we need to add "cn=Indexes" before
+     * adding an index entry.
+     */
+    private AsyncFunction<LdapException, Result, LdapException> lazilyAddGlueEntry(final Connection connection,
+                                                                                   final AddRequest addRequest) {
+        return new AsyncFunction<LdapException, Result, LdapException>() {
+            @Override
+            public Promise<Result, LdapException> apply(final LdapException e) throws LdapException {
+                if (glueObjectClasses != null && e instanceof EntryNotFoundException) {
+                    // The parent glue entry may be missing - lazily create it.
+                    final AddRequest glueAddRequest = newAddRequest(baseDn);
+                    glueAddRequest.addAttribute(glueObjectClasses);
+                    glueAddRequest.addAttribute(baseDn.rdn().getFirstAVA().toAttribute());
+                    return connection.addAsync(glueAddRequest)
+                                     .thenAsync(new AsyncFunction<Result, Result, LdapException>() {
+                                         @Override
+                                         public Promise<Result, LdapException> apply(final Result value) {
+                                             return connection.addAsync(addRequest);
+                                         }
+                                     });
+                }
+                // Something else happened, so rethrow.
+                throw e;
+            }
+        };
+    }
+
+    private Connection connectionFrom(final Context context) {
+        return context.asContext(AuthenticatedConnectionContext.class).getConnection();
+    }
+
+    Promise<ResourceResponse, ResourceException> delete(
+            final Context context, final String resourceId, final DeleteRequest request) {
+        final Connection connection = connectionFrom(context);
+        return resolveResourceDnAndType(context, connection, resourceId, request.getRevision())
+                .thenAsync(new AsyncFunction<RoutingContext, ResourceResponse, ResourceException>() {
                     @Override
-                    public Promise<ResourceResponse, ResourceException> apply(final DN dn) throws ResourceException {
-                        // Convert the patch operations to LDAP modifications.
-                        List<Promise<List<Modification>, ResourceException>> promises =
-                                new ArrayList<>(request.getPatchOperations().size());
-                        for (final PatchOperation operation : request.getPatchOperations()) {
-                            promises.add(propertyMapper.patch(connection, new JsonPointer(), operation));
+                    public Promise<ResourceResponse, ResourceException> apply(final RoutingContext dnAndType)
+                            throws ResourceException {
+                        final ChangeRecord deleteRequest = newDeleteRequest(dnAndType.getDn());
+                        if (readOnUpdatePolicy == CONTROLS) {
+                            final Set<String> attributes =
+                                    getLdapAttributesForKnownType(request.getFields(), dnAndType.getType());
+                            deleteRequest.addControl(PreReadRequestControl.newControl(false, attributes));
                         }
-
-                        return Promises.when(promises).thenAsync(
-                                new AsyncFunction<List<List<Modification>>, ResourceResponse, ResourceException>() {
-                                    @Override
-                                    public Promise<ResourceResponse, ResourceException> apply(
-                                            final List<List<Modification>> result) {
-                                        // The patch operations have been converted successfully.
-                                        try {
-                                            final ModifyRequest modifyRequest = newModifyRequest(dn);
-
-                                            // Add the modifications.
-                                            for (final List<Modification> modifications : result) {
-                                                if (modifications != null) {
-                                                    modifyRequest.getModifications().addAll(modifications);
-                                                }
-                                            }
-
-                                            final List<String> attributes =
-                                                    asList(getLdapAttributes(connection, request.getFields()));
-                                            if (modifyRequest.getModifications().isEmpty()) {
-                                                // This patch is a no-op so just read the entry and check its version.
-                                                return
-                                                   connection
-                                                     .readEntryAsync(dn, attributes)
-                                                     .thenAsync(postEmptyPatchAsyncFunction(connection, request),
-                                                                Exceptions.<ResourceResponse>toResourceException());
-                                            } else {
-                                                // Add controls and perform the modify request.
-                                                if (config.readOnUpdatePolicy() == CONTROLS) {
-                                                    modifyRequest.addControl(
-                                                            PostReadRequestControl.newControl(false, attributes));
-                                                }
-                                                if (config.usePermissiveModify()) {
-                                                    modifyRequest.addControl(
-                                                            PermissiveModifyRequestControl.newControl(true));
-                                                }
-                                                addAssertionControl(modifyRequest, request.getRevision());
-                                                return connection
-                                                        .applyChangeAsync(modifyRequest)
-                                                        .thenAsync(
-                                                                postUpdateResultAsyncFunction(connection),
-                                                                Exceptions.<ResourceResponse>toResourceException());
-                                            }
-                                        } catch (final Exception e) {
-                                            return Promises.newExceptionPromise(asResourceException(e));
-                                        }
-                                    }
-                                });
+                        if (resource.mayHaveSubResources() && useSubtreeDelete) {
+                            // Non-critical so that we can detect failure and retry without the control. Some backends,
+                            // such as cn=config, do not support the subtree delete control.
+                            deleteRequest.addControl(SubtreeDeleteRequestControl.newControl(false));
+                        }
+                        addAssertionControl(deleteRequest, request.getRevision());
+                        return connection.applyChangeAsync(deleteRequest)
+                                         .thenCatchAsync(deleteSubtreeWithoutUsingSubtreeDeleteControl(connection,
+                                                                                                       deleteRequest))
+                                         .thenAsync(encodeUpdateResourceResponse(connection, dnAndType.getType()),
+                                                    adaptLdapException(ResourceResponse.class));
                     }
                 });
     }
 
-    /** Just read the entry and check its version. */
-    private Promise<ResourceResponse, ResourceException> emptyPatchInstance(final Connection connection,
-            final String resourceId, final PatchRequest request) {
-        final SearchRequest searchRequest = searchRequest(connection, resourceId, request.getFields());
-        return connection
-                .searchSingleEntryAsync(searchRequest)
-                .thenAsync(postEmptyPatchAsyncFunction(connection, request),
-                           Exceptions.<ResourceResponse>toResourceException());
+    /**
+     * Detects whether a delete request failed because the targeted entry has children and the subtree delete control
+     * could not be applied (e.g. due to ACIs or lack of support in the backend). On failure, fall-back to a search
+     * and then a recursive bottom up delete of all subordinate entries, before finally retrying the original delete
+     * request.
+     */
+    private AsyncFunction<LdapException, Result, LdapException> deleteSubtreeWithoutUsingSubtreeDeleteControl(
+            final Connection connection, final ChangeRecord deleteRequest) {
+        return new AsyncFunction<LdapException, Result, LdapException>() {
+            @Override
+            public Promise<Result, LdapException> apply(final LdapException e) throws LdapException {
+                if (e.getResult().getResultCode().asEnum() != NOT_ALLOWED_ON_NONLEAF
+                        || !resource.mayHaveSubResources()) {
+                    throw e;
+                }
+
+                // Perform a subtree search and then delete entries one by one.
+                final SearchRequest subordinates = newSearchRequest(deleteRequest.getName(),
+                                                                    SearchScope.SUBORDINATES,
+                                                                    Filter.objectClassPresent(),
+                                                                    "1.1");
+
+                // This list does not need synchronization because search result notification is synchronized.
+                final List<DN> subordinateEntries = new ArrayList<>();
+                return connection.searchAsync(subordinates, new SearchResultHandler() {
+                    @Override
+                    public boolean handleEntry(final SearchResultEntry entry) {
+                        subordinateEntries.add(entry.getName());
+                        return true;
+                    }
+
+                    @Override
+                    public boolean handleReference(final SearchResultReference reference) {
+                        return false;
+                    }
+                }).thenAsync(new AsyncFunction<Result, Result, LdapException>() {
+                    @Override
+                    public Promise<Result, LdapException> apply(final Result result) {
+                        // Sort the entries in hierarchical order and then delete them in reverse, thus
+                        // always deleting children before parents.
+                        Collections.sort(subordinateEntries);
+                        LdapPromise<Result> promise = newSuccessfulLdapPromise(newResult(ResultCode.SUCCESS));
+                        for (int i = subordinateEntries.size() - 1; i >= 0; i--) {
+                            final ChangeRecord subordinateDelete = newDeleteRequest(subordinateEntries.get(i));
+                            promise = promise.thenAsync(new AsyncFunction<Result, Result, LdapException>() {
+                                @Override
+                                public Promise<Result, LdapException> apply(final Result result) {
+                                    return connection.applyChangeAsync(subordinateDelete);
+                                }
+                            });
+                        }
+                        // And finally retry the original delete request.
+                        return promise.thenAsync(new AsyncFunction<Result, Result, LdapException>() {
+                            @Override
+                            public Promise<Result, LdapException> apply(final Result result) {
+                                return connection.applyChangeAsync(deleteRequest);
+                            }
+                        });
+                    }
+                });
+            }
+        };
     }
 
-    private AsyncFunction<SearchResultEntry, ResourceResponse, ResourceException> postEmptyPatchAsyncFunction(
-            final Connection connection, final PatchRequest request) {
-        return new AsyncFunction<SearchResultEntry, ResourceResponse, ResourceException>() {
+    Promise<ResourceResponse, ResourceException> patch(
+            final Context context, final String resourceId, final PatchRequest request) {
+        final Connection connection = connectionFrom(context);
+        final AtomicReference<RoutingContext> dnAndTypeHolder = new AtomicReference<>();
+        return resolveResourceDnAndType(context, connection, resourceId, request.getRevision())
+                .thenAsync(new AsyncFunction<RoutingContext, List<List<Modification>>, ResourceException>() {
+                    @Override
+                    public Promise<List<List<Modification>>, ResourceException> apply(final RoutingContext dnAndType)
+                            throws ResourceException {
+                        dnAndTypeHolder.set(dnAndType);
+
+                        // Convert the patch operations to LDAP modifications.
+                        final List<Promise<List<Modification>, ResourceException>> promises =
+                                new ArrayList<>(request.getPatchOperations().size());
+                        final Resource subType = dnAndType.getType();
+                        final PropertyMapper propertyMapper = subType.getPropertyMapper();
+                        for (final PatchOperation operation : request.getPatchOperations()) {
+                            promises.add(propertyMapper.patch(connection, subType, ROOT, operation));
+                        }
+                        return when(promises);
+                    }
+                }).thenAsync(new AsyncFunction<List<List<Modification>>, ResourceResponse, ResourceException>() {
+                    @Override
+                    public Promise<ResourceResponse, ResourceException> apply(final List<List<Modification>> result)
+                            throws ResourceException {
+                        // The patch operations have been converted successfully.
+                        final RoutingContext dnAndType = dnAndTypeHolder.get();
+                        final ModifyRequest modifyRequest = newModifyRequest(dnAndType.getDn());
+
+                        // Add the modifications.
+                        for (final List<Modification> modifications : result) {
+                            if (modifications != null) {
+                                modifyRequest.getModifications().addAll(modifications);
+                            }
+                        }
+
+                        final Resource subType = dnAndType.getType();
+                        final Set<String> attributes = getLdapAttributesForKnownType(request.getFields(), subType);
+                        if (modifyRequest.getModifications().isEmpty()) {
+                            // This patch is a no-op so just read the entry and check its version.
+                            return connection.readEntryAsync(dnAndType.getDn(), attributes)
+                                             .thenAsync(encodeEmptyPatchResourceResponse(connection, subType, request),
+                                                        adaptLdapException(ResourceResponse.class));
+                        } else {
+                            // Add controls and perform the modify request.
+                            if (readOnUpdatePolicy == CONTROLS) {
+                                modifyRequest.addControl(PostReadRequestControl.newControl(false, attributes));
+                            }
+                            if (usePermissiveModify) {
+                                modifyRequest.addControl(PermissiveModifyRequestControl.newControl(true));
+                            }
+                            addAssertionControl(modifyRequest, request.getRevision());
+                            return connection.applyChangeAsync(modifyRequest)
+                                             .thenAsync(encodeUpdateResourceResponse(connection, subType),
+                                                        adaptLdapException(ResourceResponse.class));
+                        }
+                    }
+                });
+    }
+
+    private AsyncFunction<Entry, ResourceResponse, ResourceException> encodeEmptyPatchResourceResponse(
+            final Connection connection, final Resource resource, final PatchRequest request) {
+        return new AsyncFunction<Entry, ResourceResponse, ResourceException>() {
             @Override
-            public Promise<ResourceResponse, ResourceException> apply(SearchResultEntry entry)
-                    throws ResourceException {
+            public Promise<ResourceResponse, ResourceException> apply(Entry entry) throws ResourceException {
                 try {
-                    // Fail if there is a version mismatch.
                     ensureMvccVersionMatches(entry, request.getRevision());
-                    return adaptEntry(connection, entry);
+                    return encodeResourceResponse(connection, resource, entry);
                 } catch (final Exception e) {
-                    return Promises.newExceptionPromise(asResourceException(e));
+                    return asResourceException(e).asPromise();
                 }
             }
         };
     }
 
-    @Override
-    public Promise<QueryResponse, ResourceException> queryCollection(
+    Promise<QueryResponse, ResourceException> query(
             final Context context, final QueryRequest request, final QueryResourceHandler resourceHandler) {
-        final Connection connection = context.asContext(AuthenticatedConnectionContext.class).getConnection();
-        // Calculate the filter (this may require the connection).
+        final Connection connection = connectionFrom(context);
         return getLdapFilter(connection, request.getQueryFilter())
                 .thenAsync(runQuery(request, resourceHandler, connection));
     }
 
-    private Promise<Filter, ResourceException> getLdapFilter(final Connection connection,
-                                                             final QueryFilter<JsonPointer> queryFilter) {
+    // FIXME: supporting assertions against sub-type properties.
+    private Promise<Filter, ResourceException> getLdapFilter(
+            final Connection connection, final QueryFilter<JsonPointer> queryFilter) {
+        if (queryFilter == null) {
+            return new BadRequestException(ERR_QUERY_BY_ID_OR_EXPRESSION_NOT_SUPPORTED.get().toString()).asPromise();
+        }
+        final PropertyMapper propertyMapper = resource.getPropertyMapper();
         final QueryFilterVisitor<Promise<Filter, ResourceException>, Void, JsonPointer> visitor =
                 new QueryFilterVisitor<Promise<Filter, ResourceException>, Void, JsonPointer>() {
-
                     @Override
-                    public Promise<Filter, ResourceException> visitAndFilter(final Void unused,
-                            final List<QueryFilter<JsonPointer>> subFilters) {
+                    public Promise<Filter, ResourceException> visitAndFilter(
+                            final Void unused, final List<QueryFilter<JsonPointer>> subFilters) {
                         final List<Promise<Filter, ResourceException>> promises = new ArrayList<>(subFilters.size());
                         for (final QueryFilter<JsonPointer> subFilter : subFilters) {
                             promises.add(subFilter.accept(this, unused));
                         }
 
-                        return Promises.when(promises).then(new Function<List<Filter>, Filter, ResourceException>() {
+                        return when(promises).then(new Function<List<Filter>, Filter, ResourceException>() {
                             @Override
                             public Filter apply(final List<Filter> value) {
                                 // Check for unmapped filter components and optimize.
@@ -434,56 +519,58 @@
                     @Override
                     public Promise<Filter, ResourceException> visitBooleanLiteralFilter(
                             final Void unused, final boolean value) {
-                        return Promises.newResultPromise(toFilter(value));
+                        return newResultPromise(toFilter(value));
                     }
 
                     @Override
                     public Promise<Filter, ResourceException> visitContainsFilter(
                             final Void unused, final JsonPointer field, final Object valueAssertion) {
                         return propertyMapper.getLdapFilter(
-                                connection, new JsonPointer(), field, FilterType.CONTAINS, null, valueAssertion);
+                                connection, resource, ROOT, field, CONTAINS, null, valueAssertion);
                     }
 
                     @Override
                     public Promise<Filter, ResourceException> visitEqualsFilter(
                             final Void unused, final JsonPointer field, final Object valueAssertion) {
                         return propertyMapper.getLdapFilter(
-                                connection, new JsonPointer(), field, FilterType.EQUAL_TO, null, valueAssertion);
+                                connection, resource, ROOT, field, EQUAL_TO, null, valueAssertion);
                     }
 
                     @Override
                     public Promise<Filter, ResourceException> visitExtendedMatchFilter(final Void unused,
-                            final JsonPointer field, final String operator, final Object valueAssertion) {
+                                                                                       final JsonPointer field,
+                                                                                       final String operator,
+                                                                                       final Object valueAssertion) {
                         return propertyMapper.getLdapFilter(
-                                connection, new JsonPointer(), field, FilterType.EXTENDED, operator, valueAssertion);
+                                connection, resource, ROOT, field, EXTENDED, operator, valueAssertion);
                     }
 
                     @Override
                     public Promise<Filter, ResourceException> visitGreaterThanFilter(
                             final Void unused, final JsonPointer field, final Object valueAssertion) {
                         return propertyMapper.getLdapFilter(
-                                connection, new JsonPointer(), field, FilterType.GREATER_THAN, null, valueAssertion);
+                                connection, resource, ROOT, field, GREATER_THAN, null, valueAssertion);
                     }
 
                     @Override
                     public Promise<Filter, ResourceException> visitGreaterThanOrEqualToFilter(
                             final Void unused, final JsonPointer field, final Object valueAssertion) {
-                        return propertyMapper.getLdapFilter(connection, new JsonPointer(), field,
-                                                            FilterType.GREATER_THAN_OR_EQUAL_TO, null, valueAssertion);
+                        return propertyMapper.getLdapFilter(
+                                connection, resource, ROOT, field, GREATER_THAN_OR_EQUAL_TO, null, valueAssertion);
                     }
 
                     @Override
                     public Promise<Filter, ResourceException> visitLessThanFilter(
                             final Void unused, final JsonPointer field, final Object valueAssertion) {
                         return propertyMapper.getLdapFilter(
-                                connection, new JsonPointer(), field, FilterType.LESS_THAN, null, valueAssertion);
+                                connection, resource, ROOT, field, LESS_THAN, null, valueAssertion);
                     }
 
                     @Override
                     public Promise<Filter, ResourceException> visitLessThanOrEqualToFilter(
                             final Void unused, final JsonPointer field, final Object valueAssertion) {
-                        return propertyMapper.getLdapFilter(connection, new JsonPointer(), field,
-                                                            FilterType.LESS_THAN_OR_EQUAL_TO, null, valueAssertion);
+                        return propertyMapper.getLdapFilter(
+                                connection, resource, ROOT, field, LESS_THAN_OR_EQUAL_TO, null, valueAssertion);
                     }
 
                     @Override
@@ -504,14 +591,14 @@
                     }
 
                     @Override
-                    public Promise<Filter, ResourceException> visitOrFilter(final Void unused,
-                            final List<QueryFilter<JsonPointer>> subFilters) {
+                    public Promise<Filter, ResourceException> visitOrFilter(
+                            final Void unused, final List<QueryFilter<JsonPointer>> subFilters) {
                         final List<Promise<Filter, ResourceException>> promises = new ArrayList<>(subFilters.size());
                         for (final QueryFilter<JsonPointer> subFilter : subFilters) {
                             promises.add(subFilter.accept(this, unused));
                         }
 
-                        return Promises.when(promises).then(new Function<List<Filter>, Filter, ResourceException>() {
+                        return when(promises).then(new Function<List<Filter>, Filter, ResourceException>() {
                             @Override
                             public Filter apply(final List<Filter> value) {
                                 // Check for unmapped filter components and optimize.
@@ -539,30 +626,25 @@
                     @Override
                     public Promise<Filter, ResourceException> visitPresentFilter(
                             final Void unused, final JsonPointer field) {
-                        return propertyMapper.getLdapFilter(
-                                connection, new JsonPointer(), field, FilterType.PRESENT, null, null);
+                        return propertyMapper.getLdapFilter(connection, resource, ROOT, field, PRESENT, null, null);
                     }
 
                     @Override
                     public Promise<Filter, ResourceException> visitStartsWithFilter(
                             final Void unused, final JsonPointer field, final Object valueAssertion) {
                         return propertyMapper.getLdapFilter(
-                                connection, new JsonPointer(), field, FilterType.STARTS_WITH, null, valueAssertion);
+                                connection, resource, ROOT, field, STARTS_WITH, null, valueAssertion);
                     }
-
                 };
         // Note that the returned LDAP filter may be null if it could not be mapped by any property mappers.
         return queryFilter.accept(visitor, null);
     }
 
-    private AsyncFunction<Filter, QueryResponse, ResourceException> runQuery(final QueryRequest request,
-            final QueryResourceHandler resourceHandler, final Connection connection) {
+    private AsyncFunction<Filter, QueryResponse, ResourceException> runQuery(
+            final QueryRequest request, final QueryResourceHandler resourceHandler, final Connection connection) {
         return new AsyncFunction<Filter, QueryResponse, ResourceException>() {
-            /**
-             * The following fields are guarded by sequenceLock. In addition,
-             * the sequenceLock ensures that we send one JSON resource at a time
-             * back to the client.
-             */
+            // The following fields are guarded by sequenceLock. In addition, the sequenceLock ensures that
+            // we send one JSON resource at a time back to the client.
             private final Object sequenceLock = new Object();
             private String cookie;
             private ResourceException pendingResult;
@@ -574,18 +656,17 @@
             public Promise<QueryResponse, ResourceException> apply(final Filter ldapFilter) {
                 if (ldapFilter == null || ldapFilter == alwaysFalse()) {
                     // Avoid performing a search if the filter could not be mapped or if it will never match.
-                    return Promises.newResultPromise(Responses.newQueryResponse());
+                    return newQueryResponse().asPromise();
                 }
                 final PromiseImpl<QueryResponse, ResourceException> promise = PromiseImpl.create();
                 // Perform the search.
-                final String[] attributes = getLdapAttributes(connection, request.getFields());
+                final String[] attributes = getLdapAttributesForUnknownType(request.getFields()).toArray(new String[0]);
                 final Filter searchFilter = ldapFilter == Filter.alwaysTrue() ? Filter.objectClassPresent()
-                                                                              : ldapFilter;
-                final SearchRequest searchRequest = newSearchRequest(
-                        getBaseDn(), SearchScope.SINGLE_LEVEL, searchFilter, attributes);
+                        : ldapFilter;
+                final SearchRequest searchRequest = newSearchRequest(baseDn, SINGLE_LEVEL, searchFilter, attributes);
 
-                // Add the page results control. We can support the page offset by
-                // reading the next offset pages, or offset x page size resources.
+                // Add the page results control. We can support the page offset by reading the next offset pages, or
+                // offset x page size resources.
                 final int pageResultStartIndex;
                 final int pageSize = request.getPageSize();
                 if (request.getPageSize() > 0) {
@@ -634,37 +715,39 @@
                          * The best solution is probably to process the primary search results in batches using
                          * the paged results control.
                          */
-                        final String id = namingStrategy.getResourceId(connection, entry);
+                        final String id = namingStrategy.decodeResourceId(entry);
                         final String revision = getRevisionFromEntry(entry);
-                        propertyMapper.read(connection, new JsonPointer(), entry)
+                        final Resource subType = resource.resolveSubTypeFromObjectClasses(entry);
+                        final PropertyMapper propertyMapper = subType.getPropertyMapper();
+                        propertyMapper.read(connection, subType, ROOT, entry)
                                       .thenOnResult(new ResultHandler<JsonValue>() {
-                                           @Override
-                                           public void handleResult(final JsonValue result) {
-                                               synchronized (sequenceLock) {
-                                                   pendingResourceCount--;
-                                                   if (!resultSent) {
-                                                       resourceHandler.handleResource(
-                                                               Responses.newResourceResponse(id, revision, result));
-                                                   }
-                                                   completeIfNecessary(promise);
-                                               }
-                                           }
-                                       }).thenOnException(new ExceptionHandler<ResourceException>() {
-                                           @Override
-                                           public void handleException(ResourceException exception) {
-                                               synchronized (sequenceLock) {
-                                                   pendingResourceCount--;
-                                                   completeIfNecessary(exception, promise);
-                                               }
-                                           }
-                                       });
+                                          @Override
+                                          public void handleResult(final JsonValue result) {
+                                              synchronized (sequenceLock) {
+                                                  pendingResourceCount--;
+                                                  if (!resultSent) {
+                                                      resourceHandler.handleResource(
+                                                              newResourceResponse(id, revision, result));
+                                                  }
+                                                  completeIfNecessary(promise);
+                                              }
+                                          }
+                                      })
+                                      .thenOnException(new ExceptionHandler<ResourceException>() {
+                                          @Override
+                                          public void handleException(ResourceException exception) {
+                                              synchronized (sequenceLock) {
+                                                  pendingResourceCount--;
+                                                  completeIfNecessary(exception, promise);
+                                              }
+                                          }
+                                      });
                         return true;
                     }
 
                     @Override
                     public boolean handleReference(final SearchResultReference reference) {
-                        // TODO: should this be classed as an error since
-                        // rest2ldap assumes entries are all colocated?
+                        // TODO: should this be classed as an error since rest2ldap assumes entries are all colocated?
                         return true;
                     }
 
@@ -675,7 +758,7 @@
                             if (request.getPageSize() > 0) {
                                 try {
                                     final SimplePagedResultsControl control =
-                                            result.getControl(SimplePagedResultsControl.DECODER, DECODE_OPTIONS);
+                                            result.getControl(SimplePagedResultsControl.DECODER, decodeOptions);
                                     if (control != null && !control.getCookie().isEmpty()) {
                                         cookie = control.getCookie().toBase64String();
                                     }
@@ -688,9 +771,14 @@
                     }
                 }).thenOnException(new ExceptionHandler<LdapException>() {
                     @Override
-                    public void handleException(LdapException exception) {
+                    public void handleException(final LdapException e) {
                         synchronized (sequenceLock) {
-                            completeIfNecessary(asResourceException(exception), promise);
+                            if (glueObjectClasses != null && e instanceof EntryNotFoundException) {
+                                // Glue entry does not exist, so treat this as an empty result set.
+                                completeIfNecessary(SUCCESS, promise);
+                            } else {
+                                completeIfNecessary(asResourceException(e), promise);
+                            }
                         }
                     }
                 });
@@ -715,7 +803,7 @@
             private void completeIfNecessary(final PromiseImpl<QueryResponse, ResourceException> handler) {
                 if (pendingResourceCount == 0 && pendingResult != null && !resultSent) {
                     if (pendingResult == SUCCESS) {
-                        handler.handleResult(Responses.newQueryResponse(cookie));
+                        handler.handleResult(newQueryResponse(cookie));
                     } else {
                         handler.handleException(pendingResult);
                     }
@@ -725,93 +813,81 @@
         };
     }
 
-    @Override
-    public Promise<ResourceResponse, ResourceException> readInstance(
+    Promise<ResourceResponse, ResourceException> read(
             final Context context, final String resourceId, final ReadRequest request) {
-        final Connection connection = context.asContext(AuthenticatedConnectionContext.class).getConnection();
-        // Do the search.
-        SearchRequest searchRequest = searchRequest(connection, resourceId, request.getFields());
-        return connection
-                .searchSingleEntryAsync(searchRequest)
-                .thenAsync(new AsyncFunction<SearchResultEntry, ResourceResponse, ResourceException>() {
-                    @Override
-                    public Promise<ResourceResponse, ResourceException> apply(SearchResultEntry entry)
-                            throws ResourceException {
-                        return adaptEntry(connection, entry);
-                    }
-                }, Exceptions.<ResourceResponse>toResourceException());
+        final Connection connection = connectionFrom(context);
+        return connection.searchSingleEntryAsync(searchRequestForUnknownType(resourceId, request.getFields()))
+                         .thenCatchAsync(adaptLdapException(SearchResultEntry.class))
+                         .thenAsync(new AsyncFunction<SearchResultEntry, ResourceResponse, ResourceException>() {
+                             @Override
+                             public Promise<ResourceResponse, ResourceException> apply(SearchResultEntry entry) {
+                                 final Resource subType = resource.resolveSubTypeFromObjectClasses(entry);
+                                 return encodeResourceResponse(connection, subType, entry);
+                             }
+                         });
     }
 
-    @Override
-    public Promise<ResourceResponse, ResourceException> updateInstance(
+    Promise<ResourceResponse, ResourceException> update(
             final Context context, final String resourceId, final UpdateRequest request) {
-        final Connection connection = context.asContext(AuthenticatedConnectionContext.class).getConnection();
-        List<JsonPointer> attrs = Collections.emptyList();
-        SearchRequest searchRequest = searchRequest(connection, resourceId, attrs);
+        final Connection connection = connectionFrom(context);
+        final AtomicReference<Entry> entryHolder = new AtomicReference<>();
+        final AtomicReference<Resource> subTypeHolder = new AtomicReference<>();
         return connection
-                .searchSingleEntryAsync(searchRequest)
-                .thenAsync(new AsyncFunction<SearchResultEntry, ResourceResponse, ResourceException>() {
+                .searchSingleEntryAsync(searchRequestForUnknownType(resourceId, Collections.<JsonPointer>emptyList()))
+                .thenCatchAsync(adaptLdapException(SearchResultEntry.class))
+                .thenAsync(new AsyncFunction<SearchResultEntry, List<Modification>, ResourceException>() {
                     @Override
-                    public Promise<ResourceResponse, ResourceException> apply(
-                            final SearchResultEntry entry) {
-                        try {
-                            // Fail-fast if there is a version mismatch.
-                            ensureMvccVersionMatches(entry, request.getRevision());
+                    public Promise<List<Modification>, ResourceException> apply(final SearchResultEntry entry)
+                            throws ResourceException {
+                        entryHolder.set(entry);
 
-                            // Create the modify request.
-                            final ModifyRequest modifyRequest = newModifyRequest(entry.getName());
-                            if (config.readOnUpdatePolicy() == CONTROLS) {
-                                final String[] attributes =
-                                        getLdapAttributes(connection, request.getFields());
-                                modifyRequest.addControl(
-                                        PostReadRequestControl.newControl(false, attributes));
-                            }
-                            if (config.usePermissiveModify()) {
-                                modifyRequest.addControl(
-                                        PermissiveModifyRequestControl.newControl(true));
-                            }
-                            addAssertionControl(modifyRequest, request.getRevision());
+                        // Fail-fast if there is a version mismatch.
+                        ensureMvccVersionMatches(entry, request.getRevision());
 
-                            // Determine the set of changes that need to be performed.
-                            return propertyMapper.update(
-                                    connection, new JsonPointer(), entry, request.getContent())
-                                                 .thenAsync(new AsyncFunction<
-                                            List<Modification>, ResourceResponse, ResourceException>() {
-                                        @Override
-                                        public Promise<ResourceResponse, ResourceException> apply(
-                                                List<Modification> modifications)
-                                                throws ResourceException {
-                                            if (modifications.isEmpty()) {
-                                                // No changes to be performed so just return
-                                                // the entry that we read.
-                                                return adaptEntry(connection, entry);
-                                            }
-                                            // Perform the modify operation.
-                                            modifyRequest.getModifications().addAll(modifications);
-                                            return connection
-                                                    .applyChangeAsync(modifyRequest)
-                                                    .thenAsync(
-                                                            postUpdateResultAsyncFunction(connection),
-                                                            Exceptions.<ResourceResponse>toResourceException());
-                                        }
-                                    });
-                        } catch (final Exception e) {
-                            return Promises.newExceptionPromise(asResourceException(e));
-                        }
+                        // Determine the type of resource and set of changes that need to be performed.
+                        final Resource subType = resource.resolveSubTypeFromObjectClasses(entry);
+                        subTypeHolder.set(subType);
+                        final PropertyMapper propertyMapper = subType.getPropertyMapper();
+                        return propertyMapper.update(connection, subType , ROOT, entry, request.getContent());
                     }
-                }, Exceptions.<ResourceResponse>toResourceException());
+                }).thenAsync(new AsyncFunction<List<Modification>, ResourceResponse, ResourceException>() {
+                    @Override
+                    public Promise<ResourceResponse, ResourceException> apply(List<Modification> modifications)
+                            throws ResourceException {
+                        final Resource subType = subTypeHolder.get();
+                        if (modifications.isEmpty()) {
+                            // No changes to be performed so just return the entry that we read.
+                            return encodeResourceResponse(connection, subType, entryHolder.get());
+                        }
+                        // Perform the modify operation.
+                        final ModifyRequest modifyRequest = newModifyRequest(entryHolder.get().getName());
+                        if (readOnUpdatePolicy == CONTROLS) {
+                            final Set<String> attributes = getLdapAttributesForKnownType(request.getFields(), subType);
+                            modifyRequest.addControl(PostReadRequestControl.newControl(false, attributes));
+                        }
+                        if (usePermissiveModify) {
+                            modifyRequest.addControl(PermissiveModifyRequestControl.newControl(true));
+                        }
+                        addAssertionControl(modifyRequest, request.getRevision());
+                        modifyRequest.getModifications().addAll(modifications);
+                        return connection.applyChangeAsync(modifyRequest)
+                                         .thenAsync(encodeUpdateResourceResponse(connection, subType),
+                                                    adaptLdapException(ResourceResponse.class));
+                    }
+                });
     }
 
-    private Promise<ResourceResponse, ResourceException> adaptEntry(final Connection connection, final Entry entry) {
-        final String actualResourceId = namingStrategy.getResourceId(connection, entry);
-        final String revision = getRevisionFromEntry(entry);
-        return propertyMapper.read(connection, new JsonPointer(), entry)
+    private Promise<ResourceResponse, ResourceException> encodeResourceResponse(
+            final Connection connection, final Resource resource, final Entry entry) {
+        final PropertyMapper propertyMapper = resource.getPropertyMapper();
+        return propertyMapper.read(connection, resource, ROOT, entry)
                              .then(new Function<JsonValue, ResourceResponse, ResourceException>() {
-                                  @Override
-                                  public ResourceResponse apply(final JsonValue value) {
-                                      return newResourceResponse(
-                                              actualResourceId, revision, new JsonValue(value));
-                                  }
+                                 @Override
+                                 public ResourceResponse apply(final JsonValue value) {
+                                     final String revision = getRevisionFromEntry(entry);
+                                     final String actualResourceId = namingStrategy.decodeResourceId(entry);
+                                     return newResourceResponse(actualResourceId, revision, new JsonValue(value));
+                                 }
                              });
     }
 
@@ -819,40 +895,34 @@
             throws ResourceException {
         if (expectedRevision != null) {
             ensureMvccSupported();
-            request.addControl(AssertionRequestControl.newControl(true, Filter.equality(
-                    etagAttribute.toString(), expectedRevision)));
+            final Filter filter = Filter.equality(etagAttribute.toString(), expectedRevision);
+            request.addControl(AssertionRequestControl.newControl(true, filter));
         }
     }
 
-    private Promise<DN, ResourceException> doUpdateFunction(final Connection connection, final String resourceId,
-            final String revision) {
-        final String ldapAttribute = (etagAttribute != null && revision != null) ? etagAttribute.toString() : "1.1";
-        final SearchRequest searchRequest = namingStrategy.createSearchRequest(connection, getBaseDn(), resourceId)
-                                                          .addAttribute(ldapAttribute);
-        if (searchRequest.getScope().equals(SearchScope.BASE_OBJECT)) {
-            // There's no point in doing a search because we already know the DN.
-            return Promises.newResultPromise(searchRequest.getName());
+    private Promise<RoutingContext, ResourceException> resolveResourceDnAndType(
+            final Context context, final Connection connection, final String resourceId, final String revision) {
+        final SearchRequest searchRequest = namingStrategy.createSearchRequest(baseDn, resourceId);
+        if (searchRequest.getScope().equals(BASE_OBJECT) && !resource.hasSubTypes()) {
+            // There's no point in doing a search because we already know the DN and sub-resources.
+            return newResultPromise(new RoutingContext(context, searchRequest.getName(), resource));
         }
-        return connection
-                .searchSingleEntryAsync(searchRequest)
-                .thenAsync(new AsyncFunction<SearchResultEntry, DN, ResourceException>() {
-                    @Override
-                    public Promise<DN, ResourceException> apply(SearchResultEntry entry) throws ResourceException {
-                        try {
-                            // Fail-fast if there is a version mismatch.
-                            ensureMvccVersionMatches(entry, revision);
-                            // Perform update operation.
-                            return Promises.newResultPromise(entry.getName());
-                        } catch (final Exception e) {
-                            return Promises.newExceptionPromise(asResourceException(e));
-                        }
-                    }
-                }, new AsyncFunction<LdapException, DN, ResourceException>() {
-                    @Override
-                    public Promise<DN, ResourceException> apply(LdapException ldapException) throws ResourceException {
-                        return Promises.newExceptionPromise(asResourceException(ldapException));
-                    }
-                });
+        if (etagAttribute != null && revision != null) {
+            searchRequest.addAttribute(etagAttribute.toString());
+        }
+        // The resource type will be resolved from the LDAP entry's objectClass.
+        searchRequest.addAttribute("objectClass");
+        return connection.searchSingleEntryAsync(searchRequest)
+                         .thenAsync(new AsyncFunction<SearchResultEntry, RoutingContext, ResourceException>() {
+                             @Override
+                             public Promise<RoutingContext, ResourceException> apply(final SearchResultEntry entry)
+                                     throws ResourceException {
+                                 // Fail-fast if there is a version mismatch.
+                                 ensureMvccVersionMatches(entry, revision);
+                                 final Resource subType = resource.resolveSubTypeFromObjectClasses(entry);
+                                 return newResultPromise(new RoutingContext(context, entry.getName(), subType));
+                             }
+                         }, adaptLdapException(RoutingContext.class));
     }
 
     private void ensureMvccSupported() throws NotSupportedException {
@@ -874,101 +944,92 @@
         }
     }
 
-    private DN getBaseDn() {
-        return baseDn;
+    private Set<String> getLdapAttributesForUnknownType(final Collection<JsonPointer> fields) {
+        final Set<String> ldapAttributes = getLdapAttributesForKnownType(fields, resource);
+        getLdapAttributesForUnknownType(fields, resource, ldapAttributes);
+        return ldapAttributes;
     }
 
-    /**
-     * Determines the set of LDAP attributes to request in an LDAP read (search,
-     * post-read), based on the provided list of JSON pointers.
-     *
-     * @param connection
-     *          The request state.
-     * @param requestedAttributes
-     *          The list of resource attributes to be read.
-     * @return The set of LDAP attributes associated with the resource
-     *         attributes.
-     */
-    private String[] getLdapAttributes(final Connection connection, final Collection<JsonPointer> requestedAttributes) {
-        // Get all the LDAP attributes required by the property mappers.
-        final Set<String> requestedLDAPAttributes;
-        if (requestedAttributes.isEmpty()) {
+    private void getLdapAttributesForUnknownType(final Collection<JsonPointer> fields, final Resource resource,
+                                                 final Set<String> ldapAttributes) {
+        for (final Resource subType : resource.getSubTypes()) {
+            addLdapAttributesForFields(fields, subType, ldapAttributes);
+            getLdapAttributesForUnknownType(fields, subType, ldapAttributes);
+        }
+    }
+
+    private Set<String> getLdapAttributesForKnownType(final Collection<JsonPointer> fields, final Resource resource) {
+        // Includes the LDAP attributes required by the type, etag, and name strategies.
+        final Set<String> ldapAttributes = new LinkedHashSet<>();
+        ldapAttributes.add("objectClass");
+        final String resourceIdLdapAttribute = namingStrategy.getResourceIdLdapAttribute();
+        if (resourceIdLdapAttribute != null) {
+            ldapAttributes.add(resourceIdLdapAttribute);
+        }
+        if (etagAttribute != null) {
+            ldapAttributes.add(etagAttribute.toString());
+        }
+        addLdapAttributesForFields(fields, resource, ldapAttributes);
+        return ldapAttributes;
+    }
+
+    /** Includes the LDAP attributes required for the specified JSON fields for all sub-types. */
+    private void addLdapAttributesForFields(final Collection<JsonPointer> fields, final Resource resource,
+                                            final Set<String> ldapAttributes) {
+        final PropertyMapper propertyMapper = resource.getPropertyMapper();
+        if (fields.isEmpty()) {
             // Full read.
-            requestedLDAPAttributes = new LinkedHashSet<>();
-            propertyMapper.getLdapAttributes(connection, new JsonPointer(), new JsonPointer(),
-                                             requestedLDAPAttributes);
+            propertyMapper.getLdapAttributes(ROOT, ROOT, ldapAttributes);
         } else {
             // Partial read.
-            requestedLDAPAttributes = new LinkedHashSet<>(requestedAttributes.size());
-            for (final JsonPointer requestedAttribute : requestedAttributes) {
-                propertyMapper.getLdapAttributes(connection, new JsonPointer(), requestedAttribute,
-                                                 requestedLDAPAttributes);
+            for (final JsonPointer field : fields) {
+                propertyMapper.getLdapAttributes(ROOT, field, ldapAttributes);
             }
         }
-
-        // Get the LDAP attributes required by the Etag and name stategies.
-        namingStrategy.getLdapAttributes(connection, requestedLDAPAttributes);
-        if (etagAttribute != null) {
-            requestedLDAPAttributes.add(etagAttribute.toString());
-        }
-        return requestedLDAPAttributes.toArray(new String[requestedLDAPAttributes.size()]);
     }
 
     private String getRevisionFromEntry(final Entry entry) {
         return etagAttribute != null ? entry.parseAttribute(etagAttribute).asString() : null;
     }
 
-    private AsyncFunction<Result, ResourceResponse, ResourceException> postUpdateResultAsyncFunction(
-            final Connection connection) {
-        // The handler which will be invoked for the LDAP add result.
+    private AsyncFunction<Result, ResourceResponse, ResourceException> encodeUpdateResourceResponse(
+            final Connection connection, final Resource resource) {
         return new AsyncFunction<Result, ResourceResponse, ResourceException>() {
             @Override
-            public Promise<ResourceResponse, ResourceException> apply(Result result) throws ResourceException {
+            public Promise<ResourceResponse, ResourceException> apply(Result result) {
                 // FIXME: handle USE_SEARCH policy.
-                Entry entry;
                 try {
                     final PostReadResponseControl postReadControl =
-                        result.getControl(PostReadResponseControl.DECODER, config.decodeOptions());
+                            result.getControl(PostReadResponseControl.DECODER, decodeOptions);
                     if (postReadControl != null) {
-                        entry = postReadControl.getEntry();
-                    } else {
-                        final PreReadResponseControl preReadControl =
-                            result.getControl(PreReadResponseControl.DECODER, config.decodeOptions());
-                        if (preReadControl != null) {
-                            entry = preReadControl.getEntry();
-                        } else {
-                            entry = null;
-                        }
+                        return encodeResourceResponse(connection, resource, postReadControl.getEntry());
+                    }
+                    final PreReadResponseControl preReadControl =
+                            result.getControl(PreReadResponseControl.DECODER, decodeOptions);
+                    if (preReadControl != null) {
+                        return encodeResourceResponse(connection, resource, preReadControl.getEntry());
                     }
                 } catch (final DecodeException e) {
                     logger.error(ERR_DECODING_CONTROL.get(e.getLocalizedMessage()), e);
-                    entry = null;
                 }
-                if (entry != null) {
-                    return adaptEntry(connection, entry);
-                } else {
-                    return Promises.newResultPromise(
-                            newResourceResponse(null, null, new JsonValue(Collections.emptyMap())));
-                }
+                // Return an empty resource response.
+                return newResourceResponse(null, null, new JsonValue(Collections.emptyMap())).asPromise();
             }
         };
     }
 
-    private SearchRequest searchRequest(
-            final Connection connection, final String resourceId, final List<JsonPointer> requestedAttributes) {
-        final String[] attributes = getLdapAttributes(connection, requestedAttributes);
-        return namingStrategy.createSearchRequest(connection, getBaseDn(), resourceId).addAttribute(attributes);
+    private SearchRequest searchRequestForUnknownType(final String resourceId, final List<JsonPointer> fields) {
+        final String[] attributes = getLdapAttributesForUnknownType(fields).toArray(new String[0]);
+        return namingStrategy.createSearchRequest(baseDn, resourceId).addAttribute(attributes);
     }
 
-    private static final class Exceptions {
-        private static <R> AsyncFunction<LdapException, R, ResourceException> toResourceException() {
-            // The handler which will be invoked for the LDAP add result.
-            return new AsyncFunction<LdapException, R, ResourceException>() {
-                @Override
-                public Promise<R, ResourceException> apply(final LdapException ldapException) throws ResourceException {
-                    return Promises.newExceptionPromise(asResourceException(ldapException));
-                }
-            };
-        }
+    @SuppressWarnings("unused")
+    private static <R> AsyncFunction<LdapException, R, ResourceException> adaptLdapException(final Class<R> clazz) {
+        return new AsyncFunction<LdapException, R, ResourceException>() {
+            @Override
+            public Promise<R, ResourceException> apply(final LdapException ldapException) {
+                return asResourceException(ldapException).asPromise();
+            }
+        };
     }
 }
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceSingleton.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceSingleton.java
new file mode 100644
index 0000000..d0dd95c
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/SubResourceSingleton.java
@@ -0,0 +1,292 @@
+/*
+ * 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 2016 ForgeRock AS.
+ *
+ */
+package org.forgerock.opendj.rest2ldap;
+
+import static org.forgerock.http.routing.RoutingMode.EQUALS;
+import static org.forgerock.http.routing.RoutingMode.STARTS_WITH;
+import static org.forgerock.json.resource.RouteMatchers.requestUriMatcher;
+import static org.forgerock.opendj.ldap.Filter.objectClassPresent;
+import static org.forgerock.opendj.ldap.SearchScope.BASE_OBJECT;
+import static org.forgerock.opendj.ldap.requests.Requests.newSearchRequest;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_UNSUPPORTED_REQUEST_AGAINST_SINGLETON;
+import static org.forgerock.util.promise.Promises.newResultPromise;
+
+import org.forgerock.json.resource.ActionRequest;
+import org.forgerock.json.resource.ActionResponse;
+import org.forgerock.json.resource.BadRequestException;
+import org.forgerock.json.resource.CreateRequest;
+import org.forgerock.json.resource.DeleteRequest;
+import org.forgerock.json.resource.PatchRequest;
+import org.forgerock.json.resource.QueryRequest;
+import org.forgerock.json.resource.QueryResourceHandler;
+import org.forgerock.json.resource.QueryResponse;
+import org.forgerock.json.resource.ReadRequest;
+import org.forgerock.json.resource.RequestHandler;
+import org.forgerock.json.resource.ResourceException;
+import org.forgerock.json.resource.ResourceResponse;
+import org.forgerock.json.resource.Router;
+import org.forgerock.json.resource.UpdateRequest;
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.Entry;
+import org.forgerock.opendj.ldap.requests.SearchRequest;
+import org.forgerock.services.context.Context;
+import org.forgerock.util.AsyncFunction;
+import org.forgerock.util.Function;
+import org.forgerock.util.promise.Promise;
+
+/**
+ * Represents a one to one relationship between a parent resource and a child sub-resource. Removal of the parent
+ * resource implies that the child (the sub-resource) is also removed. Singletons only support read, update, patch, and
+ * action requests.
+ */
+public final class SubResourceSingleton extends SubResource {
+    /**
+     * A simple naming strategy that allows singletons to use the same processing logic as collections. The passed in
+     * resource ID will always be {@code null}.
+     */
+    private static final NamingStrategy SINGLETON_NAMING_STRATEGY = new NamingStrategy() {
+        @Override
+        public SearchRequest createSearchRequest(final DN baseDn, final String resourceId) {
+            return newSearchRequest(baseDn, BASE_OBJECT, objectClassPresent());
+        }
+
+        @Override
+        public String getResourceIdLdapAttribute() {
+            // Nothing to do.
+            return null;
+        }
+
+        @Override
+        public String decodeResourceId(final Entry entry) {
+            // It's safe to return null. The resource response will default to the _id field if present.
+            return null;
+        }
+
+        @Override
+        public void encodeResourceId(final DN baseDn, final String resourceId, final Entry entry)
+                throws ResourceException {
+            // Nothing to do because singletons cannot be created.
+        }
+    };
+
+    SubResourceSingleton(final String resourceId) {
+        super(resourceId);
+    }
+
+    /**
+     * Sets the relative URL template of the single sub-resource. The template must comprise of at least one path
+     * element. Any URL template variables will be substituted into the {@link #dnTemplate(String) DN template}.
+     *
+     * @param urlTemplate
+     *         The relative URL template.
+     * @return A reference to this object.
+     */
+    public SubResourceSingleton urlTemplate(final String urlTemplate) {
+        this.urlTemplate = urlTemplate;
+        return this;
+    }
+
+    /**
+     * Sets the relative DN template of the single sub-resource LDAP entry. The template must comprise of at least one
+     * RDN. Any DN template variables will be substituted using values extracted from the {@link #urlTemplate(String)
+     * URL template}.
+     *
+     * @param dnTemplate
+     *         The relative DN template.
+     * @return A reference to this object.
+     */
+    public SubResourceSingleton dnTemplate(final String dnTemplate) {
+        this.dnTemplate = dnTemplate;
+        return this;
+    }
+
+    /**
+     * Indicates whether this sub-resource singleton only supports read operations.
+     *
+     * @param readOnly
+     *         {@code true} if this sub-resource singleton is read-only.
+     * @return A reference to this object.
+     */
+    public SubResourceSingleton isReadOnly(final boolean readOnly) {
+        isReadOnly = readOnly;
+        return this;
+    }
+
+    @Override
+    Router addRoutes(final Router router) {
+        router.addRoute(requestUriMatcher(EQUALS, urlTemplate), readOnly(new InstanceHandler()));
+        router.addRoute(requestUriMatcher(STARTS_WITH, urlTemplate), readOnly(new SubResourceHandler()));
+        return router;
+    }
+
+    private Promise<RoutingContext, ResourceException> route(final Context context) {
+        return newResultPromise(new RoutingContext(context, dnFrom(context), resource));
+    }
+
+    private SubResourceImpl singleton(final Context context) {
+        return new SubResourceImpl(rest2Ldap, dnFrom(context), null, SINGLETON_NAMING_STRATEGY, resource);
+    }
+
+    /**
+     * Responsible for processing instance requests (RUPA) against this singleton and collection requests (CQ) to
+     * any collections sharing the same base URL as this singleton. More specifically, given the
+     * URL template /singleton/{child} then this handler processes requests against /singleton since it is
+     * both a singleton and also a collection of {child}.
+     */
+    private final class InstanceHandler extends AbstractRequestHandler {
+        private InstanceHandler() {
+            super(new BadRequestException(ERR_UNSUPPORTED_REQUEST_AGAINST_SINGLETON.get().toString()));
+        }
+
+        @Override
+        public Promise<ActionResponse, ResourceException> handleAction(final Context context,
+                                                                       final ActionRequest request) {
+            return singleton(context).action(context, null, request);
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleCreate(final Context context,
+                                                                         final CreateRequest request) {
+            return route(context)
+                    .thenAsync(new AsyncFunction<RoutingContext, ResourceResponse, ResourceException>() {
+                        @Override
+                        public Promise<ResourceResponse, ResourceException> apply(final RoutingContext context) {
+                            return subResourceRouterFrom(context).handleCreate(context, request);
+                        }
+                    }).thenCatch(this.<ResourceResponse>convert404To400());
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handlePatch(final Context context,
+                                                                        final PatchRequest request) {
+            return singleton(context).patch(context, null, request);
+        }
+
+        @Override
+        public Promise<QueryResponse, ResourceException> handleQuery(final Context context, final QueryRequest request,
+                                                                     final QueryResourceHandler handler) {
+            return route(context)
+                    .thenAsync(new AsyncFunction<RoutingContext, QueryResponse, ResourceException>() {
+                        @Override
+                        public Promise<QueryResponse, ResourceException> apply(final RoutingContext context) {
+                            return subResourceRouterFrom(context).handleQuery(context, request, handler);
+                        }
+                    }).thenCatch(this.<QueryResponse>convert404To400());
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleRead(final Context context,
+                                                                       final ReadRequest request) {
+            return singleton(context).read(context, null, request);
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleUpdate(final Context context,
+                                                                         final UpdateRequest request) {
+            return singleton(context).update(context, null, request);
+        }
+
+        private <T> Function<ResourceException, T, ResourceException> convert404To400() {
+            return SubResource.convert404To400(ERR_UNSUPPORTED_REQUEST_AGAINST_SINGLETON.get());
+        }
+    }
+
+
+
+    /**
+     * Responsible for routing requests to sub-resources of this singleton. More specifically, given
+     * the URL template /singleton then this handler processes all requests beneath /singleton.
+     */
+    private final class SubResourceHandler implements RequestHandler {
+        @Override
+        public Promise<ActionResponse, ResourceException> handleAction(final Context context,
+                                                                       final ActionRequest request) {
+            return route(context).thenAsync(new AsyncFunction<RoutingContext, ActionResponse, ResourceException>() {
+                @Override
+                public Promise<ActionResponse, ResourceException> apply(final RoutingContext context) {
+                    return subResourceRouterFrom(context).handleAction(context, request);
+                }
+            });
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleCreate(final Context context,
+                                                                         final CreateRequest request) {
+            return route(context).thenAsync(new AsyncFunction<RoutingContext, ResourceResponse, ResourceException>() {
+                @Override
+                public Promise<ResourceResponse, ResourceException> apply(final RoutingContext context) {
+                    return subResourceRouterFrom(context).handleCreate(context, request);
+                }
+            });
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleDelete(final Context context,
+                                                                         final DeleteRequest request) {
+            return route(context).thenAsync(new AsyncFunction<RoutingContext, ResourceResponse, ResourceException>() {
+                @Override
+                public Promise<ResourceResponse, ResourceException> apply(final RoutingContext context) {
+                    return subResourceRouterFrom(context).handleDelete(context, request);
+                }
+            });
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handlePatch(final Context context,
+                                                                        final PatchRequest request) {
+            return route(context).thenAsync(new AsyncFunction<RoutingContext, ResourceResponse, ResourceException>() {
+                @Override
+                public Promise<ResourceResponse, ResourceException> apply(final RoutingContext context) {
+                    return subResourceRouterFrom(context).handlePatch(context, request);
+                }
+            });
+        }
+
+        @Override
+        public Promise<QueryResponse, ResourceException> handleQuery(final Context context, final QueryRequest request,
+                                                                     final QueryResourceHandler handler) {
+            return route(context).thenAsync(new AsyncFunction<RoutingContext, QueryResponse, ResourceException>() {
+                @Override
+                public Promise<QueryResponse, ResourceException> apply(final RoutingContext context) {
+                    return subResourceRouterFrom(context).handleQuery(context, request, handler);
+                }
+            });
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleRead(final Context context,
+                                                                       final ReadRequest request) {
+            return route(context).thenAsync(new AsyncFunction<RoutingContext, ResourceResponse, ResourceException>() {
+                @Override
+                public Promise<ResourceResponse, ResourceException> apply(final RoutingContext context) {
+                    return subResourceRouterFrom(context).handleRead(context, request);
+                }
+            });
+        }
+
+        @Override
+        public Promise<ResourceResponse, ResourceException> handleUpdate(final Context context,
+                                                                         final UpdateRequest request) {
+            return route(context).thenAsync(new AsyncFunction<RoutingContext, ResourceResponse, ResourceException>() {
+                @Override
+                public Promise<ResourceResponse, ResourceException> apply(final RoutingContext context) {
+                    return subResourceRouterFrom(context).handleUpdate(context, request);
+                }
+            });
+        }
+    }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Utils.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Utils.java
index a3038c0..5ace36c 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Utils.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Utils.java
@@ -27,11 +27,6 @@
 import static org.forgerock.opendj.ldap.schema.CoreSchema.getIntegerSyntax;
 import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_UNRECOGNIZED_JSON_VALUE;
 
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileReader;
-import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Locale;
@@ -73,12 +68,6 @@
                 }
             };
 
-    static String readPasswordFromFile(String fileName) throws IOException {
-        try (final BufferedReader reader = new BufferedReader(new FileReader(new File(fileName)))) {
-            return reader.readLine();
-        }
-    }
-
     static Function<Object, ByteString, NeverThrowsException> base64ToByteString() {
         return BASE64_TO_BYTESTRING;
     }
@@ -106,19 +95,6 @@
         };
     }
 
-    /**
-     * Stub formatter for i18n strings.
-     *
-     * @param format
-     *            The format string.
-     * @param args
-     *            The string arguments.
-     * @return The formatted string.
-     */
-    static String i18n(final String format, final Object... args) {
-        return String.format(format, args);
-    }
-
     private static boolean isJsonPrimitive(final Object value) {
         return value instanceof String || value instanceof Boolean || value instanceof Number;
     }
@@ -138,7 +114,7 @@
             }
             return a;
         } else {
-            throw newLocalizedIllegalArgumentException(ERR_UNRECOGNIZED_JSON_VALUE.get(value.getClass().getName()));
+            throw new LocalizedIllegalArgumentException(ERR_UNRECOGNIZED_JSON_VALUE.get(value.getClass().getName()));
         }
     }
 
@@ -154,8 +130,8 @@
                         return ByteString.valueOfObject(value);
                     }
                 } else {
-                    throw newLocalizedIllegalArgumentException(
-                            ERR_UNRECOGNIZED_JSON_VALUE.get(value.getClass().getName()));
+                    throw new LocalizedIllegalArgumentException(ERR_UNRECOGNIZED_JSON_VALUE.get(value.getClass()
+                                                                                                     .getName()));
                 }
             }
         };
@@ -201,10 +177,6 @@
         return new JsonValueException(value, message.toString());
     }
 
-    static LocalizedIllegalArgumentException newLocalizedIllegalArgumentException(final LocalizableMessage message) {
-        return new LocalizedIllegalArgumentException(message);
-    }
-
     static BadRequestException newBadRequestException(final LocalizableMessage message) {
         return newBadRequestException(message, null);
     }
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java
index 433d4f2..27c586f 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Utils.java
@@ -33,6 +33,13 @@
 import org.forgerock.util.promise.Promises;
 
 final class Utils {
+    private static final AsyncFunction<LdapException, Response, NeverThrowsException> HANDLE_CONNECTION_FAILURE =
+            new AsyncFunction<LdapException, Response, NeverThrowsException>() {
+                @Override
+                public Promise<Response, NeverThrowsException> apply(final LdapException exception) {
+                    return asErrorResponse(exception);
+                }
+            };
 
     private Utils() { }
 
@@ -58,19 +65,13 @@
     }
 
     static AsyncFunction<LdapException, Response, NeverThrowsException> handleConnectionFailure() {
-        return new AsyncFunction<LdapException, Response, NeverThrowsException>() {
-            @Override
-            public Promise<Response, NeverThrowsException> apply(final LdapException exception) {
-                return asErrorResponse(exception);
-            }
-        };
+        return HANDLE_CONNECTION_FAILURE;
     }
 
     static Promise<Response, NeverThrowsException> asErrorResponse(final Throwable t) {
         final ResourceException e = asResourceException(t);
-        final Response response = new Response()
-                .setStatus(Status.valueOf(e.getCode()))
-                .setEntity(e.toJsonValue().getObject());
+        final Response response = new Response().setStatus(Status.valueOf(e.getCode()))
+                                                .setEntity(e.toJsonValue() .getObject());
         if (response.getStatus() == Status.UNAUTHORIZED) {
             response.getHeaders().put("WWW-Authenticate", "Basic");
         }
diff --git a/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties b/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
index bc38afb..befdf97 100644
--- a/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
+++ b/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
@@ -97,7 +97,7 @@
 ERR_ENCODING_VALUES_FOR_FIELD_52=The request cannot be processed because an error occurred while encoding \
  the values for the field '%s': '%s'
 ERR_UNRECOGNIZED_JSON_VALUE_53=Unrecognized type of JSON value: '%s'
-ERR_CLIENT_PROVIDER_RESOURCE_ID_MISSING_54=Resources cannot be created without a client provided resource ID
+ERR_CLIENT_PROVIDED_RESOURCE_ID_MISSING_54=Resources cannot be created without a client provided resource ID
 ERR_NOT_YET_IMPLEMENTED_55=Not yet implemented
 ERR_ACTION_NOT_SUPPORTED_56=The action '%s' is not supported
 ERR_PASSWORD_MODIFY_SECURE_CONNECTION_57=Password modify requires a secure connection
@@ -109,3 +109,30 @@
  A password modify request may contain two string valued fields 'oldPassword' and 'newPassword'
 ERR_CONFIG_INVALID_TRUST_MANAGER_63=The trust-manager defined in '%s' is invalid: %s
 ERR_CONFIG_INVALID_KEY_MANAGER_64=The key-manager defined in '%s' is invalid: %s
+ERR_MISSING_TYPE_PROPERTY_IN_CREATE_65=The resource cannot be created because it does not contain the \
+  type property '%s'
+ERR_UNRECOGNIZED_TYPE_IN_CREATE_66=The resource cannot be created because it specified an unrecognized resource \
+  type '%s'. Please specify one of the following types: %s
+ERR_ABSTRACT_TYPE_IN_CREATE_67=The resource cannot be created because it specified the abstract resource type '%s'. \
+  Please specify one of the following non-abstract types: %s
+ERR_QUERY_BY_ID_OR_EXPRESSION_NOT_SUPPORTED_68=Queries using _queryId or _queryExpression are not supported. Use \
+  _queryFilter instead
+ERR_READ_ONLY_ENDPOINT_69=This endpoint is read-only and only supports read and query requests
+ERR_UNSUPPORTED_REQUEST_AGAINST_COLLECTION_70=The targeted resource is a resource collection which only \
+  supports create and query requests
+ERR_UNSUPPORTED_REQUEST_AGAINST_INSTANCE_71=The targeted resource is a resource instance which only supports \
+  read, update, delete, patch, and action requests
+ERR_UNSUPPORTED_REQUEST_AGAINST_SINGLETON_72=The targeted resource is a resource singleton which only supports \
+  read, update, patch, and action requests
+ERR_SERVER_PROVIDED_RESOURCE_ID_UNEXPECTED_73=Resources cannot be created with a client provided resource ID. The \
+  server will automatically generate a resource ID
+ERR_COLLECTION_ACTIONS_NOT_SUPPORTED_74=Collections only support create or query requests
+ERR_UNRECOGNIZED_RESOURCE_SUPER_TYPE_75=The resource '%s' has an unrecognized super-type '%s'
+ERR_UNRECOGNIZED_SUB_RESOURCE_TYPE_76=The resource '%s' references an unrecognized sub-resource '%s'
+ERR_MISSING_REQUIRED_FIELD_77=The create request cannot be processed because it does not include the required field '%s'
+ERR_INVALID_ENDPOINTS_DIRECTORY_78=The endpoints configuration directory '%s' either does not exist, is not a \
+  directory or cannot be read
+ERR_INVALID_ENDPOINT_DIRECTORY_79=The endpoint configuration directory '%s' either does not exist, is not a \
+  directory or cannot be read
+INFO_REST2LDAP_STARTING_80=Rest2Ldap starting with configuration directory '%s'
+INFO_REST2LDAP_CREATING_ENDPOINT_81=Rest2Ldap created endpoint '%s' version %s
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java
index 0602c63..343ed60 100644
--- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/BasicRequestsTest.java
@@ -24,21 +24,22 @@
 import static org.forgerock.json.resource.PatchOperation.increment;
 import static org.forgerock.json.resource.PatchOperation.remove;
 import static org.forgerock.json.resource.PatchOperation.replace;
-import static org.forgerock.json.resource.Requests.newDeleteRequest;
-import static org.forgerock.json.resource.Requests.newPatchRequest;
-import static org.forgerock.json.resource.Requests.newQueryRequest;
-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.Requests.*;
 import static org.forgerock.json.resource.Resources.newInternalConnection;
 import static org.forgerock.opendj.ldap.Connections.newInternalConnectionFactory;
 import static org.forgerock.opendj.ldap.Functions.byteStringToInteger;
+import static org.forgerock.opendj.rest2ldap.Rest2Ldap.collectionOf;
 import static org.forgerock.opendj.rest2ldap.Rest2Ldap.constant;
+import static org.forgerock.opendj.rest2ldap.Rest2Ldap.rest2Ldap;
 import static org.forgerock.opendj.rest2ldap.Rest2Ldap.object;
+import static org.forgerock.opendj.rest2ldap.Rest2Ldap.resource;
 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.WritabilityPolicy.CREATE_ONLY;
+import static org.forgerock.opendj.rest2ldap.WritabilityPolicy.READ_ONLY;
+import static org.forgerock.util.Options.defaultOptions;
 
 import java.io.IOException;
 import java.util.ArrayList;
@@ -56,7 +57,6 @@
 import org.forgerock.json.resource.ResourceResponse;
 import org.forgerock.opendj.ldap.ConnectionFactory;
 import org.forgerock.opendj.ldap.IntermediateResponseHandler;
-import org.forgerock.opendj.ldap.LdapException;
 import org.forgerock.opendj.ldap.LdapResultHandler;
 import org.forgerock.opendj.ldap.MemoryBackend;
 import org.forgerock.opendj.ldap.RequestContext;
@@ -76,7 +76,6 @@
 import org.forgerock.opendj.ldap.responses.ExtendedResult;
 import org.forgerock.opendj.ldap.responses.Result;
 import org.forgerock.opendj.ldif.LDIFEntryReader;
-import org.forgerock.opendj.rest2ldap.Rest2Ldap.Builder;
 import org.forgerock.services.context.Context;
 import org.forgerock.testng.ForgeRockTestCase;
 import org.forgerock.util.query.QueryFilter;
@@ -208,7 +207,7 @@
     public void testPatchEmpty() throws Exception {
         final List<Request> requests = new LinkedList<>();
         final Context context = newAuthConnectionContext(requests);
-        final Connection connection = newConnection(requests);
+        final Connection connection = newConnection();
         final ResourceResponse resource1 = connection.patch(context, newPatchRequest("/test1"));
         checkResourcesAreEqual(resource1, getTestUser1(12345));
 
@@ -486,7 +485,7 @@
     @Test
     public void testUpdateNoChange() throws Exception {
         final List<Request> requests = new LinkedList<>();
-        final Connection connection = newConnection(requests);
+        final Connection connection = newConnection();
         final Context context = newAuthConnectionContext(requests);
         final ResourceResponse resource1 = connection.update(context, newUpdateRequest("/test1", getTestUser1(12345)));
 
@@ -604,51 +603,40 @@
     }
 
     private Connection newConnection() throws IOException {
-        return newConnection(new LinkedList<Request>());
+        return newInternalConnection(usersApi().newRequestHandlerFor("api"));
     }
 
-    private Connection newConnection(final List<Request> requests) throws IOException {
-        return newInternalConnection(newCollection(builder(requests).build()));
-    }
-
-    private Builder builder(final List<Request> requests) throws IOException {
-        return Rest2Ldap.builder()
-                        .baseDN("dc=test")
-                        .useEtagAttribute()
-                        .useClientDNNaming("uid")
-                        .readOnUpdatePolicy(ReadOnUpdatePolicy.CONTROLS)
-                        .additionalLDAPAttribute("objectClass", "top", "person")
-                        .mapper(object()
-                        .attribute("schemas", constant(asList("urn:scim:schemas:core:1.0")))
-                        .attribute("_id", simple("uid").isSingleValued()
-                                                       .isRequired()
-                                                       .writability(WritabilityPolicy.CREATE_ONLY))
-                        .attribute("name", object().attribute("displayName", simple("cn").isSingleValued()
-                                                                                         .isRequired())
-                                                    .attribute("surname", simple("sn").isSingleValued().isRequired()))
-                        .attribute("_rev", simple("etag").isSingleValued()
-                                                         .isRequired()
-                                                         .writability(WritabilityPolicy.READ_ONLY))
-                        .attribute("description", simple("description"))
-                        .attribute("singleNumber", simple("singleNumber").decoder(byteStringToInteger())
-                                                                         .isSingleValued())
-                        .attribute("multiNumber", simple("multiNumber").decoder(byteStringToInteger())));
+    private Rest2Ldap usersApi() throws IOException {
+        return rest2Ldap(defaultOptions(),
+                         resource("api").subResource(collectionOf("user").dnTemplate("dc=test")
+                                                                         .useClientDnNaming("uid")),
+                         resource("user").objectClasses("top", "person")
+                                         .property("schemas", constant(asList("urn:scim:schemas:core:1.0")))
+                                         .property("_id", simple("uid").isRequired(true).writability(CREATE_ONLY))
+                                         .property("name", object().property("displayName",
+                                                                             simple("cn").isRequired(true))
+                                                                   .property("surname", simple("sn").isRequired(true)))
+                                         .property("_rev", simple("etag").isRequired(true).writability(READ_ONLY))
+                                         .property("description", simple("description").isMultiValued(true))
+                                         .property("singleNumber",
+                                                   simple("singleNumber").decoder(byteStringToInteger()))
+                                         .property("multiNumber",
+                                                   simple("multiNumber").isMultiValued(true)
+                                                                        .decoder(byteStringToInteger())));
     }
 
     private void checkResourcesAreEqual(final ResourceResponse actual, final JsonValue expected) {
         final ResourceResponse expectedResource = asResource(expected);
         assertThat(actual.getId()).isEqualTo(expectedResource.getId());
         assertThat(actual.getRevision()).isEqualTo(expectedResource.getRevision());
-        assertThat(actual.getContent().getObject()).isEqualTo(
-                expectedResource.getContent().getObject());
+        assertThat(actual.getContent().getObject()).isEqualTo(expectedResource.getContent().getObject());
     }
 
-    private AuthenticatedConnectionContext newAuthConnectionContext() throws LdapException, IOException {
+    private AuthenticatedConnectionContext newAuthConnectionContext() throws IOException {
         return newAuthConnectionContext(new ArrayList<Request>());
     }
 
-    private AuthenticatedConnectionContext newAuthConnectionContext(List<Request> requests)
-            throws LdapException, IOException {
+    private AuthenticatedConnectionContext newAuthConnectionContext(List<Request> requests) throws IOException {
         return new AuthenticatedConnectionContext(ctx(), getConnectionFactory(requests).getConnection());
     }
 
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/Rest2LdapTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/Rest2LdapTest.java
new file mode 100644
index 0000000..f3d7dce
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/Rest2LdapTest.java
@@ -0,0 +1,465 @@
+/*
+ * 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 2016 ForgeRock AS.
+ *
+ */
+package org.forgerock.opendj.rest2ldap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.fail;
+import static org.forgerock.json.JsonValue.array;
+import static org.forgerock.json.JsonValue.json;
+import static org.forgerock.json.resource.PatchOperation.replace;
+import static org.forgerock.json.resource.Requests.*;
+import static org.forgerock.json.resource.Responses.newResourceResponse;
+import static org.forgerock.opendj.ldap.Connections.newInternalConnection;
+import static org.forgerock.opendj.rest2ldap.Rest2Ldap.*;
+import static org.forgerock.opendj.rest2ldap.WritabilityPolicy.CREATE_ONLY;
+import static org.forgerock.opendj.rest2ldap.WritabilityPolicy.READ_ONLY;
+import static org.forgerock.util.Options.defaultOptions;
+
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.forgerock.json.JsonPointer;
+import org.forgerock.json.JsonValue;
+import org.forgerock.json.resource.CreateRequest;
+import org.forgerock.json.resource.DeleteRequest;
+import org.forgerock.json.resource.NotFoundException;
+import org.forgerock.json.resource.PatchRequest;
+import org.forgerock.json.resource.QueryRequest;
+import org.forgerock.json.resource.QueryResourceHandler;
+import org.forgerock.json.resource.QueryResponse;
+import org.forgerock.json.resource.ReadRequest;
+import org.forgerock.json.resource.RequestHandler;
+import org.forgerock.json.resource.Requests;
+import org.forgerock.json.resource.ResourceResponse;
+import org.forgerock.json.resource.UpdateRequest;
+import org.forgerock.opendj.ldap.Connection;
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.Entry;
+import org.forgerock.opendj.ldap.IntermediateResponseHandler;
+import org.forgerock.opendj.ldap.LdapResultHandler;
+import org.forgerock.opendj.ldap.MemoryBackend;
+import org.forgerock.opendj.ldap.RequestContext;
+import org.forgerock.opendj.ldap.SearchResultHandler;
+import org.forgerock.opendj.ldap.requests.AddRequest;
+import org.forgerock.opendj.ldap.requests.BindRequest;
+import org.forgerock.opendj.ldap.requests.CompareRequest;
+import org.forgerock.opendj.ldap.requests.ExtendedRequest;
+import org.forgerock.opendj.ldap.requests.ModifyDNRequest;
+import org.forgerock.opendj.ldap.requests.ModifyRequest;
+import org.forgerock.opendj.ldap.requests.SearchRequest;
+import org.forgerock.opendj.ldap.responses.BindResult;
+import org.forgerock.opendj.ldap.responses.CompareResult;
+import org.forgerock.opendj.ldap.responses.ExtendedResult;
+import org.forgerock.opendj.ldap.responses.Result;
+import org.forgerock.opendj.ldif.EntryReader;
+import org.forgerock.opendj.ldif.LDIFEntryReader;
+import org.forgerock.services.context.Context;
+import org.forgerock.services.context.RootContext;
+import org.forgerock.testng.ForgeRockTestCase;
+import org.forgerock.util.query.QueryFilter;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+@SuppressWarnings({ "javadoc" })
+@Test
+public final class Rest2LdapTest extends ForgeRockTestCase {
+    // TODO: unit test for DN template variables
+    // TODO: unit test for nested sub-resources
+    // TODO: unit test for singletons
+    // TODO: unit test for read-only
+
+    private enum UseCase {
+        CLIENT_ID_PRIMARY_VIEW {
+            @Override
+            RequestHandler handler() {
+                return rest2Ldap(defaultOptions(),
+                                 top(),
+                                 userUidResource(),
+                                 resource("test")
+                                         .subResources(collectionOf("userUid").useClientDnNaming("uid")
+                                                                              .dnTemplate("dc=test")
+                                                                              .urlTemplate("users")))
+                        .newRequestHandlerFor("test");
+            }
+
+            @Override
+            CreateRequest createRequest() {
+                return newCreateRequest("users", "bjensen", userJson("bjensen", null, "bjensen", "created"));
+            }
+
+            @Override
+            ResourceResponse createdResource() {
+                return newResourceResponse("bjensen", "1", userJson("bjensen", "1", "bjensen", "created"));
+            }
+        },
+        CLIENT_ID_SECONDARY_VIEW {
+            @Override
+            RequestHandler handler() {
+                return rest2Ldap(defaultOptions(),
+                                 top(),
+                                 userUidResource(),
+                                 resource("test")
+                                         .subResources(collectionOf("userUid").useClientNaming("uid", "mail")
+                                                                              .dnTemplate("dc=test")
+                                                                              .urlTemplate("users")))
+                        .newRequestHandlerFor("test");
+            }
+
+            @Override
+            CreateRequest createRequest() {
+                return newCreateRequest("users", "bjensen@test.com", userJson("bjensen", null, "bjensen", "created"));
+            }
+
+            @Override
+            ResourceResponse createdResource() {
+                return newResourceResponse("bjensen@test.com", "1", userJson("bjensen", "1", "bjensen", "created"));
+            }
+        },
+        SERVER_ID_PRIMARY_VIEW {
+            @Override
+            RequestHandler handler() {
+                return rest2Ldap(defaultOptions(),
+                                 top(),
+                                 userEntryUuidResource(),
+                                 resource("test")
+                                         .subResources(collectionOf("userEntryUuid").useServerEntryUuidNaming("uid")
+                                                                                    .dnTemplate("dc=test")
+                                                                                    .urlTemplate("users")))
+                        .newRequestHandlerFor("test");
+            }
+
+            @Override
+            CreateRequest createRequest() {
+                return newCreateRequest("users", null, userJson(null, null, "bjensen", "created"));
+            }
+
+            @Override
+            ResourceResponse createdResource() {
+                return newResourceResponse(ENTRY_UUID, "1", userJson(ENTRY_UUID, "1", "bjensen", "created"));
+            }
+        },
+        SERVER_ID_SECONDARY_VIEW {
+            @Override
+            RequestHandler handler() {
+                return rest2Ldap(defaultOptions(),
+                                 top(),
+                                 userEntryUuidResource(),
+                                 resource("test")
+                                         .subResources(collectionOf("userEntryUuid").useClientNaming("uid", "mail")
+                                                                                    .dnTemplate("dc=test")
+                                                                                    .urlTemplate("users")))
+                        .newRequestHandlerFor("test");
+            }
+
+            @Override
+            CreateRequest createRequest() {
+                return newCreateRequest("users", "bjensen@test.com", userJson(null, null, "bjensen", "created"));
+            }
+
+            @Override
+            ResourceResponse createdResource() {
+                return newResourceResponse("bjensen@test.com", "1", userJson(ENTRY_UUID, "1", "bjensen", "created"));
+            }
+        };
+
+        abstract ResourceResponse createdResource();
+
+        Context ctx() throws Exception {
+            final EntryReader ldif = new LDIFEntryReader("dn: dc=test",
+                                                         "objectClass: top",
+                                                         "objectClass: domain",
+                                                         "dc: test");
+            final Connection connection = newInternalConnection(updateMeta(new MemoryBackend(ldif)));
+            return new AuthenticatedConnectionContext(new RootContext(), connection);
+        }
+
+        abstract RequestHandler handler();
+
+        abstract CreateRequest createRequest();
+    }
+
+    private static final String ENTRY_UUID = UUID.randomUUID().toString();
+    private static final String USER_SCHEMA_URI = "fr:opendj:user:1.0";
+
+    // Field values may be null.
+    private static JsonValue userJson(final String id, final String rev, final String uid, final String description) {
+        return json(o(f("_id", id),
+                      f("_rev", rev),
+                      f("schema", USER_SCHEMA_URI),
+                      f("uid", uid),
+                      f("email", uid + "@test.com"),
+                      f("name", o(f("displayName", uid + " displayName"), f("surname", uid + " surname"))),
+                      f("description", array(description))));
+    }
+
+    private static Map.Entry<String, Object> f(final String k, final Object v) {
+        return v != null ? JsonValue.field(k, v) : null;
+    }
+
+    private static Object o(Map.Entry<?, ?>... fields) {
+        return JsonValue.object(fields);
+    }
+
+    private static Resource top() {
+        return resource("top").isAbstract(true)
+                              .objectClass("top")
+                              .property("_rev", simple("etag").isRequired(true).writability(READ_ONLY));
+    }
+
+    private static Resource userEntryUuidResource() {
+        return userResource("userEntryUuid", simple("entryUUID").writability(READ_ONLY));
+    }
+
+    private static Resource userUidResource() {
+        return userResource("userUid", simple("uid").isRequired(true).writability(CREATE_ONLY));
+    }
+
+    private static Resource userResource(final String resourceId, final PropertyMapper id) {
+        return resource(resourceId).superType("top")
+                                   .objectClasses("person", "organizationalPerson", "inetOrgPerson")
+                                   .property("schema", constant(USER_SCHEMA_URI))
+                                   .property("_id", id)
+                                   .property("uid", simple("uid").isRequired(true).writability(CREATE_ONLY))
+                                   .property("email", simple("mail"))
+                                   .property("name", object().property("displayName", simple("cn").isRequired(true))
+                                                             .property("surname", simple("sn").isRequired(true)))
+                                   .property("description", simple("description").isMultiValued(true));
+    }
+
+    @Test(dataProvider = "useCases")
+    public void canCreateResources(UseCase useCase) throws Exception {
+        // Given
+        RequestHandler handler = useCase.handler();
+        Context ctx = useCase.ctx();
+
+        // When
+        ResourceResponse actual = handler.handleCreate(ctx, useCase.createRequest()).getOrThrowUninterruptibly();
+
+        // Then
+        assertThatExpectedResourceWasReturned(actual, useCase.createdResource());
+    }
+
+    @Test(dataProvider = "useCases", dependsOnMethods = "canCreateResources")
+    public void canReadResources(UseCase useCase) throws Exception {
+        // Given
+        RequestHandler handler = useCase.handler();
+        Context ctx = useCase.ctx();
+        CreateRequest createRequest = useCase.createRequest();
+        ResourceResponse resource = handler.handleCreate(ctx, createRequest).getOrThrowUninterruptibly();
+
+        // When
+        ReadRequest readRequest = newReadRequest(createRequest.getResourcePath(), resource.getId());
+        ResourceResponse actual = handler.handleRead(ctx, readRequest).getOrThrowUninterruptibly();
+
+        // Then
+        assertThatExpectedResourceWasReturned(actual, resource);
+    }
+
+    @Test(dataProvider = "useCases", dependsOnMethods = "canCreateResources")
+    public void canUpdateResources(UseCase useCase) throws Exception {
+        // Given
+        RequestHandler handler = useCase.handler();
+        Context ctx = useCase.ctx();
+        CreateRequest createRequest = useCase.createRequest();
+        ResourceResponse resource = handler.handleCreate(ctx, createRequest).getOrThrowUninterruptibly();
+
+        // When
+        JsonValue newContent = resource.getContent().copy();
+        newContent.put("description", array("updated"));
+        UpdateRequest updateRequest = newUpdateRequest(createRequest.getResourcePath(), resource.getId(), newContent);
+
+        ResourceResponse actual = handler.handleUpdate(ctx, updateRequest).getOrThrowUninterruptibly();
+
+        // Then
+        newContent.put("_rev", "2");
+        ResourceResponse expected = newResourceResponse(resource.getId(), "2", newContent);
+
+        assertThatExpectedResourceWasReturned(actual, expected);
+
+        ReadRequest readRequest = newReadRequest(createRequest.getResourcePath(), resource.getId());
+        ResourceResponse actual2 = handler.handleRead(ctx, readRequest).getOrThrowUninterruptibly();
+        assertThatExpectedResourceWasReturned(actual2, expected);
+    }
+
+    @Test(dataProvider = "useCases", dependsOnMethods = "canCreateResources")
+    public void canDeleteResources(UseCase useCase) throws Exception {
+        // Given
+        RequestHandler handler = useCase.handler();
+        Context ctx = useCase.ctx();
+        CreateRequest createRequest = useCase.createRequest();
+        ResourceResponse resource = handler.handleCreate(ctx, createRequest).getOrThrowUninterruptibly();
+
+        // When
+        DeleteRequest deleteRequest = Requests.newDeleteRequest(createRequest.getResourcePath(), resource.getId());
+        ResourceResponse actual = handler.handleDelete(ctx, deleteRequest).getOrThrowUninterruptibly();
+
+        // Then
+        assertThatExpectedResourceWasReturned(actual, resource);
+
+        ReadRequest readRequest = newReadRequest(createRequest.getResourcePath(), resource.getId());
+        try {
+            handler.handleRead(ctx, readRequest).getOrThrowUninterruptibly();
+            fail("Deleted resource can still be read");
+        } catch (NotFoundException e) {
+            // Expected.
+        }
+    }
+
+    @Test(dataProvider = "useCases", dependsOnMethods = "canCreateResources")
+    public void canPatchResources(UseCase useCase) throws Exception {
+        // Given
+        RequestHandler handler = useCase.handler();
+        Context ctx = useCase.ctx();
+        CreateRequest createRequest = useCase.createRequest();
+        ResourceResponse resource = handler.handleCreate(ctx, createRequest).getOrThrowUninterruptibly();
+
+        // When
+        PatchRequest patchRequest = newPatchRequest(createRequest.getResourcePath(),
+                                                    resource.getId(),
+                                                    replace("description", array("patched")));
+        ResourceResponse actual = handler.handlePatch(ctx, patchRequest).getOrThrowUninterruptibly();
+
+        // Then
+        JsonValue newContent = resource.getContent().copy();
+        newContent.put("description", array("patched"));
+        newContent.put("_rev", "2");
+        ResourceResponse expected = newResourceResponse(resource.getId(), "2", newContent);
+
+        assertThatExpectedResourceWasReturned(actual, expected);
+
+        ReadRequest readRequest = newReadRequest(createRequest.getResourcePath(), resource.getId());
+        ResourceResponse actual2 = handler.handleRead(ctx, readRequest).getOrThrowUninterruptibly();
+        assertThatExpectedResourceWasReturned(actual2, expected);
+    }
+
+    @Test(dataProvider = "useCases", dependsOnMethods = "canCreateResources")
+    public void canQueryResources(UseCase useCase) throws Exception {
+        // Given
+        RequestHandler handler = useCase.handler();
+        Context ctx = useCase.ctx();
+        CreateRequest createRequest = useCase.createRequest();
+        ResourceResponse resource = handler.handleCreate(ctx, createRequest).getOrThrowUninterruptibly();
+
+        // When
+        QueryRequest queryRequest = newQueryRequest(createRequest.getResourcePath());
+        queryRequest.setQueryFilter(QueryFilter.<JsonPointer>alwaysTrue());
+
+        final AtomicReference<ResourceResponse> actualResource = new AtomicReference<>();
+        QueryResponse actualResponse = handler.handleQuery(ctx, queryRequest, new QueryResourceHandler() {
+            @Override
+            public boolean handleResource(final ResourceResponse resource) {
+                if (!actualResource.compareAndSet(null, resource)) {
+                    fail("Too many resources returned during query");
+                }
+                return true;
+            }
+        }).getOrThrowUninterruptibly();
+
+        // Then
+        assertThat(actualResponse).isNotNull();
+        assertThatExpectedResourceWasReturned(actualResource.get(), resource);
+    }
+
+    @DataProvider
+    Object[][] useCases() throws Exception {
+        UseCase[] values = UseCase.values();
+        Object[][] data = new Object[values.length][];
+        for (int i = 0; i < values.length; i++) {
+            data[i] = new Object[] { values[i] };
+        }
+        return data;
+    }
+
+    private void assertThatExpectedResourceWasReturned(final ResourceResponse actual, final ResourceResponse expected) {
+        assertThat(actual.getId()).isEqualTo(expected.getId());
+        assertThat(actual.getRevision()).isEqualTo(expected.getRevision());
+        assertThat(actual.getContent().asMap()).isEqualTo(expected.getContent().asMap());
+    }
+
+    private static org.forgerock.opendj.ldap.RequestHandler<RequestContext> updateMeta(final MemoryBackend delegate) {
+        return new org.forgerock.opendj.ldap.RequestHandler<RequestContext>() {
+            public void handleAdd(final RequestContext requestContext, final AddRequest request,
+                                  final IntermediateResponseHandler intermediateResponseHandler,
+                                  final LdapResultHandler<Result> resultHandler) {
+                request.addAttribute("entryUuid", ENTRY_UUID);
+                request.addAttribute("etag", 1);
+                delegate.handleAdd(requestContext, request, intermediateResponseHandler, resultHandler);
+            }
+
+            public void handleBind(final RequestContext requestContext, final int version, final BindRequest request,
+                                   final IntermediateResponseHandler intermediateResponseHandler,
+                                   final LdapResultHandler<BindResult> resultHandler) {
+                delegate.handleBind(requestContext, version, request, intermediateResponseHandler, resultHandler);
+            }
+
+            public void handleCompare(final RequestContext requestContext, final CompareRequest request,
+                                      final IntermediateResponseHandler intermediateResponseHandler,
+                                      final LdapResultHandler<CompareResult> resultHandler) {
+                delegate.handleCompare(requestContext, request, intermediateResponseHandler, resultHandler);
+            }
+
+            public void handleDelete(final RequestContext requestContext,
+                                     final org.forgerock.opendj.ldap.requests.DeleteRequest request,
+                                     final IntermediateResponseHandler intermediateResponseHandler,
+                                     final LdapResultHandler<Result> resultHandler) {
+                delegate.handleDelete(requestContext, request, intermediateResponseHandler, resultHandler);
+            }
+
+            public <R extends ExtendedResult> void handleExtendedRequest(final RequestContext requestContext,
+                                                                         final ExtendedRequest<R> request,
+                                                                         final IntermediateResponseHandler
+                                                                                 intermediateResponseHandler,
+                                                                         final LdapResultHandler<R> resultHandler) {
+                delegate.handleExtendedRequest(requestContext, request, intermediateResponseHandler, resultHandler);
+            }
+
+            public void handleModify(final RequestContext requestContext, final ModifyRequest request,
+                                     final IntermediateResponseHandler intermediateResponseHandler,
+                                     final LdapResultHandler<Result> resultHandler) {
+                incrementEtag(request.getName());
+                delegate.handleModify(requestContext, request, intermediateResponseHandler, resultHandler);
+            }
+
+            private void incrementEtag(final DN name) {
+                final Entry entry = delegate.get(name);
+                if (entry != null) {
+                    final int etag = entry.parseAttribute("etag").asInteger(1);
+                    entry.replaceAttribute("etag", etag + 1);
+                }
+            }
+
+            public void handleModifyDN(final RequestContext requestContext, final ModifyDNRequest request,
+                                       final IntermediateResponseHandler intermediateResponseHandler,
+                                       final LdapResultHandler<Result> resultHandler) {
+                incrementEtag(request.getName());
+                delegate.handleModifyDN(requestContext, request, intermediateResponseHandler, resultHandler);
+            }
+
+            public void handleSearch(final RequestContext requestContext, final SearchRequest request,
+                                     final IntermediateResponseHandler intermediateResponseHandler,
+                                     final SearchResultHandler entryHandler,
+                                     final LdapResultHandler<Result> resultHandler) {
+                delegate.handleSearch(requestContext,
+                                      request,
+                                      intermediateResponseHandler,
+                                      entryHandler,
+                                      resultHandler);
+            }
+        };
+    }
+}
diff --git a/opendj-server-legacy/resource/config/config.ldif b/opendj-server-legacy/resource/config/config.ldif
index 86af4bb..0d8d11d 100644
--- a/opendj-server-legacy/resource/config/config.ldif
+++ b/opendj-server-legacy/resource/config/config.ldif
@@ -386,7 +386,7 @@
 ds-cfg-enabled: true
 ds-cfg-java-class: org.opends.server.protocols.http.rest2ldap.Rest2LdapEndpoint
 ds-cfg-base-path: /api
-ds-cfg-config-url: config/http-config.json
+ds-cfg-config-directory: config/rest2ldap/endpoints/api
 ds-cfg-http-authorization-mechanism: cn=HTTP Basic,cn=HTTP Authorization Mechanisms,cn=config
 
 dn: cn=HTTP Authorization Mechanisms,cn=config
diff --git a/opendj-server-legacy/resource/config/http-config.json b/opendj-server-legacy/resource/config/http-config.json
deleted file mode 100644
index 1287afe..0000000
--- a/opendj-server-legacy/resource/config/http-config.json
+++ /dev/null
@@ -1,104 +0,0 @@
-{
-    // The REST APIs and their LDAP attribute mappings.
-    "mappings" : {
-        "/users" : {
-            "baseDN"              : "ou=people,dc=example,dc=com",
-            "readOnUpdatePolicy"  : "controls",
-            "useSubtreeDelete"    : false,
-            "usePermissiveModify" : true,
-            "etagAttribute"       : "etag",
-            "namingStrategy"      : {
-                "strategy"    : "clientDNNaming",
-                "dnAttribute" : "uid"
-            },
-            "additionalLDAPAttributes" : [
-                {
-                    "type" : "objectClass",
-                    "values" : [
-                        "top",
-                        "person",
-                        "organizationalPerson",
-                        "inetOrgPerson"
-                    ]
-                }
-            ],
-            "attributes" : {
-                "schemas"     : { "constant" : [ "urn:scim:schemas:core:1.0" ] },
-                "_id"         : { "simple"   : { "ldapAttribute" : "uid", "isSingleValued" : true, "isRequired" : true, "writability" : "createOnly" } },
-                "_rev"        : { "simple"   : { "ldapAttribute" : "etag", "isSingleValued" : true, "writability" : "readOnly" } },
-                "userName"    : { "simple"   : { "ldapAttribute" : "mail", "isSingleValued" : true, "writability" : "readOnly" } },
-                "displayName" : { "simple"   : { "ldapAttribute" : "cn", "isSingleValued" : true, "isRequired" : true } },
-                "name"        : { "object"   : {
-                    "givenName"  : { "simple" : { "ldapAttribute" : "givenName", "isSingleValued" : true } },
-                    "familyName" : { "simple" : { "ldapAttribute" : "sn", "isSingleValued" : true, "isRequired" : true } }
-                } },
-                "manager"     : { "reference" : {
-                    "ldapAttribute" : "manager",
-                    "baseDN"        : "ou=people,dc=example,dc=com",
-                    "primaryKey"    : "uid",
-                    "mapper"         : { "object" : {
-                        "_id"         : { "simple"   : { "ldapAttribute" : "uid", "isSingleValued" : true, "isRequired" : true } },
-                        "displayName" : { "simple"   : { "ldapAttribute" : "cn", "isSingleValued" : true, "writability" : "readOnlyDiscardWrites" } }
-                    } }
-                } },
-                "groups"     : { "reference" : {
-                    "ldapAttribute" : "isMemberOf",
-                    "baseDN"        : "ou=groups,dc=example,dc=com",
-                    "writability"   : "readOnly",
-                    "primaryKey"    : "cn",
-                    "mapper"        : { "object" : {
-                        "_id"         : { "simple"   : { "ldapAttribute" : "cn", "isSingleValued" : true } }
-                    } }
-                } },
-                "contactInformation" : { "object" : {
-                    "telephoneNumber" : { "simple" : { "ldapAttribute" : "telephoneNumber", "isSingleValued" : true } },
-                    "emailAddress"    : { "simple" : { "ldapAttribute" : "mail", "isSingleValued" : true } }
-                } },
-                "meta"        : { "object" : {
-                    "created"      : { "simple" : { "ldapAttribute" : "createTimestamp", "isSingleValued" : true, "writability" : "readOnly" } },
-                    "lastModified" : { "simple" : { "ldapAttribute" : "modifyTimestamp", "isSingleValued" : true, "writability" : "readOnly" } }
-                } }
-            }
-        },
-        "/groups" : {
-            "baseDN"              : "ou=groups,dc=example,dc=com",
-            "readOnUpdatePolicy"  : "controls",
-            "useSubtreeDelete"    : false,
-            "usePermissiveModify" : true,
-            "etagAttribute"       : "etag",
-            "namingStrategy"      : {
-                "strategy"    : "clientDNNaming",
-                "dnAttribute" : "cn"
-            },
-            "additionalLDAPAttributes" : [
-                {
-                    "type" : "objectClass",
-                    "values" : [
-                        "top",
-                        "groupOfUniqueNames"
-                    ]
-                }
-            ],
-            "attributes" : {
-                "schemas"     : { "constant" : [ "urn:scim:schemas:core:1.0" ] },
-                "_id"         : { "simple"   : { "ldapAttribute" : "cn", "isSingleValued" : true, "isRequired" : true, "writability" : "createOnly" } },
-                "_rev"        : { "simple"   : { "ldapAttribute" : "etag", "isSingleValued" : true, "writability" : "readOnly" } },
-                "displayName" : { "simple"   : { "ldapAttribute" : "cn", "isSingleValued" : true, "isRequired" : true, "writability" : "readOnly" } },
-                "members"    : { "reference" : {
-                    "ldapAttribute" : "uniqueMember",
-                    "baseDN"        : "dc=example,dc=com",
-                    "primaryKey"    : "uid",
-                    "mapper"        : { "object" : {
-                        "_id"         : { "simple"   : { "ldapAttribute" : "uid", "isSingleValued" : true, "isRequired" : true } },
-                        "displayName" : { "simple"   : { "ldapAttribute" : "cn", "isSingleValued" : true, "writability" : "readOnlyDiscardWrites" } }
-                    } }
-                } },
-                "meta"        : { "object" : {
-                    "created"      : { "simple" : { "ldapAttribute" : "createTimestamp", "isSingleValued" : true, "writability" : "readOnly" } },
-                    "lastModified" : { "simple" : { "ldapAttribute" : "modifyTimestamp", "isSingleValued" : true, "writability" : "readOnly" } }
-                } }
-            }
-        }
-    }
-}
-
diff --git a/opendj-server-legacy/resource/config/rest2ldap/endpoints/api/users-and-groups-v1.json b/opendj-server-legacy/resource/config/rest2ldap/endpoints/api/users-and-groups-v1.json
new file mode 100644
index 0000000..dbdfe7d
--- /dev/null
+++ b/opendj-server-legacy/resource/config/rest2ldap/endpoints/api/users-and-groups-v1.json
@@ -0,0 +1,234 @@
+{
+    // This file defines an example Rest2Ldap API mapping exposing a multi-tenant deployment exposing users,
+    // POSIX users, and groups, as follows:
+    //
+    // /api/{tenant}/users/{uid} - users for a given tenant, e.g. "/api/example/users/bjensen"
+    // /api/{tenant}/groups/{cn} - groups for a given tenant, e.g. "/api/example/groups/administrators"
+    //
+    "version": "1.0",
+
+    // This section defines all of the resources, their inheritance, and relationships.
+    "resourceTypes": {
+        // This resource represents the entry point into the user/group API. It only defines sub-resources and
+        // does not have any properties itself. The URL and DN templates include a template variable allowing
+        // this API to support multi-tenancy. Multiple template variables are permitted.
+        "users-and-groups-v1": {
+            "subResources": {
+                "{tenant}/users": {
+                    "type": "collection",
+                    "dnTemplate": "ou=people,dc={tenant},dc=com",
+                    "resource": "frapi:opendj:rest2ldap:user:1.0",
+                    "namingStrategy": {
+                        "type": "clientDnNaming",
+                        "dnAttribute": "uid"
+                    }
+                },
+                "{tenant}/groups": {
+                    "type": "collection",
+                    "dnTemplate": "ou=groups,dc={tenant},dc=com",
+                    "resource": "frapi:opendj:rest2ldap:group:1.0",
+                    "namingStrategy": {
+                        "type": "clientDNNaming",
+                        "dnAttribute": "cn"
+                    }
+                }
+            }
+        },
+        // This resource will act as the common parent of all resources that have a JSON representation.
+        "frapi:opendj:rest2ldap:object:1.0": {
+            "isAbstract": true,
+            "objectClasses": [ "top" ],
+            // This property will store type information in a resource's JSON representation. It is the
+            // equivalent of the "objectClass" attribute, except that it is single valued and will contain
+            // the resource name, e.g. "frapi:opendj:rest2ldap:user:1.0" or "frapi:opendj:rest2ldap:group:1.0".
+            "resourceTypeProperty": "_schema",
+            "properties": {
+                // Resource type property mappers store the resource's type and don't have any configuration.
+                "_schema": {
+                    "type": "resourceType"
+                },
+                "_rev": {
+                    "type": "simple",
+                    "ldapAttribute": "etag",
+                    "writability": "readOnly"
+                },
+                "_meta": {
+                    "type": "object",
+                    "properties": {
+                        "created": {
+                            "type": "simple",
+                            "ldapAttribute": "createTimestamp",
+                            "writability": "readOnly"
+                        },
+                        "lastModified": {
+                            "type": "simple",
+                            "ldapAttribute": "modifyTimestamp",
+                            "writability": "readOnly"
+                        }
+                    }
+                }
+            }
+        },
+        // A "user" resource includes property mapping for the inetOrgPerson LDAP object class and is identified by
+        // the "uid" LDAP attribute. Users have a single sub-type representing users with POSIX account information.
+        "frapi:opendj:rest2ldap:user:1.0": {
+            "superType": "frapi:opendj:rest2ldap:object:1.0",
+            "objectClasses": [ "person", "organizationalPerson", "inetOrgPerson" ],
+            "supportedActions": [ "passwordModify" ],
+            "properties": {
+                "_id": {
+                    "type": "simple",
+                    "ldapAttribute": "uid",
+                    "isRequired": true,
+                    "writability": "createOnly"
+                },
+                "userName": {
+                    "type": "simple",
+                    "ldapAttribute": "mail"
+                },
+                "displayName": {
+                    "type": "simple",
+                    "ldapAttribute": "cn",
+                    "isMultiValued": true,
+                    "isRequired": true
+                },
+                "name": {
+                    "type": "object",
+                    "properties": {
+                        "givenName": {
+                            "type": "simple"
+                        },
+                        "familyName": {
+                            "type": "simple",
+                            "ldapAttribute": "sn",
+                            "isRequired": true
+                        }
+                    }
+                },
+                "description": {
+                    "type": "simple"
+                },
+                "manager": {
+                    "type": "reference",
+                    "ldapAttribute": "manager",
+                    "baseDn": "ou=people,dc=example,dc=com",
+                    "primaryKey": "uid",
+                    "mapper": {
+                        "type": "object",
+                        "properties": {
+                            "_id": {
+                                "type": "simple",
+                                "ldapAttribute": "uid",
+                                "isRequired": true
+                            },
+                            "displayName": {
+                                "type": "simple",
+                                "ldapAttribute": "cn",
+                                "writability": "readOnlyDiscardWrites"
+                            }
+                        }
+                    }
+                },
+                "groups": {
+                    "type": "reference",
+                    "ldapAttribute": "isMemberOf",
+                    "baseDn": "ou=groups,dc=example,dc=com",
+                    "isMultiValued": true,
+                    "writability": "readOnly",
+                    "primaryKey": "cn",
+                    "mapper": {
+                        "type": "object",
+                        "properties": {
+                            "_id": {
+                                "type": "simple",
+                                "ldapAttribute": "cn"
+                            }
+                        }
+                    }
+                },
+                "contactInformation": {
+                    "type": "object",
+                    "properties": {
+                        "telephoneNumber": {
+                            "type": "simple"
+                        },
+                        "emailAddress": {
+                            "type": "simple",
+                            "ldapAttribute": "mail"
+                        }
+                    }
+                }
+            }
+        },
+        // A user with POSIX account information.
+        "frapi:opendj:rest2ldap:posixUser:1.0": {
+            "superType": "frapi:opendj:rest2ldap:user:1.0",
+            "objectClasses": [ "posixAccount" ],
+            "properties": {
+                "uidNumber": {
+                    "type": "simple",
+                    "isRequired": true
+                },
+                "gidNumber": {
+                    "type": "simple",
+                    "isRequired": true
+                },
+                "homeDirectory": {
+                    "type": "simple",
+                    "isRequired": true
+                },
+                "loginShell": {
+                    "type": "simple"
+                },
+                "gecos": {
+                    "type": "simple"
+                }
+            }
+        },
+        // A "group" resource includes property mapping for the inetOrgPerson LDAP object class and is identified by
+        // the "uid" LDAP attribute. Users have a single sub-type representing users with POSIX account information.
+        "frapi:opendj:rest2ldap:group:1.0": {
+            "superType": "frapi:opendj:rest2ldap:object:1.0",
+            "objectClasses": [ "groupOfUniqueNames" ],
+            "properties": {
+                "_id": {
+                    "type": "simple",
+                    "ldapAttribute": "cn",
+                    "isRequired": true,
+                    "writability": "createOnly"
+                },
+                "displayName": {
+                    "type": "simple",
+                    "ldapAttribute": "cn",
+                    "isRequired": true,
+                    "writability": "readOnly"
+                },
+                "description": {
+                    "type": "simple"
+                },
+                "members": {
+                    "type": "reference",
+                    "ldapAttribute": "uniqueMember",
+                    "baseDn": "dc=example,dc=com",
+                    "primaryKey": "uid",
+                    "isMultiValued": true,
+                    "mapper": {
+                        "type": "object",
+                        "properties": {
+                            "_id": {
+                                "type": "simple",
+                                "ldapAttribute": "uid",
+                                "isRequired": true
+                            },
+                            "displayName": {
+                                "type": "simple",
+                                "ldapAttribute": "cn",
+                                "writability": "readOnlyDiscardWrites"
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/opendj-server-legacy/resource/schema/02-config.ldif b/opendj-server-legacy/resource/schema/02-config.ldif
index 181bb47..79cacd5 100644
--- a/opendj-server-legacy/resource/schema/02-config.ldif
+++ b/opendj-server-legacy/resource/schema/02-config.ldif
@@ -3844,7 +3844,7 @@
   SINGLE-VALUE
   X-ORIGIN 'OpenDJ Directory Server' )
 attributeTypes: ( 1.3.6.1.4.1.36733.2.1.1.161
-  NAME 'ds-cfg-config-url'
+  NAME 'ds-cfg-config-directory'
   EQUALITY caseIgnoreMatch
   SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
   SINGLE-VALUE
@@ -5997,7 +5997,7 @@
   NAME 'ds-cfg-rest2ldap-endpoint'
   SUP ds-cfg-http-endpoint
   STRUCTURAL
-  MUST ( ds-cfg-config-url $ ds-cfg-resource )
+  MUST ( ds-cfg-config-directory )
   X-ORIGIN 'OpenDJ Directory Server' )
 objectClasses: ( 1.3.6.1.4.1.36733.2.1.2.36
   NAME 'ds-cfg-http-authorization-mechanism'
diff --git a/opendj-server-legacy/src/main/assembly/opendj-archive-component.xml b/opendj-server-legacy/src/main/assembly/opendj-archive-component.xml
index 56442ac..3193beb 100644
--- a/opendj-server-legacy/src/main/assembly/opendj-archive-component.xml
+++ b/opendj-server-legacy/src/main/assembly/opendj-archive-component.xml
@@ -12,7 +12,7 @@
   Header, with the fields enclosed by brackets [] replaced by your own identifying
   information: "Portions Copyright [year] [name of copyright owner]".
 
-  Copyright 2015 ForgeRock AS.
+  Copyright 2015-2016 ForgeRock AS.
 -->
 <!-- OpenDJ final archive content descriptor -->
 <component>
@@ -202,9 +202,6 @@
     <fileSet>
       <directory>${basedir}/resource/config</directory>
       <outputDirectory>template/config</outputDirectory>
-      <includes>
-        <include>*.*</include>
-      </includes>
       <excludes>
         <exclude>config.ldif</exclude>
       </excludes>
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/protocols/http/rest2ldap/Rest2LdapEndpoint.java b/opendj-server-legacy/src/main/java/org/opends/server/protocols/http/rest2ldap/Rest2LdapEndpoint.java
index f49c0c6..b328186 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/protocols/http/rest2ldap/Rest2LdapEndpoint.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/protocols/http/rest2ldap/Rest2LdapEndpoint.java
@@ -15,27 +15,23 @@
  */
 package org.opends.server.protocols.http.rest2ldap;
 
-import static org.forgerock.http.util.Json.readJsonLenient;
-import static org.opends.messages.ConfigMessages.*;
+import static org.forgerock.json.resource.http.CrestHttp.newHttpHandler;
+import static org.forgerock.opendj.rest2ldap.Rest2LdapJsonConfigurator.configureEndpoint;
+import static org.forgerock.util.Options.defaultOptions;
+import static org.opends.messages.ConfigMessages.ERR_CONFIG_REST2LDAP_INVALID;
+import static org.opends.messages.ConfigMessages.ERR_CONFIG_REST2LDAP_UNABLE_READ;
+import static org.opends.messages.ConfigMessages.ERR_CONFIG_REST2LDAP_UNEXPECTED_JSON;
 import static org.opends.server.util.StaticUtils.getFileForPath;
 import static org.opends.server.util.StaticUtils.stackTraceToSingleLineString;
 
+import java.io.File;
 import java.io.IOException;
-import java.io.InputStream;
-import java.net.MalformedURLException;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.net.URL;
 
 import org.forgerock.http.Handler;
 import org.forgerock.http.HttpApplication;
 import org.forgerock.http.HttpApplicationException;
 import org.forgerock.http.io.Buffer;
-import org.forgerock.json.JsonValue;
 import org.forgerock.json.JsonValueException;
-import org.forgerock.json.resource.Router;
-import org.forgerock.json.resource.http.CrestHttp;
-import org.forgerock.opendj.rest2ldap.Rest2Ldap;
 import org.forgerock.opendj.server.config.server.Rest2ldapEndpointCfg;
 import org.forgerock.util.Factory;
 import org.opends.server.api.HttpEndpoint;
@@ -45,13 +41,13 @@
 
 /**
  * Encapsulates configuration required to start a REST2LDAP application embedded
- * in this LDAP server. Acts as a factory for {@link Rest2LDAPHttpApplication}.
+ * in this LDAP server. Acts as a factory for {@link HttpApplication}.
  */
 public final class Rest2LdapEndpoint extends HttpEndpoint<Rest2ldapEndpointCfg>
 {
 
   /**
-   * Create a new Rest2LdapEnpoint with the supplied configuration.
+   * Create a new Rest2LdapEndpoint with the supplied configuration.
    *
    * @param configuration
    *          Configuration to use for the {@link HttpApplication}
@@ -70,71 +66,32 @@
   }
 
   /**
-   * Specialized {@link Rest2LDAPHttpApplication} using internal connections to
-   * this local LDAP server.
+   * Specialized {@link HttpApplication} using internal connections to this local LDAP server.
    */
   private final class InternalRest2LDAPHttpApplication implements HttpApplication
   {
-    private final URL configURL;
-
-    InternalRest2LDAPHttpApplication() throws InitializationException
-    {
-      try
-      {
-        final URI configURI = new URI(configuration.getConfigUrl());
-        configURL = configURI.isAbsolute()
-            ? configURI.toURL()
-            : getFileForPath(configuration.getConfigUrl()).toURI().toURL();
-      }
-      catch (MalformedURLException | URISyntaxException e)
-      {
-        throw new InitializationException(
-            ERR_CONFIG_REST2LDAP_MALFORMED_URL.get(configuration.dn(), stackTraceToSingleLineString(e)));
-      }
-    }
-
     @Override
     public Handler start() throws HttpApplicationException
     {
-      JsonValue mappingConfiguration;
+      final File endpointConfig = getFileForPath(configuration.getConfigDirectory(), serverContext);
       try
       {
-        mappingConfiguration = readJson(configURL);
+        return newHttpHandler(configureEndpoint(endpointConfig, defaultOptions()));
       }
       catch (IOException e)
       {
-        throw new LocalizedHttpApplicationException(
-            ERR_CONFIG_REST2LDAP_UNABLE_READ.get(configURL, configuration.dn(), stackTraceToSingleLineString(e)), e);
-      }
-      final JsonValue mappings = mappingConfiguration.get("mappings").required();
-      final Router router = new Router();
-      try
-      {
-        for (final String mappingUrl : mappings.keys())
-        {
-          final JsonValue mapping = mappings.get(mappingUrl);
-          router.addRoute(Router.uriTemplate(mappingUrl), Rest2Ldap.builder().configureMapping(mapping).build());
-        }
+        throw new LocalizedHttpApplicationException(ERR_CONFIG_REST2LDAP_UNABLE_READ.get(
+                endpointConfig, configuration.dn(), stackTraceToSingleLineString(e)), e);
       }
       catch (JsonValueException e)
       {
-        throw new LocalizedHttpApplicationException(
-            ERR_CONFIG_REST2LDAP_UNEXPECTED_JSON.get(e.getJsonValue().getPointer(), configURL, configuration.dn(),
-                                                     stackTraceToSingleLineString(e)), e);
+        throw new LocalizedHttpApplicationException(ERR_CONFIG_REST2LDAP_UNEXPECTED_JSON.get(
+                e.getJsonValue().getPointer(), endpointConfig, configuration.dn(), stackTraceToSingleLineString(e)), e);
       }
       catch (IllegalArgumentException e)
       {
-        throw new LocalizedHttpApplicationException(
-            ERR_CONFIG_REST2LDAP_INVALID.get(configURL, configuration.dn(), stackTraceToSingleLineString(e)), e);
-      }
-      return CrestHttp.newHttpHandler(router);
-    }
-
-    private JsonValue readJson(final URL resource) throws IOException
-    {
-      try (InputStream in = resource.openStream())
-      {
-        return new JsonValue(readJsonLenient(in));
+        throw new LocalizedHttpApplicationException(ERR_CONFIG_REST2LDAP_INVALID.get(
+                endpointConfig, configuration.dn(), stackTraceToSingleLineString(e)), e);
       }
     }
 

--
Gitblit v1.10.0