blob: d5b0f1778675116ca747a1513e6e2a653a2681a6 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.directory.scim.server.repository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.BooleanNode;
import com.fasterxml.jackson.databind.node.DoubleNode;
import com.fasterxml.jackson.databind.node.FloatNode;
import com.fasterxml.jackson.databind.node.IntNode;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.POJONode;
import com.fasterxml.jackson.databind.node.TextNode;
import com.flipkart.zjsonpatch.JsonDiff;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.apache.directory.scim.server.schema.SchemaRegistry;
import org.apache.directory.scim.spec.json.ObjectMapperFactory;
import org.apache.directory.scim.spec.protocol.attribute.AttributeReference;
import org.apache.directory.scim.spec.protocol.data.PatchOperation;
import org.apache.directory.scim.spec.protocol.data.PatchOperation.Type;
import org.apache.directory.scim.spec.protocol.data.PatchOperationPath;
import org.apache.directory.scim.spec.protocol.filter.AttributeComparisonExpression;
import org.apache.directory.scim.spec.protocol.filter.CompareOperator;
import org.apache.directory.scim.spec.protocol.filter.FilterExpression;
import org.apache.directory.scim.spec.protocol.filter.ValuePathExpression;
import org.apache.directory.scim.spec.resources.ScimExtension;
import org.apache.directory.scim.spec.resources.ScimResource;
import org.apache.directory.scim.spec.resources.TypedAttribute;
import org.apache.directory.scim.spec.schema.AttributeContainer;
import org.apache.directory.scim.spec.schema.Schema;
import org.apache.directory.scim.spec.schema.Schema.Attribute;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j
@EqualsAndHashCode
@ToString
public class UpdateRequest<T extends ScimResource> {
private static final String OPERATION = "op";
private static final String PATH = "path";
private static final String VALUE = "value";
private final ObjectMapper objectMapper;
@Getter
private String id;
private T resource;
@Getter
private T original;
private List<PatchOperation> patchOperations;
private boolean initialized = false;
private Schema schema;
private SchemaRegistry schemaRegistry;
private Map<Attribute, Integer> addRemoveOffsetMap = new HashMap<>();
public UpdateRequest(SchemaRegistry schemaRegistry) {
this.schemaRegistry = schemaRegistry;
//Create a Jackson ObjectMapper that reads JaxB annotations
objectMapper = ObjectMapperFactory.getObjectMapper();
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
public UpdateRequest(String id, T original, T resource, SchemaRegistry schemaRegistry) {
this(schemaRegistry);
this.id = id;
this.original = original;
this.resource = resource;
this.schema = schemaRegistry.getSchema(original.getBaseUrn());
initialized = true;
}
public UpdateRequest(String id, T original, List<PatchOperation> patchOperations, SchemaRegistry schemaRegistry) {
this(schemaRegistry);
this.id = id;
this.original = original;
this.patchOperations = patchOperations;
this.schema = schemaRegistry.getSchema(original.getBaseUrn());
initialized = true;
}
public T getResource() {
if (!initialized) {
throw new IllegalStateException("UpdateRequest was not initialized");
}
if (resource != null) {
return resource;
}
return applyPatchOperations();
}
public List<PatchOperation> getPatchOperations() {
if (!initialized) {
throw new IllegalStateException("UpdateRequest was not initialized");
}
if (patchOperations == null) {
try {
patchOperations = createPatchOperations();
} catch (IllegalArgumentException | IllegalAccessException | JsonProcessingException e) {
throw new IllegalStateException("Error creating the patch list", e);
}
}
return patchOperations;
}
private void sortMultiValuedCollections(Object obj1, Object obj2, AttributeContainer ac) throws IllegalArgumentException, IllegalAccessException {
for (Attribute attribute : ac.getAttributes()) {
Schema.AttributeAccessor accessor = attribute.getAccessor();
if (attribute.isMultiValued()) {
@SuppressWarnings("unchecked")
List<Object> collection1 = obj1 != null ? (List<Object>) accessor.get(obj1) : null;
@SuppressWarnings("unchecked")
List<Object> collection2 = obj2 != null ? (List<Object>) accessor.get(obj2) : null;
Set<Object> priorities = findCommonElements(collection1, collection2);
PrioritySortingComparator prioritySortingComparator = new PrioritySortingComparator(priorities);
if (collection1 != null) {
Collections.sort(collection1, prioritySortingComparator);
}
if (collection2 != null) {
Collections.sort(collection2, prioritySortingComparator);
}
} else if (attribute.getType() == Attribute.Type.COMPLEX) {
Object nextObj1 = obj1 != null ? accessor.get(obj1) : null;
Object nextObj2 = obj2 != null ? accessor.get(obj2) : null;
sortMultiValuedCollections(nextObj1, nextObj2, attribute);
}
}
}
private Set<Object> findCommonElements(List<Object> list1, List<Object> list2) {
if (list1 == null || list2 == null) {
return Collections.emptySet();
}
Set<Object> set1 = new HashSet<>(list1);
Set<Object> set2 = new HashSet<>(list2);
set1 = set1.stream().map(PrioritySortingComparator::getComparableValue).collect(Collectors.toSet());
set2 = set2.stream().map(PrioritySortingComparator::getComparableValue).collect(Collectors.toSet());
set1.retainAll(set2);
return set1;
}
private T applyPatchOperations() {
throw new java.lang.UnsupportedOperationException("PATCH operations are not implemented at this time.");
}
/**
* There is a know issue with the diffing tool that the tool will attempt to move empty arrays. By
* nulling out the empty arrays during comparison, this will prevent that error from occurring. Because
* deleting requires the parent node
* @param node Parent node.
*/
private static void nullEmptyLists(JsonNode node) {
List<String> objectsToDelete = new ArrayList<>();
if (node != null) {
Iterator<Map.Entry<String, JsonNode>> children = node.fields();
while(children.hasNext()) {
Map.Entry<String, JsonNode> child = children.next();
String name = child.getKey();
JsonNode childNode = child.getValue();
//Attempt to delete children before analyzing
if (childNode.isContainerNode()) {
nullEmptyLists(childNode);
}
if (childNode instanceof ArrayNode) {
ArrayNode ar = (ArrayNode)childNode;
if (ar.size() == 0) {
objectsToDelete.add(name);
}
}
}
if (!objectsToDelete.isEmpty() && node instanceof ObjectNode) {
ObjectNode on = (ObjectNode)node;
for(String name : objectsToDelete) {
on.remove(name);
}
}
}
}
private List<PatchOperation> createPatchOperations() throws IllegalArgumentException, IllegalAccessException, JsonProcessingException {
sortMultiValuedCollections(this.original, this.resource, schema);
Map<String, ScimExtension> originalExtensions = this.original.getExtensions();
Map<String, ScimExtension> resourceExtensions = this.resource.getExtensions();
Set<String> keys = new HashSet<>();
keys.addAll(originalExtensions.keySet());
keys.addAll(resourceExtensions.keySet());
for(String key: keys) {
Schema extSchema = schemaRegistry.getSchema(key);
ScimExtension originalExtension = originalExtensions.get(key);
ScimExtension resourceExtension = resourceExtensions.get(key);
sortMultiValuedCollections(originalExtension, resourceExtension, extSchema);
}
JsonNode node1 = objectMapper.valueToTree(original);
nullEmptyLists(node1);
JsonNode node2 = objectMapper.valueToTree(resource);
nullEmptyLists(node2);
JsonNode differences = JsonDiff.asJson(node1, node2);
/*
Commenting out debug statement to prevent PII from appearing in log
ObjectWriter writer = objMapper.writerWithDefaultPrettyPrinter();
try {
log.debug("Original: "+writer.writeValueAsString(node1));
log.debug("Resource: "+writer.writeValueAsString(node2));
} catch (IOException e) {
}*/
/*try {
log.debug("Differences: " + objMapper.writerWithDefaultPrettyPrinter().writeValueAsString(differences));
} catch (JsonProcessingException e) {
log.debug("Unable to debug differences: ", e);
}*/
List<PatchOperation> patchOps = convertToPatchOperations(differences);
/*try {
log.debug("Patch Ops: " + objMapper.writerWithDefaultPrettyPrinter().writeValueAsString(patchOps));
} catch (JsonProcessingException e) {
log.debug("Unable to debug patch ops: ", e);
}*/
return patchOps;
}
List<PatchOperation> convertToPatchOperations(JsonNode node) throws IllegalArgumentException, IllegalAccessException, JsonProcessingException {
List<PatchOperation> operations = new ArrayList<>();
if (node == null) {
return Collections.emptyList();
}
if (!(node instanceof ArrayNode)) {
throw new RuntimeException("Expecting an instance of a ArrayNode, but got: " + node.getClass());
}
ArrayNode root = (ArrayNode) node;
for (int i = 0; i < root.size(); i++) {
ObjectNode patchNode = (ObjectNode) root.get(i);
JsonNode operationNode = patchNode.get(OPERATION);
JsonNode pathNode = patchNode.get(PATH);
JsonNode valueNode = patchNode.get(VALUE);
List<PatchOperation> nodeOperations = convertNodeToPatchOperations(operationNode.asText(), pathNode.asText(), valueNode);
if (!nodeOperations.isEmpty()) {
operations.addAll(nodeOperations);
}
}
return operations;
}
private List<PatchOperation> convertNodeToPatchOperations(String operationNode, String diffPath, JsonNode valueNode) throws IllegalArgumentException, IllegalAccessException, JsonProcessingException {
log.info(operationNode + ", " + diffPath);
List<PatchOperation> operations = new ArrayList<>();
PatchOperation.Type patchOpType = PatchOperation.Type.valueOf(operationNode.toUpperCase());
if (diffPath != null && diffPath.length() >= 1) {
ParseData parseData = new ParseData(diffPath);
if (parseData.pathParts.isEmpty()) {
operations.add(handleExtensions(valueNode, patchOpType, parseData));
} else {
operations.addAll(handleAttributes(valueNode, patchOpType, parseData));
}
}
return operations;
}
private PatchOperation handleExtensions(JsonNode valueNode, Type patchOpType, ParseData parseData) throws JsonProcessingException {
PatchOperation operation = new PatchOperation();
operation.setOperation(patchOpType);
AttributeReference attributeReference = new AttributeReference(parseData.pathUri, null);
PatchOperationPath patchOperationPath = new PatchOperationPath();
ValuePathExpression valuePathExpression = new ValuePathExpression(attributeReference);
patchOperationPath.setValuePathExpression(valuePathExpression);
operation.setPath(patchOperationPath);
operation.setValue(determineValue(patchOpType, valueNode, parseData));
return operation;
}
@SuppressWarnings("unchecked")
private List<PatchOperation> handleAttributes(JsonNode valueNode, PatchOperation.Type patchOpType, ParseData parseData) throws IllegalAccessException, JsonProcessingException {
log.info("in handleAttributes");
List<PatchOperation> operations = new ArrayList<>();
List<String> attributeReferenceList = new ArrayList<>();
FilterExpression valueFilterExpression = null;
List<String> subAttributes = new ArrayList<>();
boolean processingMultiValued = false;
boolean processedMultiValued = false;
boolean done = false;
int i = 0;
for (String pathPart : parseData.pathParts) {
log.info(pathPart);
if (done) {
throw new RuntimeException("Path should be done... Attribute not supported by the schema: " + pathPart);
} else if (processingMultiValued) {
parseData.traverseObjectsInArray(pathPart, patchOpType);
if (!parseData.isLastIndex(i) || patchOpType != PatchOperation.Type.ADD) {
if (parseData.originalObject instanceof TypedAttribute) {
TypedAttribute typedAttribute = (TypedAttribute) parseData.originalObject;
String type = typedAttribute.getType();
valueFilterExpression = new AttributeComparisonExpression(new AttributeReference("type"), CompareOperator.EQ, type);
} else if (parseData.originalObject instanceof String || parseData.originalObject instanceof Number) {
String toString = parseData.originalObject.toString();
valueFilterExpression = new AttributeComparisonExpression(new AttributeReference("value"), CompareOperator.EQ, toString);
} else if(parseData.originalObject instanceof Enum) {
Enum<?> tempEnum = (Enum<?>)parseData.originalObject;
valueFilterExpression = new AttributeComparisonExpression(new AttributeReference("value"), CompareOperator.EQ, tempEnum.name());
} else {
log.info("Attribute: {} doesn't implement TypedAttribute, can't create ValueFilterExpression", parseData.originalObject.getClass());
valueFilterExpression = new AttributeComparisonExpression(new AttributeReference("value"), CompareOperator.EQ, "?");
}
processingMultiValued = false;
processedMultiValued = true;
}
} else {
Attribute attribute = parseData.ac.getAttribute(pathPart);
if (attribute != null) {
if (processedMultiValued) {
subAttributes.add(pathPart);
} else {
log.info("Adding " + pathPart + " to attributeReferenceList");
attributeReferenceList.add(pathPart);
}
parseData.traverseObjects(pathPart, attribute);
if (patchOpType == Type.REPLACE &&
(parseData.resourceObject != null && parseData.resourceObject instanceof Collection && !((Collection<?>)parseData.resourceObject).isEmpty()) &&
(parseData.originalObject == null ||
(parseData.originalObject instanceof Collection && ((Collection<?>)parseData.originalObject).isEmpty()))) {
patchOpType = Type.ADD;
}
if (attribute.isMultiValued()) {
processingMultiValued = true;
} else if (attribute.getType() != Attribute.Type.COMPLEX) {
done = true;
}
}
}
++i;
}
if (patchOpType == Type.REPLACE && (parseData.resourceObject == null ||
(parseData.resourceObject instanceof Collection && ((Collection<?>)parseData.resourceObject).isEmpty()))) {
patchOpType = Type.REMOVE;
valueNode = null;
}
if (patchOpType == Type.REPLACE && parseData.originalObject == null) {
patchOpType = Type.ADD;
}
if (!attributeReferenceList.isEmpty()) {
Object value = determineValue(patchOpType, valueNode, parseData);
if (value != null && value instanceof ArrayList) {
List<Object> objList = (List<Object>)value;
if (!objList.isEmpty()) {
Object firstElement = objList.get(0);
if (firstElement instanceof ArrayList) {
objList = (List<Object>) firstElement;
}
for (Object obj : objList) {
PatchOperation operation = buildPatchOperation(patchOpType, parseData, attributeReferenceList, valueFilterExpression, subAttributes, obj);
if (operation != null) {
operations.add(operation);
}
}
}
} else {
PatchOperation operation = buildPatchOperation(patchOpType, parseData, attributeReferenceList, valueFilterExpression, subAttributes, value);
if (operation != null) {
operations.add(operation);
}
}
}
return operations;
}
private PatchOperation buildPatchOperation(PatchOperation.Type patchOpType, ParseData parseData, List<String> attributeReferenceList,
FilterExpression valueFilterExpression, List<String> subAttributes, Object value) {
PatchOperation operation = new PatchOperation();
operation.setOperation(patchOpType);
String attribute = attributeReferenceList.get(0);
String subAttribute = attributeReferenceList.size() > 1 ? attributeReferenceList.get(1) : null;
if (subAttribute == null && !subAttributes.isEmpty()) {
subAttribute = subAttributes.get(0);
}
AttributeReference attributeReference = new AttributeReference(parseData.pathUri, attribute, subAttribute);
PatchOperationPath patchOperationPath = new PatchOperationPath();
ValuePathExpression valuePathExpression = new ValuePathExpression(attributeReference, valueFilterExpression);
patchOperationPath.setValuePathExpression(valuePathExpression);
operation.setPath(patchOperationPath);
operation.setValue(value);
return operation;
}
private Object determineValue(PatchOperation.Type patchOpType, JsonNode valueNode, ParseData parseData) throws JsonProcessingException {
if (patchOpType == PatchOperation.Type.REMOVE) {
return null;
}
if (valueNode != null) {
if (valueNode instanceof TextNode) {
return valueNode.asText();
} else if (valueNode instanceof BooleanNode) {
return valueNode.asBoolean();
} else if (valueNode instanceof DoubleNode || valueNode instanceof FloatNode) {
return valueNode.asDouble();
} else if (valueNode instanceof IntNode) {
return valueNode.asInt();
} else if (valueNode instanceof NullNode) {
return null;
} else if (valueNode instanceof ObjectNode) {
return parseData.resourceObject;
} else if (valueNode instanceof POJONode) {
POJONode pojoNode = (POJONode) valueNode;
return pojoNode.getPojo();
} else if (valueNode instanceof ArrayNode) {
ArrayNode arrayNode = (ArrayNode) valueNode;
List<Object> objectList = new ArrayList<>();
for(int i = 0; i < arrayNode.size(); i++) {
Object subObject = determineValue(patchOpType, arrayNode.get(i), parseData);
if (subObject != null) {
objectList.add(subObject);
}
}
return objectList;
}
}
return null;
}
private class ParseData {
List<String> pathParts;
Object originalObject;
Object resourceObject;
AttributeContainer ac;
String pathUri;
public ParseData(String diffPath) {
String path = diffPath.substring(1);
pathParts = new ArrayList<>(Arrays.asList(path.split("/")));
// Extract namespace
pathUri = null;
String firstPathPart = pathParts.get(0);
if (firstPathPart.contains(":")) {
pathUri = firstPathPart;
pathParts.remove(0);
}
if (pathUri != null) {
ac = schemaRegistry.getSchema(pathUri);
originalObject = original.getExtension(pathUri);
resourceObject = resource.getExtension(pathUri);
} else {
ac = schema;
originalObject = original;
resourceObject = resource;
}
}
public void traverseObjects(String pathPart, Attribute attribute) throws IllegalArgumentException, IllegalAccessException {
originalObject = lookupAttribute(originalObject, ac, pathPart);
resourceObject = lookupAttribute(resourceObject, ac, pathPart);
ac = attribute;
}
public void traverseObjectsInArray(String pathPart, Type patchOpType) {
int index = Integer.parseInt(pathPart);
Attribute attr = (Attribute) ac;
Integer addRemoveOffset = addRemoveOffsetMap.getOrDefault(attr, 0);
switch (patchOpType) {
case ADD:
addRemoveOffsetMap.put(attr, addRemoveOffset - 1);
break;
case REMOVE:
addRemoveOffsetMap.put(attr, addRemoveOffset + 1);
break;
case REPLACE:
default:
// Do Nothing
break;
}
int newindex = index + addRemoveOffset;
if (newindex < 0) {
log.error("Attempting to retrieve a negative index:{} on pathPath: {}", newindex, pathPart);
}
originalObject = lookupIndexInArray(originalObject, newindex);
resourceObject = lookupIndexInArray(resourceObject, index);
}
public boolean isLastIndex(int index) {
int numPathParts = pathParts.size();
return index == (numPathParts - 1);
}
@SuppressWarnings("rawtypes")
private Object lookupIndexInArray(Object object, int index) {
if (!(object instanceof List)) {
throw new RuntimeException("Unsupported collection type: " + object.getClass());
}
List list = (List) object;
if (index >= list.size()) {
return null;
}
return list.get(index);
}
private Object lookupAttribute(Object object, AttributeContainer ac, String attributeName) throws IllegalArgumentException, IllegalAccessException {
if (object == null) {
return null;
}
Attribute attribute = ac.getAttribute(attributeName);
return attribute.getAccessor().get(object);
}
}
}