| /* |
| * 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); |
| } |
| } |
| |
| } |