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
| New file |
| | |
| | | /* |
| | | * 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 |
| | | } |
| | | } |
| New file |
| | |
| | | /* |
| | | * 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; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | /* |
| | | * 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. |
| | | } |
| | | } |
| New file |
| | |
| | | /* |
| | | * 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; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | /* |
| | | * 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; |
| | |
| | | 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 |
| | | |
| New file |
| | |
| | | /* |
| | | * 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; |
| | | } |
| | | } |
| New file |
| | |
| | | /* |
| | | * 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | /* |
| | | * 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(); |
| | | } |
| | | } |