mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Matthew Swift
20.59.2016 8dd4c87972373e0eb1c074905819ae51187f98ad
OPENDJ-3414 Support deep querying of LDAP attributes having JSON syntax

In addition, also return a more meaningful error response when clients
attempt to patch internal fields of a JSON attribute.
2 files added
3 files modified
286 ■■■■■ changed files
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JsonPropertyMapper.java 194 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2Ldap.java 22 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LdapJsonConfigurator.java 5 ●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties 4 ●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/JsonPropertyMapperTest.java 61 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/JsonPropertyMapper.java
New file
@@ -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;
    }
}
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.
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":
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'
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/JsonPropertyMapperTest.java
New file
@@ -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();
    }
}