From 95d5c1406fb12c27d1c906063c9daccde36329ca Mon Sep 17 00:00:00 2001
From: Matthew Swift <matthew.swift@forgerock.com>
Date: Thu, 06 Oct 2016 07:50:36 +0000
Subject: [PATCH] OPENDJ-2860: implement JSON attribute syntaxes and matching rules
---
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImplTest.java | 102 ++
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/package-info.java | 95 ++
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImpl.java | 123 +++
opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties | 6
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSchema.java | 253 ++++++
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImpl.java | 845 ++++++++++++++++++++++
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImplTest.java | 574 +++++++++++++++
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImplTest.java | 109 ++
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImpl.java | 75 ++
9 files changed, 2,182 insertions(+), 0 deletions(-)
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImpl.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImpl.java
new file mode 100644
index 0000000..250770f
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImpl.java
@@ -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
+ * <empty> 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
+ }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImpl.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImpl.java
new file mode 100644
index 0000000..81ef509
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImpl.java
@@ -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;
+ }
+ }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSchema.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSchema.java
new file mode 100644
index 0000000..50ee3b8
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSchema.java
@@ -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*/ccc</td><td>/aaa/bbb/ccc<br/>/aaa/bxx/ccc</td><td>/aaa/xxx/ccc<br/>/aaa/bbb</td></tr>
+ * <tr><td>/aaa/**/ddd</td><td>/aaa/ddd<br/>/aaa/xxx/yyy/ddd</td><td>/aaa/bbb/ccc</td></tr>
+ * <tr><td>/aaa/**</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.
+ }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImpl.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImpl.java
new file mode 100644
index 0000000..76cfd9c
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImpl.java
@@ -0,0 +1,123 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2016 ForgeRock AS.
+ */
+package org.forgerock.opendj.rest2ldap.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;
+ }
+ }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/package-info.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/package-info.java
new file mode 100755
index 0000000..3c347a0
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/package-info.java
@@ -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;
diff --git a/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties b/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
index 4264584..225bf31 100644
--- a/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
+++ b/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
@@ -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
+
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImplTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImplTest.java
new file mode 100644
index 0000000..494a5ed
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImplTest.java
@@ -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;
+ }
+}
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImplTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImplTest.java
new file mode 100644
index 0000000..d822ebc
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImplTest.java
@@ -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();
+ }
+}
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImplTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImplTest.java
new file mode 100644
index 0000000..bcc16a7
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImplTest.java
@@ -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();
+ }
+}
--
Gitblit v1.10.0