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

Matthew Swift
29.40.2016 95d5c1406fb12c27d1c906063c9daccde36329ca
OPENDJ-2860: implement JSON attribute syntaxes and matching rules

* added "Json" syntax whose values must be valid JSON
* added "Json Query" syntax whose values must be valid CREST queries
* added "caseIgnoreJsonQueryMatch" and "caseExactJsonQueryMatch"
equality matching rule for matching JSON values against CREST query
filters
* added JsonSchema utility class for accessing syntaxes and matching
rule, as well as creating new custom matching rules.
8 files added
1 files modified
2182 ■■■■■ changed files
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImpl.java 845 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImpl.java 75 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSchema.java 253 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImpl.java 123 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/package-info.java 95 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties 6 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImplTest.java 574 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImplTest.java 109 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImplTest.java 102 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImpl.java
New file
@@ -0,0 +1,845 @@
/*
 * 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.schema;
import static com.fasterxml.jackson.core.JsonToken.END_ARRAY;
import static com.fasterxml.jackson.core.JsonToken.END_OBJECT;
import static com.forgerock.opendj.util.StringPrepProfile.prepareUnicode;
import static org.forgerock.opendj.ldap.Assertion.UNDEFINED_ASSERTION;
import static org.forgerock.opendj.ldap.schema.CoreSchema.getIntegerMatchingRule;
import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.CASE_SENSITIVE_STRINGS;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.IGNORE_WHITE_SPACE;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.INDEXED_FIELD_PATTERNS;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.ValidationPolicy.LENIENT;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Pattern;
import org.forgerock.json.JsonPointer;
import org.forgerock.json.JsonValue;
import org.forgerock.json.resource.QueryFilters;
import org.forgerock.opendj.ldap.Assertion;
import org.forgerock.opendj.ldap.ByteSequence;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.ByteStringBuilder;
import org.forgerock.opendj.ldap.ConditionResult;
import org.forgerock.opendj.ldap.DecodeException;
import org.forgerock.opendj.ldap.schema.MatchingRuleImpl;
import org.forgerock.opendj.ldap.schema.Schema;
import org.forgerock.opendj.ldap.spi.IndexQueryFactory;
import org.forgerock.opendj.ldap.spi.Indexer;
import org.forgerock.opendj.ldap.spi.IndexingOptions;
import org.forgerock.util.Options;
import org.forgerock.util.query.QueryFilter;
import org.forgerock.util.query.QueryFilterVisitor;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.JsonToken;
/**
 * This class implements the jsonQueryMatch equality matching rule whose assertion syntax is a
 * CREST {@link QueryFilter} and whose string syntax is defined in {@link org.forgerock.util.query.QueryFilterParser}.
 */
final class JsonQueryEqualityMatchingRuleImpl implements MatchingRuleImpl {
    // Separator bytes used when encoding JSON keys. Null sorts before false, sorts before true, etc.
    // Package private for testing.
    private static final int KEY_FIELD_START = 0;
    private static final int KEY_FIELD_END = 1;
    private static final int KEY_TYPE_NULL = 0;
    private static final int KEY_TYPE_FALSE = 1;
    private static final int KEY_TYPE_TRUE = 2;
    private static final int KEY_TYPE_NUMBER = 3;
    private static final int KEY_TYPE_STRING = 4;
    private final String indexID;
    private final boolean ignoreWhiteSpaceInStrings;
    private final boolean caseSensitiveStrings;
    private final List<Pattern> indexedFieldPatterns;
    private final QueryFilterVisitor<ConditionResult, JsonValue, JsonPointer> matcher = new Matcher();
    private final List<? extends Indexer> indexers = Collections.singletonList(new IndexerImpl());
    JsonQueryEqualityMatchingRuleImpl(final String indexID, Options options) {
        this.indexID = indexID;
        this.ignoreWhiteSpaceInStrings = options.get(IGNORE_WHITE_SPACE);
        this.caseSensitiveStrings = options.get(CASE_SENSITIVE_STRINGS);
        this.indexedFieldPatterns = compileWildCardPatterns(options.get(INDEXED_FIELD_PATTERNS));
    }
    private static List<Pattern> compileWildCardPatterns(final Collection<String> wildCardPatterns) {
        final List<Pattern> regexes = new ArrayList<>();
        for (final String wildCardPattern : wildCardPatterns) {
            regexes.add(compileWildCardPattern(wildCardPattern));
        }
        return regexes;
    }
    /**
     * Compiles a wild-card pattern into a regex taking care to normalize percent encoded characters, etc. This
     * method is package private for testing.
     */
    static Pattern compileWildCardPattern(final String wildCardPattern) {
        // Make the pattern easier to parse: replace multi-char sequences with a single char in order to avoid
        // having to maintain state during subsequent parsing phase.
        final char slashStarStar = '\u0000';
        final char starStar = '\u0001';
        final char star = '\u0002';
        final String normalizedPattern = new JsonPointer(wildCardPattern).toString()
                                                                         .replaceAll("/\\*\\*", "" + slashStarStar)
                                                                         .replaceAll("\\*\\*", "" + starStar)
                                                                         .replaceAll("\\*", "" + star);
        final StringBuilder builder = new StringBuilder();
        int elementStart = 0;
        for (int i = 0; i < normalizedPattern.length(); i++) {
            final char c = normalizedPattern.charAt(i);
            if (c <= star) {
                if (elementStart < i) {
                    // Escape and add literal substring.
                    builder.append(Pattern.quote(normalizedPattern.substring(elementStart, i)));
                }
                switch (c) {
                case slashStarStar:
                    builder.append("(/.*)?");
                    break;
                case starStar:
                    builder.append(".*");
                    break;
                case star:
                    builder.append("[^/]*");
                    break;
                }
                elementStart = i + 1;
            }
        }
        if (elementStart < normalizedPattern.length()) {
            // Escape and add remaining literal substring.
            builder.append(Pattern.quote(normalizedPattern.substring(elementStart)));
        }
        return Pattern.compile(builder.toString());
    }
    @Override
    public Assertion getAssertion(final Schema schema, final ByteSequence assertionValue) throws DecodeException {
        final QueryFilter<JsonPointer> queryFilter;
        try {
            queryFilter = QueryFilters.parse(assertionValue.toString());
        } catch (Exception e) {
            throw DecodeException.error(ERR_JSON_QUERY_PARSE_ERROR.get(assertionValue));
        }
        return new Assertion() {
            @Override
            public ConditionResult matches(final ByteSequence normalizedAttributeValue) {
                try (final InputStream inputStream = normalizedAttributeValue.asReader().asInputStream()) {
                    final Object object = LENIENT.getObjectMapper().readValue(inputStream, Object.class);
                    final JsonValue jsonValue = new JsonValue(object);
                    return queryFilter.accept(matcher, jsonValue);
                } catch (IOException e) {
                    // It may be that syntax validation was disabled when the attribute was created.
                    return ConditionResult.FALSE;
                }
            }
            @Override
            public <T> T createIndexQuery(final IndexQueryFactory<T> factory) throws DecodeException {
                return queryFilter.accept(new IndexQueryBuilder<T>(), factory);
            }
        };
    }
    @Override
    public Assertion getSubstringAssertion(final Schema schema, final ByteSequence subInitial,
                                           final List<? extends ByteSequence> subAnyElements,
                                           final ByteSequence subFinal) throws DecodeException {
        return UNDEFINED_ASSERTION;
    }
    @Override
    public Assertion getGreaterOrEqualAssertion(final Schema schema, final ByteSequence value) throws DecodeException {
        return UNDEFINED_ASSERTION;
    }
    @Override
    public Assertion getLessOrEqualAssertion(final Schema schema, final ByteSequence value) throws DecodeException {
        return UNDEFINED_ASSERTION;
    }
    @Override
    public ByteString normalizeAttributeValue(final Schema schema, final ByteSequence value) throws DecodeException {
        // The normalized representation is still valid JSON so that it can be reparsed during assertion matching.
        try (final InputStream inputStream = value.asReader().asInputStream();
             final JsonParser parser = LENIENT.getJsonFactory().createParser(inputStream)) {
            JsonToken jsonToken = parser.nextToken();
            if (jsonToken == null) {
                throw DecodeException.error(ERR_JSON_EMPTY_CONTENT.get());
            }
            final ByteStringBuilder normalizedValue = new ByteStringBuilder(value.length());
            normalizeJsonValue(parser, jsonToken, normalizedValue);
            if (parser.nextToken() != null) {
                throw DecodeException.error(ERR_JSON_TRAILING_CONTENT.get());
            }
            return normalizedValue.toByteString();
        } catch (DecodeException e) {
            throw e;
        } catch (JsonProcessingException e) {
            throw DecodeException.error(ERR_JSON_PARSE_ERROR.get(e.getLocation().getLineNr(),
                                                                 e.getLocation().getColumnNr(),
                                                                 e.getOriginalMessage()));
        } catch (IOException e) {
            throw DecodeException.error(ERR_JSON_IO_ERROR.get(e.getMessage()));
        }
    }
    private void normalizeJsonValue(final JsonParser parser, JsonToken jsonToken, final ByteStringBuilder builder)
            throws IOException {
        switch (jsonToken) {
        case START_OBJECT:
            final TreeMap<String, ByteSequence> normalizedObject = new TreeMap<>();
            while (parser.nextToken() != END_OBJECT) {
                final String key = parser.getCurrentName();
                final ByteStringBuilder value = new ByteStringBuilder();
                normalizeJsonValue(parser, parser.nextToken(), value);
                normalizedObject.put(key, value);
            }
            builder.appendByte('{');
            boolean isFirstField = true;
            for (Map.Entry<String, ByteSequence> keyValuePair : normalizedObject.entrySet()) {
                if (!isFirstField) {
                    builder.appendByte(',');
                }
                builder.appendByte('"');
                builder.appendUtf8(keyValuePair.getKey());
                builder.appendByte('"');
                builder.appendByte(':');
                builder.appendBytes(keyValuePair.getValue());
                isFirstField = false;
            }
            builder.appendByte('}');
            break;
        case START_ARRAY:
            builder.appendByte('[');
            boolean isFirstElement = true;
            while ((jsonToken = parser.nextToken()) != END_ARRAY) {
                if (!isFirstElement) {
                    builder.appendByte(',');
                }
                normalizeJsonValue(parser, jsonToken, builder);
                isFirstElement = false;
            }
            builder.appendByte(']');
            break;
        case VALUE_STRING:
            builder.appendByte('"');
            builder.appendUtf8(normalizeString(parser.getText()));
            builder.appendByte('"');
            break;
        case VALUE_NUMBER_INT:
        case VALUE_NUMBER_FLOAT:
            builder.appendUtf8(parser.getNumberValue().toString());
            break;
        case VALUE_TRUE:
        case VALUE_FALSE:
        case VALUE_NULL:
            builder.appendUtf8(parser.getText());
            break;
        case END_OBJECT:
        case END_ARRAY:
        case FIELD_NAME:
        case NOT_AVAILABLE:
        case VALUE_EMBEDDED_OBJECT:
            // Should not happen.
            throw new IllegalStateException();
        }
    }
    /** Normalize strings in a similar manner to LDAP's directory string matching rules. */
    private String normalizeString(final String string) {
        final StringBuilder builder = new StringBuilder(string.length());
        prepareUnicode(builder, ByteString.valueOfUtf8(string), ignoreWhiteSpaceInStrings, !caseSensitiveStrings);
        if (builder.length() == 0 && string.length() > 0) {
            return " ";
        }
        return builder.toString();
    }
    @Override
    public Collection<? extends Indexer> createIndexers(final IndexingOptions options) {
        return indexers;
    }
    private class IndexerImpl implements Indexer {
        @Override
        public String getIndexID() {
            return indexID;
        }
        @Override
        public void createKeys(final Schema schema, final ByteSequence value, final Collection<ByteString> keys)
                throws DecodeException {
            try (final InputStream inputStream = value.asReader().asInputStream();
                 final JsonParser parser = LENIENT.getJsonFactory().createParser(inputStream)) {
                JsonToken jsonToken = parser.nextToken();
                if (jsonToken == null) {
                    throw DecodeException.error(ERR_JSON_EMPTY_CONTENT.get());
                }
                JsonPointer parentJsonPointer = new JsonPointer();
                JsonPointer jsonPointer = new JsonPointer();
                String normalizedJsonPointer = normalizeJsonPointer(jsonPointer);
                final ByteStringBuilder builder = new ByteStringBuilder();
                int depth = 0;
                do {
                    switch (jsonToken) {
                    case START_OBJECT:
                        parentJsonPointer = jsonPointer;
                        depth++;
                        break;
                    case START_ARRAY:
                        // Ignore array indices and instead treat elements as if they were multiple values for the
                        // current pointer.
                        depth++;
                        break;
                    case END_OBJECT:
                        jsonPointer = parentJsonPointer;
                        normalizedJsonPointer = normalizeJsonPointer(jsonPointer);
                        parentJsonPointer = parentJsonPointer.parent();
                        depth--;
                        break;
                    case END_ARRAY:
                        depth--;
                        break;
                    case FIELD_NAME:
                        // Normalize for the pathological case where a field name happens to be a number.
                        jsonPointer = parentJsonPointer.child(parser.getCurrentName());
                        normalizedJsonPointer = normalizeJsonPointer(jsonPointer);
                        break;
                    case VALUE_NULL:
                        if (isFieldIndexed(normalizedJsonPointer)) {
                            createFieldStartIndexKey(normalizedJsonPointer, builder);
                            keys.add(createNullIndexKey(builder));
                        }
                        break;
                    case VALUE_FALSE:
                        if (isFieldIndexed(normalizedJsonPointer)) {
                            createFieldStartIndexKey(normalizedJsonPointer, builder);
                            keys.add(createBooleanIndexKey(builder, false));
                        }
                        break;
                    case VALUE_TRUE:
                        if (isFieldIndexed(normalizedJsonPointer)) {
                            createFieldStartIndexKey(normalizedJsonPointer, builder);
                            keys.add(createBooleanIndexKey(builder, true));
                        }
                        break;
                    case VALUE_NUMBER_INT:
                    case VALUE_NUMBER_FLOAT:
                        if (isFieldIndexed(normalizedJsonPointer)) {
                            createFieldStartIndexKey(normalizedJsonPointer, builder);
                            keys.add(createNumberIndexKey(builder, parser.getDecimalValue()));
                        }
                        break;
                    case VALUE_STRING:
                        if (isFieldIndexed(normalizedJsonPointer)) {
                            createFieldStartIndexKey(normalizedJsonPointer, builder);
                            keys.add(createStringIndexKey(builder, parser.getText()));
                        }
                        break;
                    case NOT_AVAILABLE:
                    case VALUE_EMBEDDED_OBJECT:
                        // Should not happen.
                        throw new IllegalStateException();
                    }
                    builder.setLength(0);
                    jsonToken = parser.nextToken();
                } while (depth > 0);
                if (parser.nextToken() != null) {
                    throw DecodeException.error(ERR_JSON_TRAILING_CONTENT.get());
                }
            } catch (DecodeException e) {
                throw e;
            } catch (JsonProcessingException e) {
                throw DecodeException.error(ERR_JSON_PARSE_ERROR.get(e.getLocation().getLineNr(),
                                                                     e.getLocation().getColumnNr(),
                                                                     e.getOriginalMessage()));
            } catch (IOException e) {
                throw DecodeException.error(ERR_JSON_IO_ERROR.get(e.getMessage()));
            }
        }
        @Override
        public String keyToHumanReadableString(final ByteSequence key) {
            return key.toByteString().toASCIIString();
        }
    }
    private boolean isFieldIndexed(final String normalizedJsonPointer) {
        // Default behavior is that all fields are indexed.
        if (indexedFieldPatterns.isEmpty()) {
            return true;
        }
        // The field is indexed if it matches any of the configured patterns.
        for (Pattern indexedFieldPattern : indexedFieldPatterns) {
            if (indexedFieldPattern.matcher(normalizedJsonPointer).matches()) {
                return true;
            }
        }
        return false;
    }
    private ByteString createFieldStartIndexKey(final String normalizedJsonPointer) {
        final ByteStringBuilder builder = new ByteStringBuilder(normalizedJsonPointer.length() + 1);
        createFieldStartIndexKey(normalizedJsonPointer, builder);
        return builder.toByteString();
    }
    private void createFieldStartIndexKey(final String normalizedJsonPointer, final ByteStringBuilder builder) {
        builder.appendUtf8(normalizedJsonPointer);
        builder.appendByte(KEY_FIELD_START);
    }
    private ByteString createFieldEndIndexKey(final String normalizedJsonPointer) {
        final ByteStringBuilder builder = new ByteStringBuilder();
        builder.appendUtf8(normalizedJsonPointer);
        builder.appendByte(KEY_FIELD_END);
        return builder.toByteString();
    }
    // Package private for testing.
    ByteString createIndexKey(final String normalizedJsonPointer, final Object value) {
        final ByteString fieldKey = createFieldStartIndexKey(normalizedJsonPointer);
        return createIndexKey(fieldKey, value).toByteString();
    }
    private ByteSequence createIndexKey(final ByteString fieldKey, final Object value) {
        final ByteStringBuilder builder = new ByteStringBuilder(fieldKey);
        if (value == null) {
            return createNullIndexKey(builder);
        } else if (value instanceof Number) {
            final Double doubleValue = ((Number) value).doubleValue();
            return createNumberIndexKey(builder, BigDecimal.valueOf(doubleValue));
        } else if (value instanceof Boolean) {
            final Boolean booleanValue = (Boolean) value;
            return createBooleanIndexKey(builder, booleanValue);
        } else { // String or something unexpected in which case convert it to a string.
            final String stringValue = normalizeString(value.toString());
            return createStringIndexKey(builder, stringValue);
        }
    }
    private ByteString createStringIndexKey(final ByteStringBuilder builder, final String string) {
        builder.appendByte(KEY_TYPE_STRING);
        builder.appendUtf8(normalizeString(string));
        return builder.toByteString();
    }
    private ByteString createNumberIndexKey(final ByteStringBuilder builder, final BigDecimal number) {
        // Re-use the integer matching rule in order to have a natural sort order. To do this we need
        // to first convert floating point numbers to an integer. We multiply by 10^6 in order to
        // preserve 6 decimal places of accuracy.
        builder.appendByte(KEY_TYPE_NUMBER);
        final ByteString micros = ByteString.valueOfObject(number.movePointRight(6).toBigInteger());
        try {
            builder.appendBytes(getIntegerMatchingRule().normalizeAttributeValue(micros));
        } catch (DecodeException e) {
            throw new RuntimeException(e); // Shouldn't happen since we know the value is valid.
        }
        return builder.toByteString();
    }
    private ByteString createBooleanIndexKey(final ByteStringBuilder builder, final boolean b) {
        builder.appendByte(b ? KEY_TYPE_TRUE : KEY_TYPE_FALSE);
        return builder.toByteString();
    }
    private ByteString createNullIndexKey(final ByteStringBuilder builder) {
        builder.appendByte(KEY_TYPE_NULL);
        return builder.toByteString();
    }
    /**
     * We need to strip out numeric JSON pointer elements in order to cope with our lack of wild-card support when
     * querying JSON arrays. Given the following JSON value:
     * <pre>
     *     {
     *         "array": [ "value1", "value2", "value3" ],
     *         "string": "value4",
     *         "123": "legal!"
     *     }
     * </pre>
     * We want to be able to perform queries against multi-valued fields without having to know the index of the
     * element that we are looking for. For example, the wild-card filter {@code /array/* eq 'value2'} should match the
     * above object because one of the array elements matches 'value2'. Unfortunately, there is no explicit wild-card
     * support for JSON pointers, so we support it implicitly instead. Thus the filter {@code /array eq 'value2'}
     * matches. We need to support explicit indexing though as well, so {@code /array/2 eq 'value2'} should match as
     * well. This makes indexing a bit trickier, since we effectively need to index each value twice, once with the
     * array index and once without.
     * <p/>
     * Indexes can return false positives, so a simple solution is to remove any JSON pointer tokens that look like
     * array indices. This even works in the rare case where object keys are numbers. The above object yields the
     * following keys:
     * <pre>
     *     array KEY_FIELD_START KEY_TYPE_STRING value1
     *     array KEY_FIELD_START KEY_TYPE_STRING value2
     *     array KEY_FIELD_START KEY_TYPE_STRING value3
     *     string KEY_FIELD_START KEY_TYPE_STRING value4
     *     &lt;empty&gt; KEY_FIELD_START KEY_TYPE_STRING legal!
     * </pre>
     */
    private String normalizeJsonPointer(final JsonPointer jsonPointer) {
        // Ensure that returned string has same encoding as JsonPointer.toString().
        for (int i = 0; i < jsonPointer.size(); i++) {
            final String token = jsonPointer.get(i);
            if (isArrayIndex(token)) {
                final ArrayList<String> tokens = new ArrayList<>(jsonPointer.size());
                for (int j = 0; j < jsonPointer.size(); j++) {
                    final String tokenj = jsonPointer.get(j);
                    if (j == i || (j > i && isArrayIndex(tokenj))) {
                        continue;
                    }
                    tokens.add(tokenj);
                }
                return new JsonPointer(tokens.toArray(new String[0])).toString();
            }
        }
        return jsonPointer.toString();
    }
    private boolean isArrayIndex(final String token) {
        final int length = token.length();
        if (length == 0) {
            return false;
        }
        for (int i = 0; i < length; i++) {
            final char c = token.charAt(i);
            if (c < '0' || c > '9') {
                return false;
            }
        }
        return true;
    }
    private class IndexQueryBuilder<T> implements QueryFilterVisitor<T, IndexQueryFactory<T>, JsonPointer> {
        @Override
        public T visitAndFilter(final IndexQueryFactory<T> indexQueryFactory,
                                final List<QueryFilter<JsonPointer>> subFilters) {
            final List<T> subQueries = new ArrayList<>(subFilters.size());
            for (QueryFilter<JsonPointer> subFilter : subFilters) {
                subQueries.add(subFilter.accept(this, indexQueryFactory));
            }
            return indexQueryFactory.createIntersectionQuery(subQueries);
        }
        @Override
        public T visitBooleanLiteralFilter(final IndexQueryFactory<T> indexQueryFactory, final boolean value) {
            return value ? indexQueryFactory.createMatchAllQuery()
                         : indexQueryFactory.createUnionQuery(Collections.<T>emptySet());
        }
        @Override
        public T visitContainsFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
                                     final Object valueAssertion) {
            // Not supported yet, but we can at least narrow down the set of candidates to those entries containing
            // the requested field.
            return visitPresentFilter(indexQueryFactory, field);
        }
        @Override
        public T visitEqualsFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
                                   final Object valueAssertion) {
            final String normalizedJsonPointer = normalizeJsonPointer(field);
            if (!isFieldIndexed(normalizedJsonPointer)) {
                return indexQueryFactory.createMatchAllQuery();
            }
            final ByteString fieldKey = createFieldStartIndexKey(normalizedJsonPointer);
            final ByteSequence key = createIndexKey(fieldKey, valueAssertion);
            return indexQueryFactory.createExactMatchQuery(indexID, key);
        }
        @Override
        public T visitExtendedMatchFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
                                          final String operator, final Object valueAssertion) {
            // Not supported, so the filter does not match any entries.
            return indexQueryFactory.createUnionQuery(Collections.<T>emptySet());
        }
        @Override
        public T visitGreaterThanFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
                                        final Object valueAssertion) {
            final String normalizedJsonPointer = normalizeJsonPointer(field);
            if (!isFieldIndexed(normalizedJsonPointer)) {
                return indexQueryFactory.createMatchAllQuery();
            }
            final ByteString fieldKey = createFieldStartIndexKey(normalizedJsonPointer);
            final ByteSequence startKey = createIndexKey(fieldKey, valueAssertion);
            final ByteString endKey = createFieldEndIndexKey(normalizedJsonPointer);
            return indexQueryFactory.createRangeMatchQuery(indexID, startKey, endKey, false, false);
        }
        @Override
        public T visitGreaterThanOrEqualToFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
                                                 final Object valueAssertion) {
            final String normalizedJsonPointer = normalizeJsonPointer(field);
            if (!isFieldIndexed(normalizedJsonPointer)) {
                return indexQueryFactory.createMatchAllQuery();
            }
            final ByteString fieldKey = createFieldStartIndexKey(normalizedJsonPointer);
            final ByteSequence startKey = createIndexKey(fieldKey, valueAssertion);
            final ByteString endKey = createFieldEndIndexKey(normalizedJsonPointer);
            return indexQueryFactory.createRangeMatchQuery(indexID, startKey, endKey, true, false);
        }
        @Override
        public T visitLessThanFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
                                     final Object valueAssertion) {
            final String normalizedJsonPointer = normalizeJsonPointer(field);
            if (!isFieldIndexed(normalizedJsonPointer)) {
                return indexQueryFactory.createMatchAllQuery();
            }
            final ByteString startKey = createFieldStartIndexKey(normalizedJsonPointer);
            final ByteSequence endKey = createIndexKey(startKey, valueAssertion);
            return indexQueryFactory.createRangeMatchQuery(indexID, startKey, endKey, false, false);
        }
        @Override
        public T visitLessThanOrEqualToFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
                                              final Object valueAssertion) {
            final String normalizedJsonPointer = normalizeJsonPointer(field);
            if (!isFieldIndexed(normalizedJsonPointer)) {
                return indexQueryFactory.createMatchAllQuery();
            }
            final ByteString startKey = createFieldStartIndexKey(normalizedJsonPointer);
            final ByteSequence endKey = createIndexKey(startKey, valueAssertion);
            return indexQueryFactory.createRangeMatchQuery(indexID, startKey, endKey, false, true);
        }
        @Override
        public T visitNotFilter(final IndexQueryFactory<T> indexQueryFactory,
                                final QueryFilter<JsonPointer> subFilter) {
            // It's not possible to generate a query for a NOT filter so just consider all entries as candidates.
            return indexQueryFactory.createMatchAllQuery();
        }
        @Override
        public T visitOrFilter(final IndexQueryFactory<T> indexQueryFactory,
                               final List<QueryFilter<JsonPointer>> subFilters) {
            final List<T> subQueries = new ArrayList<>(subFilters.size());
            for (QueryFilter<JsonPointer> subFilter : subFilters) {
                subQueries.add(subFilter.accept(this, indexQueryFactory));
            }
            return indexQueryFactory.createUnionQuery(subQueries);
        }
        @Override
        public T visitPresentFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field) {
            final String normalizedJsonPointer = normalizeJsonPointer(field);
            if (!isFieldIndexed(normalizedJsonPointer)) {
                return indexQueryFactory.createMatchAllQuery();
            }
            final ByteString startKey = createFieldStartIndexKey(normalizedJsonPointer);
            final ByteString endKey = createFieldEndIndexKey(normalizedJsonPointer);
            return indexQueryFactory.createRangeMatchQuery(indexID, startKey, endKey, true, false);
        }
        @Override
        public T visitStartsWithFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
                                       final Object valueAssertion) {
            final String normalizedJsonPointer = normalizeJsonPointer(field);
            if (!isFieldIndexed(normalizedJsonPointer)) {
                return indexQueryFactory.createMatchAllQuery();
            }
            // These assertions make sense for string values, but don't make much sense for other primitive types.
            if (valueAssertion instanceof String) {
                return visitGreaterThanOrEqualToFilter(indexQueryFactory, field, valueAssertion);
            }
            // Best effort: 'true' starts with 'true' and '123' starts with '123', etc.
            return visitEqualsFilter(indexQueryFactory, field, valueAssertion);
        }
    }
    private final class Matcher implements QueryFilterVisitor<ConditionResult, JsonValue, JsonPointer> {
        @Override
        public ConditionResult visitAndFilter(final JsonValue jsonValue,
                                              final List<QueryFilter<JsonPointer>> subFilters) {
            ConditionResult r = ConditionResult.TRUE;
            for (final QueryFilter<JsonPointer> subFilter : subFilters) {
                final ConditionResult p = subFilter.accept(this, jsonValue);
                if (p == ConditionResult.FALSE) {
                    return p;
                }
                r = ConditionResult.and(r, p);
            }
            return r;
        }
        @Override
        public ConditionResult visitBooleanLiteralFilter(final JsonValue jsonValue, final boolean value) {
            return ConditionResult.valueOf(value);
        }
        @Override
        public ConditionResult visitContainsFilter(final JsonValue jsonValue, final JsonPointer field,
                                                   final Object valueAssertion) {
            return visitComparisonFilter(jsonValue, field, valueAssertion, FilterType.CONTAINS);
        }
        @Override
        public ConditionResult visitEqualsFilter(final JsonValue jsonValue, final JsonPointer field,
                                                 final Object valueAssertion) {
            return visitComparisonFilter(jsonValue, field, valueAssertion, FilterType.EQUALS);
        }
        @Override
        public ConditionResult visitExtendedMatchFilter(final JsonValue jsonValue, final JsonPointer field,
                                                        final String operator, final Object valueAssertion) {
            return ConditionResult.UNDEFINED; // Not supported.
        }
        @Override
        public ConditionResult visitGreaterThanFilter(final JsonValue jsonValue, final JsonPointer field,
                                                      final Object valueAssertion) {
            return visitComparisonFilter(jsonValue, field, valueAssertion, FilterType.GREATER_THAN);
        }
        @Override
        public ConditionResult visitGreaterThanOrEqualToFilter(final JsonValue jsonValue, final JsonPointer field,
                                                               final Object valueAssertion) {
            return visitComparisonFilter(jsonValue, field, valueAssertion, FilterType.GREATER_THAN_OR_EQUAL_TO);
        }
        @Override
        public ConditionResult visitLessThanFilter(final JsonValue jsonValue, final JsonPointer field,
                                                   final Object valueAssertion) {
            return visitComparisonFilter(jsonValue, field, valueAssertion, FilterType.LESS_THAN);
        }
        @Override
        public ConditionResult visitLessThanOrEqualToFilter(final JsonValue jsonValue, final JsonPointer field,
                                                            final Object valueAssertion) {
            return visitComparisonFilter(jsonValue, field, valueAssertion, FilterType.LESS_THAN_OR_EQUAL_TO);
        }
        @Override
        public ConditionResult visitNotFilter(final JsonValue jsonValue, final QueryFilter<JsonPointer> subFilter) {
            return ConditionResult.not(subFilter.accept(this, jsonValue));
        }
        @Override
        public ConditionResult visitOrFilter(final JsonValue jsonValue,
                                             final List<QueryFilter<JsonPointer>> subFilters) {
            ConditionResult r = ConditionResult.FALSE;
            for (final QueryFilter<JsonPointer> subFilter : subFilters) {
                final ConditionResult p = subFilter.accept(this, jsonValue);
                if (p == ConditionResult.TRUE) {
                    return p;
                }
                r = ConditionResult.or(r, p);
            }
            return r;
        }
        @Override
        public ConditionResult visitPresentFilter(final JsonValue jsonValue, final JsonPointer field) {
            return ConditionResult.valueOf(jsonValue.get(field) != null);
        }
        @Override
        public ConditionResult visitStartsWithFilter(final JsonValue jsonValue, final JsonPointer field,
                                                     final Object valueAssertion) {
            return visitComparisonFilter(jsonValue, field, valueAssertion, FilterType.STARTS_WITH);
        }
        private ConditionResult visitComparisonFilter(final JsonValue jsonValue, final JsonPointer field,
                                                      final Object valueAssertion, final FilterType equals) {
            final JsonValue jsonValueField = jsonValue.get(field);
            if (jsonValueField == null || jsonValueField.isMap()) {
                return ConditionResult.FALSE;
            }
            if (jsonValueField.isList()) {
                for (Object listElement : jsonValueField.asList()) {
                    if (compare(equals, valueAssertion, listElement)) {
                        return ConditionResult.TRUE;
                    }
                }
                return ConditionResult.FALSE;
            } else {
                return ConditionResult.valueOf(compare(equals, valueAssertion, jsonValueField.getObject()));
            }
        }
        private boolean compare(final FilterType type, final Object assertion, final Object value) {
            if (assertion instanceof String && value instanceof String) {
                final String stringAssertion = normalizeString((String) assertion);
                final String stringValue = normalizeString((String) value);
                switch (type) {
                case CONTAINS:
                    return stringValue.contains(stringAssertion);
                case STARTS_WITH:
                    return stringValue.startsWith(stringAssertion);
                default:
                    return compare0(type, stringAssertion, stringValue);
                }
            } else if (assertion instanceof Number && value instanceof Number) {
                final Double doubleAssertion = ((Number) assertion).doubleValue();
                final Double doubleValue = ((Number) value).doubleValue();
                return compare0(type, doubleAssertion, doubleValue);
            } else if (assertion instanceof Boolean && value instanceof Boolean) {
                final Boolean booleanAssertion = (Boolean) assertion;
                final Boolean booleanValue = (Boolean) value;
                return compare0(type, booleanAssertion, booleanValue);
            } else {
                return false;
            }
        }
        private <T extends Comparable<T>> boolean compare0(final FilterType type, final T assertion, final T value) {
            switch (type) {
            case EQUALS:
            case CONTAINS:
            case STARTS_WITH:
                return value.equals(assertion);
            case GREATER_THAN:
                return value.compareTo(assertion) > 0;
            case GREATER_THAN_OR_EQUAL_TO:
                return value.compareTo(assertion) >= 0;
            case LESS_THAN:
                return value.compareTo(assertion) < 0;
            case LESS_THAN_OR_EQUAL_TO:
                return value.compareTo(assertion) <= 0;
            }
            return false;
        }
    }
    private enum FilterType {
        EQUALS,
        CONTAINS,
        GREATER_THAN,
        GREATER_THAN_OR_EQUAL_TO,
        LESS_THAN,
        LESS_THAN_OR_EQUAL_TO,
        STARTS_WITH
    }
}
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImpl.java
New file
@@ -0,0 +1,75 @@
/*
 * 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.schema;
import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_JSON_QUERY_PARSE_ERROR;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.SYNTAX_JSON_QUERY_DESCRIPTION;
import org.forgerock.i18n.LocalizableMessageBuilder;
import org.forgerock.json.resource.QueryFilters;
import org.forgerock.opendj.ldap.ByteSequence;
import org.forgerock.opendj.ldap.schema.Schema;
import org.forgerock.opendj.ldap.schema.SyntaxImpl;
/** This class implements the JSON query attribute syntax. */
final class JsonQuerySyntaxImpl implements SyntaxImpl {
    @Override
    public String getName() {
        return SYNTAX_JSON_QUERY_DESCRIPTION;
    }
    @Override
    public String getApproximateMatchingRule() {
        return null;
    }
    @Override
    public String getEqualityMatchingRule() {
        return null;
    }
    @Override
    public String getOrderingMatchingRule() {
        return null;
    }
    @Override
    public String getSubstringMatchingRule() {
        return null;
    }
    @Override
    public boolean isBEREncodingRequired() {
        return false;
    }
    @Override
    public boolean isHumanReadable() {
        return true;
    }
    @Override
    public boolean valueIsAcceptable(final Schema schema, final ByteSequence value,
                                     final LocalizableMessageBuilder invalidReason) {
        try {
            QueryFilters.parse(value.toString());
            return true;
        } catch (Exception e) {
            invalidReason.append(ERR_JSON_QUERY_PARSE_ERROR.get(value));
            return false;
        }
    }
}
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSchema.java
New file
@@ -0,0 +1,253 @@
/*
 * 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.schema;
import static com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_COMMENTS;
import static com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_SINGLE_QUOTES;
import static com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS;
import static java.util.Collections.emptyList;
import static org.forgerock.opendj.ldap.schema.Schema.getCoreSchema;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.ValidationPolicy.STRICT;
import static org.forgerock.util.Options.defaultOptions;
import java.util.Collection;
import org.forgerock.opendj.ldap.schema.MatchingRule;
import org.forgerock.opendj.ldap.schema.MatchingRuleImpl;
import org.forgerock.opendj.ldap.schema.Schema;
import org.forgerock.opendj.ldap.schema.SchemaBuilder;
import org.forgerock.opendj.ldap.schema.Syntax;
import org.forgerock.util.Option;
import org.forgerock.util.Options;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
 * Utility methods for obtaining JSON syntaxes and matching rules. See the package documentation for more detail.
 */
public final class JsonSchema {
    /** JSON value validation policies. */
    public enum ValidationPolicy {
        /** JSON validation policy requiring strict conformance to RFC 7159. */
        STRICT(new ObjectMapper()),
        /**
         * JSON validation policy requiring conformance to RFC 7159 with the following exceptions: 1) comments are
         * allowed, 2) single quotes may be used instead of double quotes, and 3) unquoted control characters are
         * allowed in strings.
         */
        LENIENT(new ObjectMapper().enable(ALLOW_COMMENTS)
                                  .enable(ALLOW_SINGLE_QUOTES)
                                  .enable(ALLOW_UNQUOTED_CONTROL_CHARS)),
        /** JSON validation policy which does not perform any validation. */
        DISABLED(null);
        private final ObjectMapper objectMapper;
        ValidationPolicy(final ObjectMapper objectMapper) {
            this.objectMapper = objectMapper;
        }
        final JsonFactory getJsonFactory() {
            return objectMapper.getFactory();
        }
        final ObjectMapper getObjectMapper() {
            return objectMapper;
        }
    }
    /**
     * Schema option controlling syntax validation for JSON based attributes. By default this compatibility option
     * is set to {@link ValidationPolicy#STRICT}.
     */
    public static final Option<ValidationPolicy> VALIDATION_POLICY = Option.withDefault(STRICT);
    /**
     * Matching rule option controlling whether JSON string comparisons should be case-sensitive. By default this
     * compatibility option is set to {@code false} meaning that case will be ignored.
     * <p>
     * This option must be provided when constructing a JSON matching rule using {@link
     * #newJsonQueryEqualityMatchingRuleImpl}, and cannot be overridden at the schema level.
     */
    public static final Option<Boolean> CASE_SENSITIVE_STRINGS = Option.withDefault(false);
    /**
     * Matching rule option controlling whether JSON string comparisons should ignore white-space. By default this
     * compatibility option is set to {@code true} meaning that leading and trailing white-space will be ignored and
     * intermediate white-space will be reduced to a single white-space character.
     * <p>
     * This option must be provided when constructing a JSON matching rule using {@link
     * #newJsonQueryEqualityMatchingRuleImpl}, and cannot be overridden at the schema level.
     */
    public static final Option<Boolean> IGNORE_WHITE_SPACE = Option.withDefault(true);
    /**
     * Matching rule option controlling which JSON fields should be indexed by the matching rule. By default all
     * fields will be indexed. To restrict the set of indexed fields specify a list whose values are wild-card
     * patterns for matching against JSON pointers. Patterns are JSON pointers where "*" represents zero or more
     * characters in a single path element, and "**" represents any number of path elements. For example:
     *
     * <table valign="top">
     *     <tr><th>Pattern</th><th>Matches</th><th>Doesn't match</th></tr>
     *     <tr><td>/aaa/bbb/ccc</td><td>/aaa/bbb/ccc</td><td>/aaa/bbb/ccc/ddd<br/>/aaa/bbb/cccc</td></tr>
     *     <tr><td>/aaa/b&#x002A;/ccc</td><td>/aaa/bbb/ccc<br/>/aaa/bxx/ccc</td><td>/aaa/xxx/ccc<br/>/aaa/bbb</td></tr>
     *     <tr><td>/aaa/&#x002A;&#x002A;/ddd</td><td>/aaa/ddd<br/>/aaa/xxx/yyy/ddd</td><td>/aaa/bbb/ccc</td></tr>
     *     <tr><td>/aaa/&#x002A;&#x002A;</td><td>/aaa<br/>/aaa/bbb<br/>/aaa/bbb/ccc<br/></td><td>/aa</td></tr>
     * </table>
     */
    @SuppressWarnings("unchecked")
    public static final Option<Collection<String>> INDEXED_FIELD_PATTERNS =
            (Option) Option.of(Collection.class, emptyList());
    /** The OID of the JSON attribute syntax. */
    static final String SYNTAX_JSON_OID = "1.3.6.1.4.1.36733.2.1.3.1";
    /** The description of the JSON attribute syntax. */
    static final String SYNTAX_JSON_DESCRIPTION = "Json";
    /** The OID of the JSON query attribute syntax. */
    static final String SYNTAX_JSON_QUERY_OID = "1.3.6.1.4.1.36733.2.1.3.2";
    /** The description of the JSON query attribute syntax. */
    static final String SYNTAX_JSON_QUERY_DESCRIPTION = "Json Query";
    /** The OID of the case insensitive JSON query equality matching rule. */
    static final String EMR_CASE_IGNORE_JSON_QUERY_OID = "1.3.6.1.4.1.36733.2.1.4.1";
    /** The name of the case insensitive JSON query equality matching rule. */
    static final String EMR_CASE_IGNORE_JSON_QUERY_NAME = "caseIgnoreJsonQueryMatch";
    /** The OID of the case sensitive JSON query equality matching rule. */
    static final String EMR_CASE_EXACT_JSON_QUERY_OID = "1.3.6.1.4.1.36733.2.1.4.2";
    /** The name of the case sensitive JSON query equality matching rule. */
    static final String EMR_CASE_EXACT_JSON_QUERY_NAME = "caseExactJsonQueryMatch";
    private static final Syntax JSON_SYNTAX;
    private static final Syntax JSON_QUERY_SYNTAX;
    private static final MatchingRule CASE_IGNORE_JSON_QUERY_MATCHING_RULE;
    private static final MatchingRule CASE_EXACT_JSON_QUERY_MATCHING_RULE;
    static {
        final Schema schema = addJsonSyntaxesAndMatchingRulesToSchema(new SchemaBuilder(getCoreSchema())).toSchema();
        JSON_SYNTAX = schema.getSyntax(SYNTAX_JSON_OID);
        JSON_QUERY_SYNTAX = schema.getSyntax(SYNTAX_JSON_QUERY_OID);
        CASE_IGNORE_JSON_QUERY_MATCHING_RULE = schema.getMatchingRule(EMR_CASE_IGNORE_JSON_QUERY_OID);
        CASE_EXACT_JSON_QUERY_MATCHING_RULE = schema.getMatchingRule(EMR_CASE_EXACT_JSON_QUERY_OID);
    }
    /**
     * Returns the JSON attribute syntax having the OID 1.3.6.1.4.1.36733.2.1.3.1. Attribute values of this syntax
     * must be valid JSON. Use the {@link #VALIDATION_POLICY} schema option to control the degree of syntax
     * enforcement. By default JSON attributes will support equality matching using the
     * {@link #getCaseIgnoreJsonQueryMatchingRule() jsonQueryMatch} matching rule, although this may be overridden
     * when defining individual attribute types.
     *
     * @return The JSON attribute syntax having the OID 1.3.6.1.4.1.36733.2.1.3.1.
     */
    public static Syntax getJsonSyntax() {
        return JSON_SYNTAX;
    }
    /**
     * Returns the JSON Query attribute syntax having the OID 1.3.6.1.4.1.36733.2.1.3.2. Attribute values of this
     * syntax must be valid CREST JSON {@link org.forgerock.util.query.QueryFilter query filter} strings as
     * defined in {@link org.forgerock.util.query.QueryFilterParser}.
     *
     * @return The JSON Query attribute syntax having the OID 1.3.6.1.4.1.36733.2.1.3.2.
     */
    public static Syntax getJsonQuerySyntax() {
        return JSON_QUERY_SYNTAX;
    }
    /**
     * Returns the {@code jsonQueryMatch} matching rule having the OID 1.3.6.1.4.1.36733.2.1.4.1. The
     * matching rule's assertion syntax is a {@link #getJsonQuerySyntax() CREST JSON query filter}. This matching
     * rule will ignore case when comparing JSON strings as well as ignoring white-space. In addition, all JSON
     * fields will be indexed if indexing is enabled.
     *
     * @return The @code jsonQueryMatch} matching rule having the OID 1.3.6.1.4.1.36733.2.1.4.1.
     */
    public static MatchingRule getCaseIgnoreJsonQueryMatchingRule() {
        return CASE_IGNORE_JSON_QUERY_MATCHING_RULE;
    }
    /**
     * Returns the {@code jsonQueryMatch} matching rule having the OID 1.3.6.1.4.1.36733.2.1.4.2. The
     * matching rule's assertion syntax is a {@link #getJsonQuerySyntax() CREST JSON query filter}. This matching
     * rule will ignore case when comparing JSON strings as well as ignoring white-space. In addition, all JSON
     * fields will be indexed if indexing is enabled.
     *
     * @return The @code jsonQueryMatch} matching rule having the OID 1.3.6.1.4.1.36733.2.1.4.2.
     */
    public static MatchingRule getCaseExactJsonQueryMatchingRule() {
        return CASE_EXACT_JSON_QUERY_MATCHING_RULE;
    }
    /**
     * Creates a new custom JSON query equality matching rule implementation with the provided matching rule name and
     * options. This method should be used when creating custom JSON matching rules whose behavior differs from
     * {@link #getCaseIgnoreJsonQueryMatchingRule()}.
     *
     * @param matchingRuleName
     *         The name of the matching rule. This will be used as the index ID in attribute indexes so it must not
     *         collide with other indexes identifiers.
     * @param options
     *         The options controlling the behavior of the matching rule.
     * @return The new custom JSON query equality matching rule implementation.
     * @see #CASE_SENSITIVE_STRINGS
     * @see #IGNORE_WHITE_SPACE
     */
    public static MatchingRuleImpl newJsonQueryEqualityMatchingRuleImpl(final String matchingRuleName,
                                                                        final Options options) {
        return new JsonQueryEqualityMatchingRuleImpl(matchingRuleName, options);
    }
    /**
     * Adds the syntaxes and matching rules required by for JSON attribute support to the provided schema builder.
     *
     * @param builder
     *         The schema builder to which the schema elements should be added.
     * @return The schema builder.
     */
    public static SchemaBuilder addJsonSyntaxesAndMatchingRulesToSchema(final SchemaBuilder builder) {
        builder.buildSyntax(SYNTAX_JSON_OID)
               .description(SYNTAX_JSON_DESCRIPTION)
               .implementation(new JsonSyntaxImpl())
               .extraProperties("X-ORIGIN", "OpenDJ Directory Server")
               .addToSchema();
        builder.buildSyntax(SYNTAX_JSON_QUERY_OID)
               .description(SYNTAX_JSON_QUERY_DESCRIPTION)
               .implementation(new JsonQuerySyntaxImpl())
               .extraProperties("X-ORIGIN", "OpenDJ Directory Server")
               .addToSchema();
        final JsonQueryEqualityMatchingRuleImpl caseIgnoreImpl = new JsonQueryEqualityMatchingRuleImpl(
                EMR_CASE_IGNORE_JSON_QUERY_NAME,
                defaultOptions().set(CASE_SENSITIVE_STRINGS, false).set(IGNORE_WHITE_SPACE, true));
        builder.buildMatchingRule(EMR_CASE_IGNORE_JSON_QUERY_OID)
               .names(EMR_CASE_IGNORE_JSON_QUERY_NAME)
               .syntaxOID(SYNTAX_JSON_QUERY_OID)
               .extraProperties("X-ORIGIN", "OpenDJ Directory Server")
               .implementation(caseIgnoreImpl)
               .addToSchema();
        final JsonQueryEqualityMatchingRuleImpl caseExactImpl = new JsonQueryEqualityMatchingRuleImpl(
                EMR_CASE_EXACT_JSON_QUERY_NAME,
                defaultOptions().set(CASE_SENSITIVE_STRINGS, true).set(IGNORE_WHITE_SPACE, true));
        builder.buildMatchingRule(EMR_CASE_EXACT_JSON_QUERY_OID)
               .names(EMR_CASE_EXACT_JSON_QUERY_NAME)
               .syntaxOID(SYNTAX_JSON_QUERY_OID)
               .extraProperties("X-ORIGIN", "OpenDJ Directory Server")
               .implementation(caseExactImpl)
               .addToSchema();
        return builder;
    }
    private JsonSchema() {
        // Prevent instantiation.
    }
}
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImpl.java
New file
@@ -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.schema;
import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_JSON_EMPTY_CONTENT;
import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_JSON_IO_ERROR;
import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_JSON_PARSE_ERROR;
import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_JSON_TRAILING_CONTENT;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.EMR_CASE_IGNORE_JSON_QUERY_OID;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.VALIDATION_POLICY;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.ValidationPolicy.DISABLED;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.SYNTAX_JSON_DESCRIPTION;
import java.io.IOException;
import java.io.InputStream;
import org.forgerock.i18n.LocalizableMessageBuilder;
import org.forgerock.opendj.ldap.ByteSequence;
import org.forgerock.opendj.ldap.schema.Schema;
import org.forgerock.opendj.ldap.schema.SyntaxImpl;
import org.forgerock.opendj.rest2ldap.schema.JsonSchema.ValidationPolicy;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.JsonToken;
/** This class implements the JSON attribute syntax. */
final class JsonSyntaxImpl implements SyntaxImpl {
    @Override
    public String getName() {
        return SYNTAX_JSON_DESCRIPTION;
    }
    @Override
    public String getApproximateMatchingRule() {
        return null;
    }
    @Override
    public String getEqualityMatchingRule() {
        return EMR_CASE_IGNORE_JSON_QUERY_OID;
    }
    @Override
    public String getOrderingMatchingRule() {
        return null;
    }
    @Override
    public String getSubstringMatchingRule() {
        return null;
    }
    @Override
    public boolean isBEREncodingRequired() {
        return false;
    }
    @Override
    public boolean isHumanReadable() {
        return true;
    }
    @Override
    public boolean valueIsAcceptable(final Schema schema, final ByteSequence value,
                                     final LocalizableMessageBuilder invalidReason) {
        final ValidationPolicy validationPolicy = schema.getOption(VALIDATION_POLICY);
        if (validationPolicy == DISABLED) {
            return true;
        }
        try (final InputStream inputStream = value.asReader().asInputStream();
             final JsonParser parser = validationPolicy.getJsonFactory().createParser(inputStream)) {
            JsonToken jsonToken = parser.nextToken();
            if (jsonToken == null) {
                invalidReason.append(ERR_JSON_EMPTY_CONTENT.get());
                return false;
            }
            int depth = 0;
            do {
                switch (jsonToken) {
                case START_OBJECT:
                case START_ARRAY:
                    depth++;
                    break;
                case END_OBJECT:
                case END_ARRAY:
                    depth--;
                    break;
                default:
                    // Skip.
                }
                jsonToken = parser.nextToken();
            } while (depth > 0);
            if (jsonToken != null) {
                invalidReason.append(ERR_JSON_TRAILING_CONTENT.get());
                return false;
            }
            return true;
        } catch (JsonProcessingException e) {
            invalidReason.append(ERR_JSON_PARSE_ERROR.get(e.getLocation().getLineNr(),
                                                          e.getLocation().getColumnNr(),
                                                          e.getOriginalMessage()));
            return false;
        } catch (IOException e) {
            invalidReason.append(ERR_JSON_IO_ERROR.get(e.getMessage()));
            return false;
        }
    }
}
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/package-info.java
New file
@@ -0,0 +1,95 @@
/*
 * 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.
 */
/**
 * This package contains LDAP schema syntaxes and matching rules for JSON based attributes.
 * <p>
 * There are two syntaxes, 'Json' and 'Json Query'.
 * <pre>
 * ( 1.3.6.1.4.1.36733.2.1.3.1 DESC 'Json' )
 * ( 1.3.6.1.4.1.36733.2.1.3.2 DESC 'Json Query' )
 * </pre>
 * The first of these, {@link org.forgerock.opendj.rest2ldap.schema.JsonSchema#getJsonSyntax() Json}, is an attribute
 * syntax whose values must conform to the JSON syntax as defined in RFC 7159. The schema option {@link
 * org.forgerock.opendj.rest2ldap.schema.JsonSchema#VALIDATION_POLICY} allows applications to relax the syntax
 * enforcement. For example, to allow single quotes and comments set the following schema option:
 * <pre>
 * SchemaBuilder builder = ...;
 * builder.setOption(JsonSchema.VALIDATION_POLICY, LENIENT);
 * </pre>
 * The second syntax, {@link org.forgerock.opendj.rest2ldap.schema.JsonSchema#getJsonQuerySyntax() Json Query}, is an
 * attribute syntax whose values are {@link org.forgerock.util.query.QueryFilterParser CREST query filters}. This syntax
 * is also the assertion syntax used by the
 * {@link org.forgerock.opendj.rest2ldap.schema.JsonSchema#getCaseIgnoreJsonQueryMatchingRule()
 * caseIgnoreJsonQueryMatch} and
 * {@link org.forgerock.opendj.rest2ldap.schema.JsonSchema#getCaseExactJsonQueryMatchingRule() caseExactJsonQueryMatch}
 * matching rules:
 * <pre>
 * ( 1.3.6.1.4.1.36733.2.1.4.1 NAME 'caseIgnoreJsonQueryMatch' SYNTAX 1.3.6.1.4.1.36733.2.1.3.2 )
 * ( 1.3.6.1.4.1.36733.2.1.4.2 NAME 'caseExactJsonQueryMatch' SYNTAX 1.3.6.1.4.1.36733.2.1.3.2 )
 * </pre>
 * These syntaxes and matching rules are included by default with the OpenDJ server, but may be added to application
 * code as follows:
 * <pre>
 * SchemaBuilder builder = ...;
 * JsonSchema.addJsonSyntaxesAndMatchingRulesToSchema(schemaBuilder);
 * </pre>
 * <p>
 * <b>Trying it out against OpenDJ server</b>
 * <p>
 * After install OpenDJ server add the following schema definition to config/schema/99-user.ldif:
 * <pre>
 * dn: cn=schema
 * objectClass: top
 * objectClass: ldapSubentry
 * objectClass: subschema
 * attributeTypes: ( 1.3.6.1.4.1.36733.2.1.1.999 NAME 'json'
 *   SYNTAX 1.3.6.1.4.1.36733.2.1.3.1 EQUALITY caseIgnoreJsonQueryMatch SINGLE-VALUE )
 * objectClasses: (1.3.6.1.4.1.36733.2.1.2.999 NAME 'jsonObject' SUP top
 *   MUST (cn $ json ) )
 * </pre>
 * Start the server and then add the following entries:
 * <pre>
 * <b>path/to/opendj$ ./bin/ldapmodify -a -h localhost -p 1389 -D cn=directory\ manager -w password</b>
 * dn: cn=bjensen,ou=people,dc=example,dc=com
 * objectClass: top
 * objectClass: jsonObject
 * cn: bjensen
 * json: { "_id":"bjensen", "_rev":"123", "name": { "first": "Babs", "surname": "Jensen" }, "age": 65, "roles": [
 *   "sales", "admin" ] }
 *
 * dn: cn=scarter,ou=people,dc=example,dc=com
 * objectClass: top
 * objectClass: jsonObject
 * cn: scarter
 * json: { "_id":"scarter", "_rev":"456", "name": { "first": "Sam", "surname": "Carter" }, "age": 48, "roles": [
 *   "manager", "eng" ] }
 * </pre>
 * A finally perform some searches:
 * <pre>
 * <b>path/to/opendj$ ./bin/ldapsearch -h localhost -p 1389 -D cn=directory\ manager -w password \
 *   -b ou=people,dc=example,dc=com "(json=age lt 60 and name/first sw 's')"</b>
 * dn: cn=scarter,ou=people,dc=example,dc=com
 * objectClass: jsonObject
 * objectClass: top
 * cn: scarter
 * json: { "_id":"scarter", "_rev":"456", "name": { "first": "Sam", "surname": "Car
 *   ter" }, "age": 48, "roles": [ "manager", "eng" ] }
 * </pre>
 * The JSON query matching rules support indexing which can be enabled using dsconfig against the appropriate
 * attribute index.
 */
package org.forgerock.opendj.rest2ldap.schema;
opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
@@ -140,3 +140,9 @@
ERR_PASSWORD_RESET_USER_AUTHENTICATED_83=Passwords can only be reset by authenticated users
ERR_BAD_API_RESOURCE_VERSION_84=The requested resource API version '%s' is unsupported. This endpoint only supports \
  the following resource API version(s): %s
ERR_JSON_PARSE_ERROR_85=The value could not be parsed as valid JSON because a syntax error was detected on line %d column %d: %s
ERR_JSON_IO_ERROR_86=The value could not be parsed as valid JSON: %s
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
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImplTest.java
New file
@@ -0,0 +1,574 @@
/*
 * 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.schema;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
import static org.forgerock.opendj.ldap.Attributes.singletonAttribute;
import static org.forgerock.opendj.ldap.ConditionResult.FALSE;
import static org.forgerock.opendj.ldap.ConditionResult.TRUE;
import static org.forgerock.opendj.ldap.ConditionResult.UNDEFINED;
import static org.forgerock.opendj.ldap.ConditionResult.or;
import static org.forgerock.opendj.ldap.schema.Schema.getDefaultSchema;
import static org.forgerock.opendj.rest2ldap.schema.JsonQueryEqualityMatchingRuleImpl.compileWildCardPattern;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.*;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.ValidationPolicy.LENIENT;
import static org.forgerock.util.Options.defaultOptions;
import static org.mockito.Mockito.mock;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import org.forgerock.json.JsonPointer;
import org.forgerock.opendj.ldap.Assertion;
import org.forgerock.opendj.ldap.AttributeDescription;
import org.forgerock.opendj.ldap.ByteSequence;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.ConditionResult;
import org.forgerock.opendj.ldap.DecodeException;
import org.forgerock.opendj.ldap.Entry;
import org.forgerock.opendj.ldap.Filter;
import org.forgerock.opendj.ldap.LinkedHashMapEntry;
import org.forgerock.opendj.ldap.Matcher;
import org.forgerock.opendj.ldap.schema.MatchingRule;
import org.forgerock.opendj.ldap.schema.Schema;
import org.forgerock.opendj.ldap.schema.SchemaBuilder;
import org.forgerock.opendj.ldap.spi.IndexQueryFactory;
import org.forgerock.opendj.ldap.spi.Indexer;
import org.forgerock.opendj.ldap.spi.IndexingOptions;
import org.forgerock.testng.ForgeRockTestCase;
import org.forgerock.util.Options;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
@SuppressWarnings("javadoc")
@Test
public class JsonQueryEqualityMatchingRuleImplTest extends ForgeRockTestCase {
    private final MatchingRule matchingRule = JsonSchema.getCaseIgnoreJsonQueryMatchingRule();
    private final JsonQueryEqualityMatchingRuleImpl matchingRuleImpl =
            new JsonQueryEqualityMatchingRuleImpl(EMR_CASE_IGNORE_JSON_QUERY_NAME, defaultOptions());
    // @formatter:off
    private final Schema schema = addJsonSyntaxesAndMatchingRulesToSchema(new SchemaBuilder(getDefaultSchema()))
            .setOption(VALIDATION_POLICY, LENIENT)
            .addAttributeType("( 9.9.9 NAME 'json' "
                                      + "EQUALITY caseIgnoreJsonQueryMatch "
                                      + "SYNTAX " + SYNTAX_JSON_OID + " )", true)
            .toSchema();
    // @formatter:on
    @DataProvider
    public static Object[][] validJson() {
        // @formatter:off
        return new Object[][] {
            { "null", "null" },
            { "false", "false" },
            { "true", "true" },
            { "123", "123" },
            { "123.456", "123.456" },
            { "'string'", "'string'" },
            { "   '  HeLlo  WoRlD  '   ", "'hello world'" },
            { "[]", "[]" },
            { " [ 1, 2, 3 ] ", "[1,2,3]" },
            // Sort keys
            { " { 'c' : 1, 'a' : 2, 'b' : 3 } ", "{'a':2,'b':3,'c':1}" },
            { "{'a':1,'A':2}", "{'A':2,'a':1}" },
            // Case-sensitive keys
            { "{'a':1,'a':2}", "{'a':2}" },
            // Filter duplicate keys
            // Nested objects
            { "{'c':3,'b':[1,'One',1],'a':'XYZ'}", "{'a':'xyz','b':[1,'one',1],'c':3}" },
            { "[1,2,{'c':3,'b':[1,'One',1],'a':'XYZ'}]", "[1,2,{'a':'xyz','b':[1,'one',1],'c':3}]" }
        };
        // @formatter:on
    }
    @Test(dataProvider = "validJson")
    public void testNormalizeAttributeValueWithValidJson(String json, String normalizedJson) throws Exception {
        final ByteString expected = ByteString.valueOfUtf8(normalizedJson.replaceAll("'", "\""));
        final ByteString normalizeAttributeValue = matchingRule.normalizeAttributeValue(ByteString.valueOfUtf8(json));
        assertThat(normalizeAttributeValue).isEqualTo(expected);
    }
    @DataProvider
    public static Object[][] invalidJson() {
        // @formatter:off
        return new Object[][] {
            { "" },
            { "x" },
            { "'string" },
            { "string'" },
            { "'a' 'b'" },
            { "000" },
            { "3." },
            { ".1" },
            { "1 2 3" },
            { "[1" },
            { "1]" },
            { "[1, 2," },
            { "[1, 2, 3], 4]" },
            { "[1, [2, 3, 4]" },
            { "{'k1':'v1'" },
            { "'k1':'v1'}" },
            { "{'k1':'v1','k2':{'k3':'v3','k4':'v4','k5':'v5','k6':'v6'}" },
            { "{'k1':'v1','k2':{'k3':'v3','k4':'v4','k5':'v5'}},'k6':'v6'}" }
        };
        // @formatter:on
    }
    @Test(dataProvider = "invalidJson", expectedExceptions = DecodeException.class)
    public void testNormalizeAttributeValueWithInvalidJson(String json) throws Exception {
        matchingRule.normalizeAttributeValue(ByteString.valueOfUtf8(json));
    }
    @DataProvider
    public Object[][] indexKeys() {
        // @formatter:off
        // json, keys...
        return new Object[][] {
            { "null",  keys(key("/", null)) },
            { "false", keys(key("/", false)) },
            { "true", keys(key("/", true)) },
            { "123", keys(key("/", 123)) },
            { "123.456", keys(key("/", 123.456)) },
            { "'string'", keys(key("/", "string")) },
            { "   '  HeLlo  WoRlD  '   ", keys(key("/", "hello world")) },
            { "[]", keys() },
            { " [ 1, 2, 3 ] ",
              keys(key("/", 1),
                   key("/", 2),
                   key("/", 3)) },
            { "{'ak1':'av1','ak2':{'bk1':'bv1','bk2':[1,2,3],'bk3':{'ck1':'cv1','ck2':'cv2'}},'ak3':'av3'}",
              keys(key("/ak1", "av1"),
                   key("/ak2/bk1", "bv1"),
                   key("/ak2/bk2", 1),
                   key("/ak2/bk2", 2),
                   key("/ak2/bk2", 3),
                   key("/ak2/bk3/ck1", "cv1"),
                   key("/ak2/bk3/ck2", "cv2"),
                   key("/ak3", "av3")),
            }
        };
        // @formatter:on
    }
    @Test(dataProvider = "indexKeys")
    public void testCreateIndexers(String json, ByteString[] expectedKeys) throws Exception {
        final Collection<? extends Indexer> indexers = matchingRule.createIndexers(mock(IndexingOptions.class));
        assertThat(indexers).hasSize(1);
        final Indexer indexer = indexers.iterator().next();
        final ArrayList<ByteString> keys = new ArrayList<>();
        indexer.createKeys(null, ByteString.valueOfUtf8(json), keys);
        assertThat(keys).containsOnly(expectedKeys);
    }
    private ByteString[] keys(final ByteString... keys) {
        return keys;
    }
    private ByteString key(final String jsonPointer, final Object value) {
        return matchingRuleImpl.createIndexKey(jsonPointer, value);
    }
    /** JSON object that jsonQueryAssertions will match against. */
    // @formatter:off
    private final String json = "{"
            + "'null':null,"
            + "'true':true,"
            + "'false':false,"
            + "'intpos':123,"
            + "'intneg':-123,"
            + "'doublepos':12.3,"
            + "'doubleneg':-12.3,"
            + "'string':'  HELLO  world  ',"
            + "'array':['One',['Two','Three'],{'key':'value'},'Four'],"
            + "'object':{'nested':{'k1':'v1','k2':[1,2,3],'k3':'v3'},'tail':'tail','999':'999'}"
            + "}";
    // @formatter:on
    @DataProvider
    public static Object[][] jsonQueryAssertions() {
        // @formatter:off
        return new Object[][] {
            // Test field addressing.
            { "true", TRUE, false },
            { "false", FALSE, false },
            { "garbage", UNDEFINED, false },
            { "/missing pr", FALSE, false },
            { "/null pr", TRUE, false },
            { "/missing eq 123", FALSE, false },
            { "/null eq 123", FALSE, false },
            { "/true eq true", TRUE, false },
            { "/true eq false", FALSE, false },
            { "/true eq 123", FALSE, false },
            { "/false eq true", FALSE, false },
            { "/false eq false", TRUE, false },
            { "/intpos eq 123", TRUE, false },
            { "/intpos eq -123", FALSE, false },
            { "/intpos eq true", FALSE, false },
            { "/intneg eq -123", TRUE, false },
            { "/intneg eq 123", FALSE, false },
            { "/intneg eq true", FALSE, false },
            { "/doublepos eq 12.3", TRUE, false },
            { "/doublepos eq -12.3", FALSE, false },
            { "/doublepos eq true", FALSE, false },
            { "/doubleneg eq -12.3", TRUE, false },
            { "/doubleneg eq 12.3", FALSE, false },
            { "/doubleneg eq true", FALSE, false },
            { "/string eq 'hello world'", TRUE, false },
            { "/string eq ' HELLO  WORLD '", TRUE, false },
            { "/string eq 'hello mars'", FALSE, false },
            { "/string eq 123", FALSE, false },
            { "/array eq 'one'", TRUE, false },
            { "/array eq 'two'", FALSE, true },
            { "/array eq 'four'", TRUE, false },
            { "/array/0 eq 'one'", TRUE, false },
            { "/array/0 eq 'four'", FALSE, true },
            { "/array/10 eq 'one'", FALSE, true },
            { "/array/2/key eq 'value'", TRUE, false },
            { "/object eq 'value'", FALSE, false },
            { "/object/nested/k1 eq 'v1'", TRUE, false },
            { "/object/tail eq 'tail'", TRUE, false },
            { "/object/999 eq '999'", TRUE, false },
            // Integer comparisons.
            { "/intpos lt 1000", TRUE, false },
            { "/intpos lt 123", FALSE, false },
            { "/intpos lt -1000", FALSE, false },
            { "/intneg lt 1000", TRUE, false },
            { "/intneg lt -123", FALSE, false },
            { "/intneg lt -1000", FALSE, false },
            { "/intpos le 1000", TRUE, false },
            { "/intpos le 123", TRUE, false },
            { "/intpos le -1000", FALSE, false },
            { "/intneg le 1000", TRUE, false },
            { "/intneg le -123", TRUE, false },
            { "/intneg le -1000", FALSE, false },
            { "/intpos gt 1000", FALSE, false },
            { "/intpos gt 123", FALSE, false },
            { "/intpos gt -1000", TRUE, false },
            { "/intneg gt 1000", FALSE, false },
            { "/intneg gt -123", FALSE, false },
            { "/intneg gt -1000", TRUE, false },
            { "/intpos ge 1000", FALSE, false },
            { "/intpos ge 123", TRUE, false },
            { "/intpos ge -1000", TRUE, false },
            { "/intneg ge 1000", FALSE, false },
            { "/intneg ge -123", TRUE, false },
            { "/intneg ge -1000", TRUE, false },
            // Double comparisons.
            { "/doublepos lt 100.0", TRUE, false },
            { "/doublepos lt 12.3", FALSE, false },
            { "/doublepos lt -100.0", FALSE, false },
            { "/doubleneg lt 100.0", TRUE, false },
            { "/doubleneg lt -12.3", FALSE, false },
            { "/doubleneg lt -100.0", FALSE, false },
            { "/doublepos le 100.0", TRUE, false },
            { "/doublepos le 12.3", TRUE, false },
            { "/doublepos le -100.0", FALSE, false },
            { "/doubleneg le 100.0", TRUE, false },
            { "/doubleneg le -12.3", TRUE, false },
            { "/doubleneg le -100.0", FALSE, false },
            { "/doublepos gt 100.0", FALSE, false },
            { "/doublepos gt 12.3", FALSE, false },
            { "/doublepos gt -100.0", TRUE, false },
            { "/doubleneg gt 100.0", FALSE, false },
            { "/doubleneg gt -12.3", FALSE, false },
            { "/doubleneg gt -100.0", TRUE, false },
            { "/doublepos ge 100.0", FALSE, false },
            { "/doublepos ge 12.3", TRUE, false },
            { "/doublepos ge -100.0", TRUE, false },
            { "/doubleneg ge 100.0", FALSE, false },
            { "/doubleneg ge -12.3", TRUE, false },
            { "/doubleneg ge -100.0", TRUE, false },
            // String comparisons.
            { "/string lt 'zzz'", TRUE, false },
            { "/string lt ' Hello  World '", FALSE, false },
            { "/string lt 'aaa'", FALSE, false },
            { "/string le 'zzz'", TRUE, false },
            { "/string le ' Hello  World '", TRUE, false },
            { "/string le 'aaa'", FALSE, false },
            { "/string gt 'zzz'", FALSE, false },
            { "/string gt ' Hello  World '", FALSE, false },
            { "/string gt 'aaa'", TRUE, false },
            { "/string ge 'zzz'", FALSE, false },
            { "/string ge ' Hello  World '", TRUE, false },
            { "/string ge 'aaa'", TRUE, false },
            { "/string sw '  HELLO'", TRUE, false },
            { "/string sw 'mars'", FALSE, false },
            { "/string co '  LO  '", TRUE, false },
            { "/string co 'mars'", FALSE, true },
            // Test AND operator.
            { "false and false", FALSE, false },
            { "false and true", FALSE, false },
            { "true and false", FALSE, false },
            { "true and true", TRUE, false },
            // Test OR operator.
            { "false or false", FALSE, false },
            { "false or true", TRUE, false },
            { "true or false", TRUE, false },
            { "true or true", TRUE, false },
            // Test NOT operator.
            // { "!false", TRUE, false }, // FIXME: bug in QueryFilterParser?
            // { "!true", FALSE, true }, // FIXME: bug in QueryFilterParser?
            { "!(false)", TRUE, true },
            { "!(true)", FALSE, true },
        };
        // @formatter:on
    }
    @Test(dataProvider = "jsonQueryAssertions")
    public void testAssertionMatching(final String query, final ConditionResult expected,
                                      final boolean expectFalsePositives) throws Exception {
        final Matcher matcher = Filter.equality("json", query).matcher(schema);
        assertThat(matcher.matches(jsonEntry(schema))).isEqualTo(expected);
    }
    /**
     * Tests index generation and querying. The previous test evaluates a JSON filter directly against a JSON value
     * and checks that the returned filter result matches the expected result. This test, however, evaluates the
     * filter against a pseudo index containing index keys for the JSON value. If the set of keys remaining at the
     * end of the index query is non-empty then the "entry" is candidate. Remember that indexes are allowed to return
     * false positives, so there is not a direct correlation between the expected filter result and the final key
     * set's content: if the filter is expected to match then the key set must be non-empty if the filter is expected
     * not to match then the key set should be empty, but MAY be non-empty in rare cases. The third test parameter
     * "expectFalsePositives" indicates whether a false positive is expected.
     */
    @Test(dataProvider = "jsonQueryAssertions")
    public void testAssertionCreateIndexQuery(final String query, final ConditionResult expected,
                                              final boolean expectFalsePositives) throws Exception {
        if (expected == UNDEFINED) {
            try {
                matchingRuleImpl.getAssertion(schema, ByteString.valueOfUtf8(query));
                fail("Unexpectedly succeeded when parsing invalid assertion");
            } catch (DecodeException e) {
                // Expected.
            }
            return;
        }
        testAssertionIndexQuery(query, expected, expectFalsePositives, matchingRuleImpl, schema);
    }
    @DataProvider
    public static Object[][] jsonQueryAssertionsPartialIndexing() {
        // @formatter:off
        return new Object[][] {
            // Indexed.
            { "/intpos eq 123", TRUE, false },
            { "/intpos eq -123", FALSE, false },
            // Not indexed.
            { "/doublepos eq 12.3", TRUE, true },
            { "/doublepos eq -12.3", FALSE, true },
            // Indexed.
            { "/string eq 'hello world'", TRUE, false },
            { "/string eq 'hello mars'", FALSE, false },
            // Indexed.
            { "/array eq 'one'", TRUE, false },
            { "/array eq 'two'", FALSE, true },
            { "/array/0 eq 'one'", TRUE, false },
            { "/array/0 eq 'four'", FALSE, true },
            { "/array/10 eq 'one'", FALSE, true },
            // Not indexed.
            { "/array/2/key eq 'value'", TRUE, true },
            { "/array/2/key eq 'nomatch'", FALSE, true },
            // Indexed.
            { "/object/nested/k1 eq 'v1'", TRUE, false },
            { "/object/nested/k1 eq 'v2'", FALSE, false },
            // Not indexed.
            { "/object/tail eq 'tail'", TRUE, true },
            { "/object/tail eq 'head'", FALSE, true },
            { "/object/999 eq '999'", TRUE, true },
            { "/object/999 eq '666'", FALSE, true },
        };
        // @formatter:on
    }
    @Test(dataProvider = "jsonQueryAssertionsPartialIndexing")
    public void testAssertionCreateIndexQueryPartialIndexing(final String query, final ConditionResult expected,
                                                     final boolean expectFalsePositives) throws Exception {
        final Options options = defaultOptions()
                .set(INDEXED_FIELD_PATTERNS, Arrays.asList("intpos", "/string", "/array", "/object/nested/**"));
        final JsonQueryEqualityMatchingRuleImpl matchingRuleImpl =
                new JsonQueryEqualityMatchingRuleImpl(EMR_CASE_IGNORE_JSON_QUERY_NAME, options);
        final SchemaBuilder builder = new SchemaBuilder(getDefaultSchema());
        addJsonSyntaxesAndMatchingRulesToSchema(builder);
        builder.setOption(VALIDATION_POLICY, LENIENT)
               .buildMatchingRule("9.9.9")
               .names("partialIndexingTestMatch")
               .syntaxOID(SYNTAX_JSON_QUERY_OID)
               .implementation(matchingRuleImpl)
               .addToSchema()
               .addAttributeType("( 9.9.9 NAME 'json' EQUALITY partialIndexingTestMatch "
                                         + "SYNTAX " + SYNTAX_JSON_OID + " )", true);
        final Schema schema = builder.toSchema();
        testAssertionIndexQuery(query, expected, expectFalsePositives, matchingRuleImpl, schema);
    }
    private void testAssertionIndexQuery(final String query, final ConditionResult expected,
                                         final boolean expectFalsePositives,
                                         final JsonQueryEqualityMatchingRuleImpl impl, final Schema schema)
            throws DecodeException {
        // The assertion is expected to be valid so create an index query. The generated query depends on the
        // assertion, so there's not much that we can verify here.
        final Indexer indexer = impl.createIndexers(null).iterator().next();
        final TreeSet<ByteString> keys = new TreeSet<>();
        indexer.createKeys(schema, ByteString.valueOfUtf8(json), keys);
        final Assertion assertion = impl.getAssertion(schema, ByteString.valueOfUtf8(query));
        final IndexQueryFactory<Set<ByteString>> factory = new MockIndexQueryFactory(keys);
        final Set<ByteString> matchedKeys = assertion.createIndexQuery(factory);
        final ConditionResult isPotentialMatch = ConditionResult.valueOf(!matchedKeys.isEmpty());
        assertThat(isPotentialMatch).isEqualTo(or(expected, ConditionResult.valueOf(expectFalsePositives)));
    }
    private static class MockIndexQueryFactory implements IndexQueryFactory<Set<ByteString>> {
        private final TreeSet<ByteString> keys;
        private MockIndexQueryFactory(final TreeSet<ByteString> keys) {
            this.keys = keys;
        }
        @Override
        public Set<ByteString> createExactMatchQuery(final String indexID, final ByteSequence key) {
            assertThat(indexID).isEqualTo(EMR_CASE_IGNORE_JSON_QUERY_NAME);
            final ByteString keyByteString = key.toByteString();
            if (keys.contains(keyByteString)) {
                return Collections.singleton(keyByteString);
            }
            return Collections.emptySet();
        }
        @Override
        public Set<ByteString> createMatchAllQuery() {
            return keys;
        }
        @Override
        public Set<ByteString> createRangeMatchQuery(final String indexID, final ByteSequence lower,
                                                     final ByteSequence upper, final boolean lowerIncluded,
                                                     final boolean upperIncluded) {
            assertThat(indexID).isEqualTo(EMR_CASE_IGNORE_JSON_QUERY_NAME);
            if (lower.isEmpty()) {
                return keys.headSet(upper.toByteString(), upperIncluded);
            } else if (upper.isEmpty()) {
                return keys.tailSet(lower.toByteString(), lowerIncluded);
            } else {
                return keys.subSet(lower.toByteString(), lowerIncluded, upper.toByteString(), upperIncluded);
            }
        }
        @Override
        public Set<ByteString> createIntersectionQuery(final Collection<Set<ByteString>> subqueries) {
            final TreeSet<ByteString> result = new TreeSet<>(keys);
            for (Set<ByteString> subquery : subqueries) {
                result.retainAll(subquery);
            }
            return result;
        }
        @Override
        public Set<ByteString> createUnionQuery(final Collection<Set<ByteString>> subqueries) {
            final TreeSet<ByteString> result = new TreeSet<>();
            for (Set<ByteString> subquery : subqueries) {
                result.addAll(subquery);
            }
            return result;
        }
        @Override
        public IndexingOptions getIndexingOptions() {
            return null;
        }
    }
    @Test
    public void testGetGreaterOrEqualAssertion() throws Exception {
        assertThat(matchingRule.getGreaterOrEqualAssertion(null)).isSameAs(Assertion.UNDEFINED_ASSERTION);
    }
    @Test
    public void testGetLessOrEqualAssertion() throws Exception {
        assertThat(matchingRule.getLessOrEqualAssertion(null)).isSameAs(Assertion.UNDEFINED_ASSERTION);
    }
    @Test
    public void testGetSubstringAssertion() throws Exception {
        assertThat(matchingRule.getSubstringAssertion(null, null, null)).isSameAs(Assertion.UNDEFINED_ASSERTION);
    }
    @DataProvider
    public static Object[][] patterns() {
        // @formatter:off
        return new Object[][] {
            { "", "/", true },
            { "/", "/", true },
            { "/", "/a", false },
            { "/a", "/a", true },
            { "/a", "/aa", false},
            { "/a", "/a/a", false},
            { "/a/b", "/a/b", true },
            { "/a/b", "/a/bb", false },
            { "/a/*/c", "/a/b/c", true },
            { "/a/*/c", "/a/bbb/c", true },
            { "/a/*/c", "/a/b/b/c", false },
            { "/a/*/c", "/a/c", false },
            { "/a/**/c", "/a/b/c", true },
            { "/a/**/c", "/a/bbb/c", true },
            { "/a/**/c", "/a/b/b/c", true },
            { "/a/**/c", "/a/b/b/c/c", true },
            { "/a/**/c", "/a/b/b/c/cc", false },
            { "/a/**/c", "/a/c", true },
            { "/a/*", "/a", false },
            { "/a/*", "/a/b", true },
            { "/a/*", "/a/b/c", false },
            { "/a/**", "/a", true },
            { "/a/**", "/a/b", true },
            { "/a/**", "/a/b", true },
            { "/a/**", "/a/b/c", true },
            { "/**/a", "/a", true },
            { "/**/a", "/b", false },
            { "/**/a", "/b/a", true },
            { "/**/a", "/c/b/a", true },
            { "/**/a", "/c/b/a/a", true },
        };
        // @formatter:on
    }
    @Test(dataProvider = "patterns")
    public void testIndexedFieldPattens(String pattern, String field, boolean isMatchExpected) throws Exception {
        final Pattern regex = compileWildCardPattern(pattern);
        final String normalizedJsonPointer = new JsonPointer(field).toString();
        assertThat(regex.matcher(normalizedJsonPointer).matches()).isEqualTo(isMatchExpected);
    }
    private Entry jsonEntry(final Schema schema) {
        // @formatter:off
        final Entry entry = new LinkedHashMapEntry("dn: cn=test",
                                                   "objectClass: top",
                                                   "objectClass: extensibleObject",
                                                   "cn: test");
        // @formatter:on
        final AttributeDescription jsonAttributeDescription = AttributeDescription.valueOf("json", schema);
        entry.addAttribute(singletonAttribute(jsonAttributeDescription, json));
        return entry;
    }
}
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImplTest.java
New file
@@ -0,0 +1,109 @@
/*
 * 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.schema;
import static org.assertj.core.api.Assertions.assertThat;
import org.forgerock.i18n.LocalizableMessageBuilder;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.schema.Syntax;
import org.forgerock.testng.ForgeRockTestCase;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
@SuppressWarnings("javadoc")
@Test
public class JsonQuerySyntaxImplTest extends ForgeRockTestCase {
    private final Syntax syntax = JsonSchema.getJsonQuerySyntax();
    @Test
    public void testGetName() throws Exception {
        assertThat(syntax.getName()).isEqualTo(JsonSchema.SYNTAX_JSON_QUERY_DESCRIPTION);
    }
    @Test
    public void testGetApproximateMatchingRule() throws Exception {
        assertThat(syntax.getApproximateMatchingRule()).isNull();
    }
    @Test
    public void testGetEqualityMatchingRule() throws Exception {
        assertThat(syntax.getEqualityMatchingRule()).isNull();
    }
    @Test
    public void testGetOrderingMatchingRule() throws Exception {
        assertThat(syntax.getOrderingMatchingRule()).isNull();
    }
    @Test
    public void testGetSubstringMatchingRule() throws Exception {
        assertThat(syntax.getSubstringMatchingRule()).isNull();
    }
    @Test
    public void testIsBEREncodingRequired() throws Exception {
        assertThat(syntax.isBEREncodingRequired()).isFalse();
    }
    @Test
    public void testIsHumanReadable() throws Exception {
        assertThat(syntax.isHumanReadable()).isTrue();
    }
    @DataProvider
    public static Object[][] validJsonQuery() {
        // There's no need to be exhaustive since the CREST unit tests should already provide sufficient coverage.
        // @formatter:off
        return new Object[][] {
            { "true" },
            { "_id eq 123" },
            { "_id eq \"bjensen\"" },
            { "_id eq \"bjensen\" and /a/b/c eq true" },
        };
        // @formatter:on
    }
    @Test(dataProvider = "validJsonQuery")
    public void testValueIsAcceptableWithValidJson(String value) throws Exception {
        final LocalizableMessageBuilder localizableMessageBuilder = new LocalizableMessageBuilder();
        final boolean valueIsAcceptable =
                syntax.valueIsAcceptable(ByteString.valueOfUtf8(value), localizableMessageBuilder);
        assertThat(valueIsAcceptable).isTrue();
        assertThat(localizableMessageBuilder).isEmpty();
    }
    @DataProvider
    public static Object[][] invalidJsonQuery() {
        // @formatter:off
        return new Object[][] {
            { "" },
            { "bad value" },
            { "/a%XXb eq 123" } // bad hex encoded char
        };
        // @formatter:on
    }
    @Test(dataProvider = "invalidJsonQuery")
    public void testValueIsAcceptableWithInvalidValue(String value) throws Exception {
        final LocalizableMessageBuilder localizableMessageBuilder = new LocalizableMessageBuilder();
        final boolean valueIsAcceptable =
                syntax.valueIsAcceptable(ByteString.valueOfUtf8(value), localizableMessageBuilder);
        assertThat(valueIsAcceptable).isFalse();
        assertThat(localizableMessageBuilder).isNotEmpty();
    }
}
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImplTest.java
New file
@@ -0,0 +1,102 @@
/*
 * 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.schema;
import static org.assertj.core.api.Assertions.assertThat;
import static org.forgerock.opendj.ldap.schema.Schema.getDefaultSchema;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.VALIDATION_POLICY;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.ValidationPolicy.LENIENT;
import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.addJsonSyntaxesAndMatchingRulesToSchema;
import org.forgerock.i18n.LocalizableMessageBuilder;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.schema.SchemaBuilder;
import org.forgerock.opendj.ldap.schema.Syntax;
import org.forgerock.testng.ForgeRockTestCase;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
@SuppressWarnings("javadoc")
@Test
public class JsonSyntaxImplTest extends ForgeRockTestCase {
    private final Syntax syntax = addJsonSyntaxesAndMatchingRulesToSchema(new SchemaBuilder(getDefaultSchema()))
            .setOption(VALIDATION_POLICY, LENIENT)
            .toSchema()
            .getSyntax(JsonSchema.SYNTAX_JSON_OID);
    @Test
    public void testGetName() throws Exception {
        assertThat(syntax.getName()).isEqualTo(JsonSchema.SYNTAX_JSON_DESCRIPTION);
    }
    @Test
    public void testGetApproximateMatchingRule() throws Exception {
        assertThat(syntax.getApproximateMatchingRule()).isNull();
    }
    @Test
    public void testGetEqualityMatchingRule() throws Exception {
        assertThat(syntax.getEqualityMatchingRule().getOID()).isEqualTo(JsonSchema.EMR_CASE_IGNORE_JSON_QUERY_OID);
    }
    @Test
    public void testGetOrderingMatchingRule() throws Exception {
        assertThat(syntax.getOrderingMatchingRule()).isNull();
    }
    @Test
    public void testGetSubstringMatchingRule() throws Exception {
        assertThat(syntax.getSubstringMatchingRule()).isNull();
    }
    @Test
    public void testIsBEREncodingRequired() throws Exception {
        assertThat(syntax.isBEREncodingRequired()).isFalse();
    }
    @Test
    public void testIsHumanReadable() throws Exception {
        assertThat(syntax.isHumanReadable()).isTrue();
    }
    @DataProvider
    public static Object[][] validJson() {
        return JsonQueryEqualityMatchingRuleImplTest.validJson();
    }
    @DataProvider
    public static Object[][] invalidJson() {
        return JsonQueryEqualityMatchingRuleImplTest.invalidJson();
    }
    @Test(dataProvider = "validJson")
    public void testValueIsAcceptableWithValidJson(String value, String normalizedValue) throws Exception {
        final LocalizableMessageBuilder localizableMessageBuilder = new LocalizableMessageBuilder();
        final boolean valueIsAcceptable =
                syntax.valueIsAcceptable(ByteString.valueOfUtf8(value), localizableMessageBuilder);
        assertThat(valueIsAcceptable).isTrue();
        assertThat(localizableMessageBuilder).isEmpty();
    }
    @Test(dataProvider = "invalidJson")
    public void testValueIsAcceptableWithInvalidJson(String value) throws Exception {
        final LocalizableMessageBuilder localizableMessageBuilder = new LocalizableMessageBuilder();
        final boolean valueIsAcceptable =
                syntax.valueIsAcceptable(ByteString.valueOfUtf8(value), localizableMessageBuilder);
        assertThat(valueIsAcceptable).isFalse();
        assertThat(localizableMessageBuilder).isNotEmpty();
    }
}