From 8dd4c87972373e0eb1c074905819ae51187f98ad Mon Sep 17 00:00:00 2001
From: Matthew Swift <matthew.swift@forgerock.com>
Date: Fri, 21 Oct 2016 09:55:23 +0000
Subject: [PATCH] OPENDJ-3414 Support deep querying of LDAP attributes having JSON syntax

---
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java |    5 
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/JsonPropertyMapperTest.java    |   61 ++++++++++++
 opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties      |    4 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2Ldap.java                 |   22 ++++
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JsonPropertyMapper.java        |  194 ++++++++++++++++++++++++++++++++++++++
 5 files changed, 281 insertions(+), 5 deletions(-)

diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JsonPropertyMapper.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JsonPropertyMapper.java
new file mode 100644
index 0000000..9ffd810
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JsonPropertyMapper.java
@@ -0,0 +1,194 @@
+/*
+ * 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.emptyList;
+import static java.util.Collections.singletonList;
+import static org.forgerock.json.JsonValue.field;
+import static org.forgerock.json.JsonValue.object;
+import static org.forgerock.opendj.rest2ldap.Rest2Ldap.asResourceException;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_ENCODING_VALUES_FOR_FIELD;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_PATCH_JSON_INTERNAL_PROPERTY;
+import static org.forgerock.opendj.rest2ldap.Utils.jsonToAttribute;
+import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException;
+import static org.forgerock.opendj.rest2ldap.Utils.newNotSupportedException;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.byteStringToJson;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.jsonToByteString;
+import static org.forgerock.util.promise.Promises.newResultPromise;
+
+import java.util.ArrayList;
+import java.util.Collection;
+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.AttributeDescription;
+import org.forgerock.opendj.ldap.Entry;
+import org.forgerock.opendj.ldap.Filter;
+import org.forgerock.opendj.ldap.Modification;
+import org.forgerock.services.context.Context;
+import org.forgerock.util.promise.Promise;
+import org.forgerock.util.query.QueryFilter;
+
+/** A property mapper which provides a mapping from a JSON value to an LDAP attribute having the JSON syntax. */
+public final class JsonPropertyMapper extends AbstractLdapPropertyMapper<JsonPropertyMapper> {
+    /**
+     * The default JSON schema for this property. According to json-schema.org the {} schema allows anything.
+     * However, the OpenAPI transformer seems to expect at least a "type" field to be present.
+     */
+    private static final JsonValue ANY_SCHEMA = new JsonValue(object(field("type", "object")));
+    private JsonValue jsonSchema = ANY_SCHEMA;
+
+    JsonPropertyMapper(final AttributeDescription ldapAttributeName) {
+        super(ldapAttributeName);
+    }
+
+    /**
+     * 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.
+     * @return This property mapper.
+     */
+    public JsonPropertyMapper defaultJsonValue(final Object defaultValue) {
+        this.defaultJsonValues = defaultValue != null ? singletonList(defaultValue) : emptyList();
+        return this;
+    }
+
+    /**
+     * 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 JsonPropertyMapper defaultJsonValues(final Collection<?> defaultValues) {
+        this.defaultJsonValues = defaultValues != null ? new ArrayList<>(defaultValues) : emptyList();
+        return this;
+    }
+
+    /**
+     * Sets the JSON schema corresponding to this simple property mapper. If not {@code null},
+     * it will be returned by {@link #toJsonSchema()}, otherwise a default JSON schema will be
+     * automatically generated with the information available in this property mapper.
+     *
+     * @param jsonSchema
+     *         the JSON schema corresponding to this simple property mapper. Can be {@code null}
+     * @return This property mapper.
+     */
+    public JsonPropertyMapper jsonSchema(JsonValue jsonSchema) {
+        this.jsonSchema = jsonSchema != null ? jsonSchema : ANY_SCHEMA;
+        return this;
+    }
+
+    @Override
+    public String toString() {
+        return "json(" + ldapAttributeName + ")";
+    }
+
+    @Override
+    Promise<Filter, ResourceException> getLdapFilter(final Context context, final Resource resource,
+                                                     final JsonPointer path, final JsonPointer subPath,
+                                                     final FilterType type, final String operator,
+                                                     final Object valueAssertion) {
+        final QueryFilter<JsonPointer> queryFilter = toQueryFilter(type, subPath, operator, valueAssertion);
+        return newResultPromise(Filter.equality(ldapAttributeName.toString(), queryFilter));
+    }
+
+    private QueryFilter<JsonPointer> toQueryFilter(final FilterType type, final JsonPointer subPath,
+                                                   final String operator, final Object valueAssertion) {
+        switch (type) {
+        case CONTAINS:
+            return QueryFilter.contains(subPath, valueAssertion);
+        case STARTS_WITH:
+            return QueryFilter.startsWith(subPath, valueAssertion);
+        case EQUAL_TO:
+            return QueryFilter.equalTo(subPath, valueAssertion);
+        case GREATER_THAN:
+            return QueryFilter.greaterThan(subPath, valueAssertion);
+        case GREATER_THAN_OR_EQUAL_TO:
+            return QueryFilter.greaterThanOrEqualTo(subPath, valueAssertion);
+        case LESS_THAN:
+            return QueryFilter.lessThan(subPath, valueAssertion);
+        case LESS_THAN_OR_EQUAL_TO:
+            return QueryFilter.lessThanOrEqualTo(subPath, valueAssertion);
+        case PRESENT:
+            return QueryFilter.present(subPath);
+        case EXTENDED:
+            return QueryFilter.extendedMatch(subPath, operator, valueAssertion);
+        default:
+            return QueryFilter.alwaysFalse();
+        }
+    }
+
+    @Override
+    Promise<Attribute, ResourceException> getNewLdapAttributes(final Context context, final Resource resource,
+                                                               final JsonPointer path, final List<Object> newValues) {
+        try {
+            return newResultPromise(jsonToAttribute(newValues, ldapAttributeName, jsonToByteString()));
+        } catch (final Exception e) {
+            return newBadRequestException(ERR_ENCODING_VALUES_FOR_FIELD.get(path, e.getMessage())).asPromise();
+        }
+    }
+
+    @Override
+    JsonPropertyMapper getThis() {
+        return this;
+    }
+
+    /** Intercept attempts to patch internal fields and reject these as unsupported rather than unrecognized. */
+    @Override
+    Promise<List<Modification>, ResourceException> patch(final Context context, final Resource resource,
+                                                         final JsonPointer path, final PatchOperation operation) {
+        final JsonPointer field = operation.getField();
+        if (field.isEmpty() || field.size() == 1 && field.get(0).equals("-")) {
+            return super.patch(context, resource, path, operation);
+        }
+        return newNotSupportedException(ERR_PATCH_JSON_INTERNAL_PROPERTY.get(field, path, path)).asPromise();
+    }
+
+    @SuppressWarnings("fallthrough")
+    @Override
+    Promise<JsonValue, ResourceException> read(final Context context, final Resource resource, final JsonPointer path,
+                                               final Entry e) {
+        try {
+            final Set<Object> s = e.parseAttribute(ldapAttributeName).asSetOf(byteStringToJson(), 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)));
+            }
+        } catch (final Exception ex) {
+            // The LDAP attribute could not be decoded.
+            return asResourceException(ex).asPromise();
+        }
+    }
+
+    @Override
+    JsonValue toJsonSchema() {
+        return jsonSchema;
+    }
+}
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 ae42e86..114b0de 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
@@ -295,6 +295,28 @@
     }
 
     /**
+     * Returns a property mapper which provides a mapping from a JSON value to a LDAP attribute having the JSON syntax.
+     *
+     * @param attribute
+     *         The LDAP attribute to be mapped.
+     * @return The property mapper.
+     */
+    public static JsonPropertyMapper json(final AttributeDescription attribute) {
+        return new JsonPropertyMapper(attribute);
+    }
+
+    /**
+     * Returns a property mapper which provides a mapping from a JSON value to a LDAP attribute having the JSON syntax.
+     *
+     * @param attribute
+     *         The LDAP attribute to be mapped.
+     * @return The property mapper.
+     */
+    public static JsonPropertyMapper json(final String attribute) {
+        return json(AttributeDescription.valueOf(attribute));
+    }
+
+    /**
      * 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.
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java
index d902445..65572d5 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java
@@ -79,7 +79,6 @@
 import org.forgerock.opendj.ldap.SSLContextBuilder;
 import org.forgerock.opendj.ldap.requests.BindRequest;
 import org.forgerock.opendj.ldap.requests.Requests;
-import org.forgerock.opendj.rest2ldap.schema.JsonSchema;
 import org.forgerock.services.context.Context;
 import org.forgerock.util.Options;
 import org.forgerock.util.promise.Promise;
@@ -358,12 +357,10 @@
                     .isMultiValued(mapper.get("isMultiValued").defaultTo(false).asBoolean())
                     .writability(parseWritability(mapper));
         case "json":
-            return simple(mapper.get("ldapAttribute").defaultTo(defaultLdapAttribute).required().asString())
+            return json(mapper.get("ldapAttribute").defaultTo(defaultLdapAttribute).required().asString())
                     .defaultJsonValue(mapper.get("defaultJsonValue").getObject())
                     .isRequired(mapper.get("isRequired").defaultTo(false).asBoolean())
                     .isMultiValued(mapper.get("isMultiValued").defaultTo(false).asBoolean())
-                    .encoder(JsonSchema.jsonToByteString())
-                    .decoder(JsonSchema.byteStringToJson())
                     .jsonSchema(mapper.isDefined("schema") ? mapper.get("schema") : null)
                     .writability(parseWritability(mapper));
         case "reference":
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 225bf31..3c1e587 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
@@ -145,4 +145,6 @@
 ERR_JSON_TRAILING_CONTENT_87=The value could not be parsed as valid JSON because it contains trailing content after the JSON value
 ERR_JSON_EMPTY_CONTENT_88=The value could not be parsed as valid JSON because it is empty
 ERR_JSON_QUERY_PARSE_ERROR_89=The value '%s' could not be parsed as a valid JSON query filter
-
+ERR_PATCH_JSON_INTERNAL_PROPERTY_90=The patch request cannot be processed because it attempts to modify the \
+  internal field '%s' of object '%s'. This capability is not currently supported by Rest2Ldap. Applications should \
+  instead perform a patch which replaces the entire object '%s'
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/JsonPropertyMapperTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/JsonPropertyMapperTest.java
new file mode 100644
index 0000000..675f05b
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/JsonPropertyMapperTest.java
@@ -0,0 +1,61 @@
+/*
+ * 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.forgerock.opendj.rest2ldap.FilterType.EQUAL_TO;
+import static org.forgerock.opendj.rest2ldap.FilterType.GREATER_THAN;
+
+import org.forgerock.json.JsonPointer;
+import org.forgerock.opendj.ldap.Filter;
+import org.forgerock.testng.ForgeRockTestCase;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+@SuppressWarnings("javadoc")
+@Test
+public class JsonPropertyMapperTest extends ForgeRockTestCase {
+    private final JsonPropertyMapper mapper = Rest2Ldap.json("json");
+
+    @DataProvider
+    static Object[][] ldapFilters() {
+        // @formatter:off
+        return new Object[][] {
+            { "x/y", "z", EQUAL_TO, "string", "(json=/z eq \"string\")" },
+            { "x/y", "", GREATER_THAN, 123, "(json=/ gt 123)" },
+        };
+        // @formatter:on
+    }
+
+    @Test(dataProvider = "ldapFilters")
+    public void testGetLdapFilter(final String path, final String subPath, final FilterType filterType,
+                                  final Object assertion, final String expected) throws Exception {
+        final Filter filter = mapper.getLdapFilter(null,
+                                                   null,
+                                                   new JsonPointer(path),
+                                                   new JsonPointer(subPath),
+                                                   filterType,
+                                                   null,
+                                                   assertion).getOrThrowUninterruptibly();
+        assertThat(filter.toString()).isEqualTo(expected);
+    }
+
+    @Test
+    public void testToJsonSchema() throws Exception {
+        assertThat(mapper.toJsonSchema()).isNotNull();
+        assertThat(mapper.toJsonSchema().isMap()).isTrue();
+    }
+}

--
Gitblit v1.10.0