diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7b6d290ff..3715ee9a682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ * `Config.errorMessages` has been removed. Please use Kubernetes status messages directly. * Fix #6138: Removed unused `io:fabric8:kubernetes-model` artifact * Fix #6156: Removed deprecated extension `io:fabric8:service-catalog` +* Fix #5480: Migrate from `io.fabric8:zjsonpatch` to `com.flipkart.zjsonpatch:zjsonpatch` . In case you're relying on `io.fabric8.zjsonpatch.*` classes, you need to migrate to `com.flipkart.zjsonpatch.*` classes. ### 6.13.3 (2024-08-13) diff --git a/kubernetes-client/pom.xml b/kubernetes-client/pom.xml index 278503c4631..e0ac053b0ee 100644 --- a/kubernetes-client/pom.xml +++ b/kubernetes-client/pom.xml @@ -74,7 +74,6 @@ io.fabric8 zjsonpatch - ${zjsonpatch.version} diff --git a/platforms/karaf/features/src/main/resources/feature.xml b/platforms/karaf/features/src/main/resources/feature.xml index d340c9fec61..5b84837f65e 100644 --- a/platforms/karaf/features/src/main/resources/feature.xml +++ b/platforms/karaf/features/src/main/resources/feature.xml @@ -40,7 +40,7 @@ mvn:org.ow2.asm/asm-tree/${asm.bundle.version} mvn:org.ow2.asm/asm-util/${asm.bundle.version} mvn:io.fabric8/kubernetes-model-common/${project.version} - mvn:io.fabric8/zjsonpatch/${zjsonpatch.version} + mvn:io.fabric8/zjsonpatch/${project.version} mvn:io.fabric8/kubernetes-model-core/${project.version} mvn:io.fabric8/kubernetes-model-rbac/${project.version} diff --git a/pom.xml b/pom.xml index 66809d15e7f..dddfe4d6c7a 100644 --- a/pom.xml +++ b/pom.xml @@ -100,7 +100,7 @@ 3.9.9 3.15.0 4.5.9 - 0.3.0 + 0.4.16 3.0.2 @@ -226,6 +226,7 @@ httpclient-vertx kubernetes-client-deps-compatibility-tests log4j + zjsonpatch @@ -715,6 +716,11 @@ kube-api-test-client-inject ${project.version} + + io.fabric8 + zjsonpatch + ${project.version} + @@ -890,12 +896,19 @@ true - - io.fabric8 + com.flipkart.zjsonpatch zjsonpatch ${zjsonpatch.version} + + + org.apache.commons + commons-collections4 + + + + org.mockito mockito-core diff --git a/uberjar/pom.xml b/uberjar/pom.xml index b4c17d32449..9c7944bc9e0 100644 --- a/uberjar/pom.xml +++ b/uberjar/pom.xml @@ -199,6 +199,12 @@ openshift-server-mock ${project.version} + + + io.fabric8 + zjsonpatch + ${project.version} + @@ -233,7 +239,7 @@ - io.fabric8 + com.flipkart.zjsonpatch zjsonpatch ${zjsonpatch.version} diff --git a/zjsonpatch/pom.xml b/zjsonpatch/pom.xml new file mode 100644 index 00000000000..b1bf94c6787 --- /dev/null +++ b/zjsonpatch/pom.xml @@ -0,0 +1,97 @@ + + + + 4.0.0 + + io.fabric8 + kubernetes-client-project + 7.0-SNAPSHOT + + + bundle + zjsonpatch + + + + * + + + io.fabric8.zjsonpatch*, + com.flipkart.zjsonpatch, + + + + + + com.flipkart.zjsonpatch + zjsonpatch + ${zjsonpatch.version} + + + org.apache.commons + commons-collections4 + + + + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.assertj + assertj-core + test + + + + + + + org.apache.felix + maven-bundle-plugin + + + bundle + package + + bundle + + + + ${project.name} + ${project.groupId}.${project.artifactId} + ${osgi.export} + ${osgi.import} + ${osgi.dynamic.import} + ${osgi.private} + ${osgi.bundles} + ${osgi.activator} + ${osgi.export.service} + + + + + + + + diff --git a/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/Diff.java b/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/Diff.java new file mode 100644 index 00000000000..1ad32fc099b --- /dev/null +++ b/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/Diff.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * Licensed 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 io.fabric8.zjsonpatch; + +import com.fasterxml.jackson.databind.JsonNode; +import com.flipkart.zjsonpatch.Operation; + +/** + * This class is ported from FlipKart + * JSONPatch repository + */ +class Diff { + private final Operation operation; + private final JsonPointer path; + private final JsonNode value; + private JsonPointer toPath; //only to be used in move operation + private final JsonNode srcValue; // only used in replace operation + + Diff(Operation operation, JsonPointer path, JsonNode value) { + this.operation = operation; + this.path = path; + this.value = value; + this.srcValue = null; + } + + Diff(Operation operation, JsonPointer fromPath, JsonPointer toPath) { + this.operation = operation; + this.path = fromPath; + this.toPath = toPath; + this.value = null; + this.srcValue = null; + } + + Diff(Operation operation, JsonPointer path, JsonNode srcValue, JsonNode value) { + this.operation = operation; + this.path = path; + this.value = value; + this.srcValue = srcValue; + } + + public Operation getOperation() { + return operation; + } + + public JsonPointer getPath() { + return path; + } + + public JsonNode getValue() { + return value; + } + + public static Diff generateDiff(Operation replace, JsonPointer path, JsonNode target) { + return new Diff(replace, path, target); + } + + public static Diff generateDiff(Operation replace, JsonPointer path, JsonNode source, JsonNode target) { + return new Diff(replace, path, source, target); + } + + JsonPointer getToPath() { + return toPath; + } + + public JsonNode getSrcValue() { + return srcValue; + } +} diff --git a/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/JsonDiff.java b/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/JsonDiff.java new file mode 100644 index 00000000000..e3562ff9549 --- /dev/null +++ b/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/JsonDiff.java @@ -0,0 +1,500 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * Licensed 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 io.fabric8.zjsonpatch; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.flipkart.zjsonpatch.DiffFlags; +import com.flipkart.zjsonpatch.Operation; +import io.fabric8.zjsonpatch.internal.collections4.ListUtils; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * This class is ported from FlipKart + * JSONPatch repository + */ +public class JsonDiff { + private final List diffs = new ArrayList<>(); + private final EnumSet flags; + public static final String OP = "op"; + public static final String VALUE = "value"; + public static final String PATH = "path"; + public static final String FROM = "from"; + public static final String FROM_VALUE = "fromValue"; + + private JsonDiff(EnumSet flags) { + this.flags = flags.clone(); + } + + public static JsonNode asJson(final JsonNode source, final JsonNode target) { + return asJson(source, target, DiffFlags.defaults()); + } + + public static JsonNode asJson(final JsonNode source, final JsonNode target, EnumSet flags) { + JsonDiff diff = new JsonDiff(flags); + if (source == null && target != null) { + // return add node at root pointing to the target + diff.diffs.add(Diff.generateDiff(Operation.ADD, JsonPointer.ROOT, target)); + } + if (source != null && target == null) { + // return remove node at root pointing to the source + diff.diffs.add(Diff.generateDiff(Operation.REMOVE, JsonPointer.ROOT, source)); + } + if (source != null && target != null) { + diff.generateDiffs(JsonPointer.ROOT, source, target); + + if (!flags.contains(DiffFlags.OMIT_MOVE_OPERATION)) + // Merging remove & add to move operation + diff.introduceMoveOperation(); + + if (!flags.contains(DiffFlags.OMIT_COPY_OPERATION)) + // Introduce copy operation + diff.introduceCopyOperation(source, target); + + if (flags.contains(DiffFlags.ADD_EXPLICIT_REMOVE_ADD_ON_REPLACE)) + // Split replace into remove and add instructions + diff.introduceExplicitRemoveAndAddOperation(); + } + return diff.getJsonNodes(); + } + + private static JsonPointer getMatchingValuePath(Map unchangedValues, JsonNode value) { + return unchangedValues.get(value); + } + + private void introduceCopyOperation(JsonNode source, JsonNode target) { + Map unchangedValues = getUnchangedPart(source, target); + + for (int i = 0; i < diffs.size(); i++) { + Diff diff = diffs.get(i); + if (Operation.ADD != diff.getOperation()) + continue; + + JsonPointer matchingValuePath = getMatchingValuePath(unchangedValues, diff.getValue()); + if (matchingValuePath != null && isAllowed(matchingValuePath, diff.getPath())) { + // Matching value found; replace add with copy + if (flags.contains(DiffFlags.EMIT_TEST_OPERATIONS)) { + // Prepend test node + diffs.add(i, new Diff(Operation.TEST, matchingValuePath, diff.getValue())); + i++; + } + diffs.set(i, new Diff(Operation.COPY, matchingValuePath, diff.getPath())); + } + } + } + + private static boolean isNumber(String str) { + int size = str.length(); + + for (int i = 0; i < size; i++) { + if (!Character.isDigit(str.charAt(i))) { + return false; + } + } + + return size > 0; + } + + // TODO this is quite unclear and needs some serious documentation + private static boolean isAllowed(JsonPointer source, JsonPointer destination) { + boolean isSame = source.equals(destination); + int i = 0; + int j = 0; + // Hack to fix broken COPY operation, need better handling here + while (i < source.size() && j < destination.size()) { + JsonPointer.RefToken srcValue = source.get(i); + JsonPointer.RefToken dstValue = destination.get(j); + String srcStr = srcValue.toString(); + String dstStr = dstValue.toString(); + if (isNumber(srcStr) && isNumber(dstStr)) { + + if (srcStr.compareTo(dstStr) > 0) { + return false; + } + } + i++; + j++; + + } + return !isSame; + } + + private static Map getUnchangedPart(JsonNode source, JsonNode target) { + Map unchangedValues = new HashMap<>(); + computeUnchangedValues(unchangedValues, JsonPointer.ROOT, source, target); + return unchangedValues; + } + + private static void computeUnchangedValues(Map unchangedValues, JsonPointer path, JsonNode source, + JsonNode target) { + if (source.equals(target)) { + if (!unchangedValues.containsKey(target)) { + unchangedValues.put(target, path); + } + return; + } + + final NodeType firstType = NodeType.getNodeType(source); + final NodeType secondType = NodeType.getNodeType(target); + + if (firstType == secondType) { + switch (firstType) { + case OBJECT: + computeObject(unchangedValues, path, source, target); + break; + case ARRAY: + computeArray(unchangedValues, path, source, target); + break; + default: + /* nothing */ + } + } + } + + private static void computeArray(Map unchangedValues, JsonPointer path, JsonNode source, + JsonNode target) { + final int size = Math.min(source.size(), target.size()); + + for (int i = 0; i < size; i++) { + JsonPointer currPath = path.append(i); + computeUnchangedValues(unchangedValues, currPath, source.get(i), target.get(i)); + } + } + + private static void computeObject(Map unchangedValues, JsonPointer path, JsonNode source, + JsonNode target) { + final Iterator firstFields = source.fieldNames(); + while (firstFields.hasNext()) { + String name = firstFields.next(); + if (target.has(name)) { + JsonPointer currPath = path.append(name); + computeUnchangedValues(unchangedValues, currPath, source.get(name), target.get(name)); + } + } + } + + /** + * This method merge 2 diffs ( remove then add, or vice versa ) with same value into one Move operation, + * all the core logic resides here only + */ + private void introduceMoveOperation() { + for (int i = 0; i < diffs.size(); i++) { + Diff diff1 = diffs.get(i); + + // if not remove OR add, move to next diff + if (!(Operation.REMOVE == diff1.getOperation() || + Operation.ADD == diff1.getOperation())) { + continue; + } + + for (int j = i + 1; j < diffs.size(); j++) { + Diff diff2 = diffs.get(j); + if (!diff1.getValue().equals(diff2.getValue())) { + continue; + } + + Diff moveDiff = null; + if (Operation.REMOVE == diff1.getOperation() && + Operation.ADD == diff2.getOperation()) { + JsonPointer relativePath = computeRelativePath(diff2.getPath(), i + 1, j - 1, diffs); + moveDiff = new Diff(Operation.MOVE, diff1.getPath(), relativePath); + + } else if (Operation.ADD == diff1.getOperation() && + Operation.REMOVE == diff2.getOperation()) { + JsonPointer relativePath = computeRelativePath(diff2.getPath(), i, j - 1, diffs); // diff1's add should also be considered + moveDiff = new Diff(Operation.MOVE, relativePath, diff1.getPath()); + } + if (moveDiff != null) { + diffs.remove(j); + diffs.set(i, moveDiff); + break; + } + } + } + } + + /** + * This method splits a {@link Operation#REPLACE} operation within a diff into a {@link Operation#REMOVE} + * and {@link Operation#ADD} in order, respectively. + * Does nothing if {@link Operation#REPLACE} op does not contain a from value + */ + private void introduceExplicitRemoveAndAddOperation() { + List updatedDiffs = new ArrayList(); + for (Diff diff : diffs) { + if (!diff.getOperation().equals(Operation.REPLACE) || diff.getSrcValue() == null) { + updatedDiffs.add(diff); + continue; + } + //Split into two #REMOVE and #ADD + updatedDiffs.add(new Diff(Operation.REMOVE, diff.getPath(), diff.getSrcValue())); + updatedDiffs.add(new Diff(Operation.ADD, diff.getPath(), diff.getValue())); + } + diffs.clear(); + diffs.addAll(updatedDiffs); + } + + //Note : only to be used for arrays + //Finds the longest common Ancestor ending at Array + private static JsonPointer computeRelativePath(JsonPointer path, int startIdx, int endIdx, List diffs) { + List counters = new ArrayList<>(path.size()); + for (int i = 0; i < path.size(); i++) { + counters.add(0); + } + + for (int i = startIdx; i <= endIdx; i++) { + Diff diff = diffs.get(i); + //Adjust relative path according to #ADD and #Remove + if (Operation.ADD == diff.getOperation() || Operation.REMOVE == diff.getOperation()) { + updatePath(path, diff, counters); + } + } + return updatePathWithCounters(counters, path); + } + + private static JsonPointer updatePathWithCounters(List counters, JsonPointer path) { + List tokens = path.decompose(); + for (int i = 0; i < counters.size(); i++) { + int value = counters.get(i); + if (value != 0) { + int currValue = tokens.get(i).getIndex(); + tokens.set(i, new JsonPointer.RefToken(Integer.toString(currValue + value))); + } + } + return new JsonPointer(tokens); + } + + private static void updatePath(JsonPointer path, Diff pseudo, List counters) { + //find longest common prefix of both the paths + + if (pseudo.getPath().size() <= path.size()) { + int idx = -1; + for (int i = 0; i < pseudo.getPath().size() - 1; i++) { + if (pseudo.getPath().get(i).equals(path.get(i))) { + idx = i; + } else { + break; + } + } + if (idx == pseudo.getPath().size() - 2) { + if (pseudo.getPath().get(pseudo.getPath().size() - 1).isArrayIndex()) { + updateCounters(pseudo, pseudo.getPath().size() - 1, counters); + } + } + } + } + + private static void updateCounters(Diff pseudo, int idx, List counters) { + if (Operation.ADD == pseudo.getOperation()) { + counters.set(idx, counters.get(idx) - 1); + } else { + if (Operation.REMOVE == pseudo.getOperation()) { + counters.set(idx, counters.get(idx) + 1); + } + } + } + + private ArrayNode getJsonNodes() { + JsonNodeFactory FACTORY = JsonNodeFactory.instance; + final ArrayNode patch = FACTORY.arrayNode(); + for (Diff diff : diffs) { + ObjectNode jsonNode = getJsonNode(FACTORY, diff, flags); + patch.add(jsonNode); + } + return patch; + } + + private static ObjectNode getJsonNode(JsonNodeFactory FACTORY, Diff diff, EnumSet flags) { + ObjectNode jsonNode = FACTORY.objectNode(); + jsonNode.put(OP, diff.getOperation().rfcName()); + + switch (diff.getOperation()) { + case MOVE: + case COPY: + jsonNode.put(FROM, diff.getPath().toString()); // required {from} only in case of Move Operation + jsonNode.put(PATH, diff.getToPath().toString()); // destination Path + break; + + case REMOVE: + jsonNode.put(PATH, diff.getPath().toString()); + if (!flags.contains(DiffFlags.OMIT_VALUE_ON_REMOVE)) + jsonNode.set(VALUE, diff.getValue()); + break; + + case REPLACE: + if (flags.contains(DiffFlags.ADD_ORIGINAL_VALUE_ON_REPLACE)) { + jsonNode.set(FROM_VALUE, diff.getSrcValue()); + } + case ADD: + case TEST: + jsonNode.put(PATH, diff.getPath().toString()); + jsonNode.set(VALUE, diff.getValue()); + break; + + default: + // Safety net + throw new IllegalArgumentException("Unknown operation specified:" + diff.getOperation()); + } + + return jsonNode; + } + + private void generateDiffs(JsonPointer path, JsonNode source, JsonNode target) { + if (!source.equals(target)) { + final NodeType sourceType = NodeType.getNodeType(source); + final NodeType targetType = NodeType.getNodeType(target); + + if (sourceType == NodeType.ARRAY && targetType == NodeType.ARRAY) { + //both are arrays + compareArray(path, source, target); + } else if (sourceType == NodeType.OBJECT && targetType == NodeType.OBJECT) { + //both are json + compareObjects(path, source, target); + } else { + //can be replaced + if (flags.contains(DiffFlags.EMIT_TEST_OPERATIONS)) + diffs.add(new Diff(Operation.TEST, path, source)); + diffs.add(Diff.generateDiff(Operation.REPLACE, path, source, target)); + } + } + } + + private void compareArray(JsonPointer path, JsonNode source, JsonNode target) { + List lcs = getLCS(source, target); + int srcIdx = 0; + int targetIdx = 0; + int lcsIdx = 0; + int srcSize = source.size(); + int targetSize = target.size(); + int lcsSize = lcs.size(); + + int pos = 0; + while (lcsIdx < lcsSize) { + JsonNode lcsNode = lcs.get(lcsIdx); + JsonNode srcNode = source.get(srcIdx); + JsonNode targetNode = target.get(targetIdx); + + if (lcsNode.equals(srcNode) && lcsNode.equals(targetNode)) { // Both are same as lcs node, nothing to do here + srcIdx++; + targetIdx++; + lcsIdx++; + pos++; + } else { + if (lcsNode.equals(srcNode)) { // src node is same as lcs, but not targetNode + //addition + JsonPointer currPath = path.append(pos); + diffs.add(Diff.generateDiff(Operation.ADD, currPath, targetNode)); + pos++; + targetIdx++; + } else if (lcsNode.equals(targetNode)) { //targetNode node is same as lcs, but not src + //removal, + JsonPointer currPath = path.append(pos); + if (flags.contains(DiffFlags.EMIT_TEST_OPERATIONS)) + diffs.add(new Diff(Operation.TEST, currPath, srcNode)); + diffs.add(Diff.generateDiff(Operation.REMOVE, currPath, srcNode)); + srcIdx++; + } else { + JsonPointer currPath = path.append(pos); + //both are unequal to lcs node + generateDiffs(currPath, srcNode, targetNode); + srcIdx++; + targetIdx++; + pos++; + } + } + } + + while ((srcIdx < srcSize) && (targetIdx < targetSize)) { + JsonNode srcNode = source.get(srcIdx); + JsonNode targetNode = target.get(targetIdx); + JsonPointer currPath = path.append(pos); + generateDiffs(currPath, srcNode, targetNode); + srcIdx++; + targetIdx++; + pos++; + } + pos = addRemaining(path, target, pos, targetIdx, targetSize); + removeRemaining(path, pos, srcIdx, srcSize, source); + } + + private void removeRemaining(JsonPointer path, int pos, int srcIdx, int srcSize, JsonNode source) { + while (srcIdx < srcSize) { + JsonPointer currPath = path.append(pos); + if (flags.contains(DiffFlags.EMIT_TEST_OPERATIONS)) + diffs.add(new Diff(Operation.TEST, currPath, source.get(srcIdx))); + diffs.add(Diff.generateDiff(Operation.REMOVE, currPath, source.get(srcIdx))); + srcIdx++; + } + } + + private int addRemaining(JsonPointer path, JsonNode target, int pos, int targetIdx, int targetSize) { + while (targetIdx < targetSize) { + JsonNode jsonNode = target.get(targetIdx); + JsonPointer currPath = path.append(pos); + diffs.add(Diff.generateDiff(Operation.ADD, currPath, jsonNode.deepCopy())); + pos++; + targetIdx++; + } + return pos; + } + + private void compareObjects(JsonPointer path, JsonNode source, JsonNode target) { + Iterator keysFromSrc = source.fieldNames(); + while (keysFromSrc.hasNext()) { + String key = keysFromSrc.next(); + if (!target.has(key)) { + //remove case + JsonPointer currPath = path.append(key); + if (flags.contains(DiffFlags.EMIT_TEST_OPERATIONS)) + diffs.add(new Diff(Operation.TEST, currPath, source.get(key))); + diffs.add(Diff.generateDiff(Operation.REMOVE, currPath, source.get(key))); + continue; + } + JsonPointer currPath = path.append(key); + generateDiffs(currPath, source.get(key), target.get(key)); + } + Iterator keysFromTarget = target.fieldNames(); + while (keysFromTarget.hasNext()) { + String key = keysFromTarget.next(); + if (!source.has(key)) { + //add case + JsonPointer currPath = path.append(key); + diffs.add(Diff.generateDiff(Operation.ADD, currPath, target.get(key))); + } + } + } + + private static List getLCS(final JsonNode first, final JsonNode second) { + return ListUtils.longestCommonSubsequence(toList((ArrayNode) first), toList((ArrayNode) second)); + } + + static List toList(ArrayNode input) { + int size = input.size(); + List toReturn = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + toReturn.add(input.get(i)); + } + return toReturn; + } +} diff --git a/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/JsonPatch.java b/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/JsonPatch.java new file mode 100644 index 00000000000..110522f3de3 --- /dev/null +++ b/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/JsonPatch.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * Licensed 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 io.fabric8.zjsonpatch; + +import com.fasterxml.jackson.databind.JsonNode; +import com.flipkart.zjsonpatch.CompatibilityFlags; +import com.flipkart.zjsonpatch.JsonPatchApplicationException; + +public class JsonPatch { + private JsonPatch() { + } + + public static JsonNode apply(JsonNode patch, JsonNode source) throws JsonPatchApplicationException { + return com.flipkart.zjsonpatch.JsonPatch.apply(patch, source, CompatibilityFlags.defaults()); + } +} diff --git a/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/JsonPointer.java b/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/JsonPointer.java new file mode 100644 index 00000000000..5e03593986c --- /dev/null +++ b/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/JsonPointer.java @@ -0,0 +1,350 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * Licensed 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 io.fabric8.zjsonpatch; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * This class is ported from FlipKart + * JSONPatch repository + */ +public class JsonPointer { + private final RefToken[] tokens; + + /** A JSON pointer representing the root node of a JSON document */ + public final static JsonPointer ROOT = new JsonPointer(new RefToken[] {}); + + private JsonPointer(RefToken[] tokens) { + this.tokens = tokens; + } + + /** + * Constructs a new pointer from a list of reference tokens. + * + * @param tokens The list of reference tokens from which to construct the new pointer. This list is not modified. + */ + public JsonPointer(List tokens) { + this.tokens = tokens.toArray(new RefToken[0]); + } + + /** + * Parses a valid string representation of a JSON Pointer. + * + * @param path The string representation to be parsed. + * @return An instance of {@link JsonPointer} conforming to the specified string representation. + * @throws IllegalArgumentException The specified JSON Pointer is invalid. + */ + public static JsonPointer parse(String path) throws IllegalArgumentException { + StringBuilder reftoken = null; + List result = new ArrayList<>(); + + for (int i = 0; i < path.length(); ++i) { + char c = path.charAt(i); + + // Require leading slash + if (i == 0) { + if (c != '/') + throw new IllegalArgumentException("Missing leading slash"); + reftoken = new StringBuilder(); + continue; + } + + switch (c) { + // Escape sequences + case '~': + switch (path.charAt(++i)) { + case '0': + reftoken.append('~'); + break; + case '1': + reftoken.append('/'); + break; + default: + throw new IllegalArgumentException("Invalid escape sequence ~" + path.charAt(i) + " at index " + i); + } + break; + + // New reftoken + case '/': + result.add(new RefToken(reftoken.toString())); + reftoken.setLength(0); + break; + + default: + reftoken.append(c); + break; + } + } + + if (reftoken == null) + return ROOT; + + result.add(RefToken.parse(reftoken.toString())); + return new JsonPointer(result); + } + + /** + * Indicates whether or not this instance points to the root of a JSON document. + * + * @return {@code true} if this pointer represents the root node, {@code false} otherwise. + */ + public boolean isRoot() { + return tokens.length == 0; + } + + /** + * Creates a new JSON pointer to the specified field of the object referenced by this instance. + * + * @param field The desired field name, or any valid JSON Pointer reference token + * @return The new {@link JsonPointer} instance. + */ + JsonPointer append(String field) { + RefToken[] newTokens = Arrays.copyOf(tokens, tokens.length + 1); + newTokens[tokens.length] = new RefToken(field); + return new JsonPointer(newTokens); + } + + /** + * Creates a new JSON pointer to an indexed element of the array referenced by this instance. + * + * @param index The desired index, or {@link #LAST_INDEX} to point past the end of the array. + * @return The new {@link JsonPointer} instance. + */ + JsonPointer append(int index) { + return append(Integer.toString(index)); + } + + /** Returns the number of reference tokens comprising this instance. */ + int size() { + return tokens.length; + } + + /** + * Returns a string representation of this instance + * + * @return + * An RFC 6901 compliant string + * representation of this JSON pointer. + */ + public String toString() { + StringBuilder sb = new StringBuilder(); + for (RefToken token : tokens) { + sb.append('/'); + sb.append(token); + } + return sb.toString(); + } + + /** + * Decomposes this JSON pointer into its reference tokens. + * + * @return A list of {@link RefToken}s. Modifications to this list do not affect this instance. + */ + public List decompose() { + return Arrays.asList(tokens.clone()); + } + + /** + * Retrieves the reference token at the specified index. + * + * @param index The desired reference token index. + * @return The specified instance of {@link RefToken}. + * @throws IndexOutOfBoundsException The specified index is illegal. + */ + public RefToken get(int index) throws IndexOutOfBoundsException { + if (index < 0 || index >= tokens.length) + throw new IndexOutOfBoundsException("Illegal index: " + index); + return tokens[index]; + } + + /** + * Retrieves the last reference token for this JSON pointer. + * + * @return The last {@link RefToken} comprising this instance. + * @throws IllegalStateException Last cannot be called on {@link #ROOT root} pointers. + */ + public RefToken last() { + if (isRoot()) + throw new IllegalStateException("Root pointers contain no reference tokens"); + return tokens[tokens.length - 1]; + } + + /** + * Creates a JSON pointer to the parent of the node represented by this instance. + * + * The parent of the {@link #ROOT root} pointer is the root pointer itself. + * + * @return A {@link JsonPointer} to the parent node. + */ + public JsonPointer getParent() { + return isRoot() ? this : new JsonPointer(Arrays.copyOf(tokens, tokens.length - 1)); + } + + private void error(int atToken, String message, JsonNode document) throws JsonPointerEvaluationException { + throw new JsonPointerEvaluationException( + message, + new JsonPointer(Arrays.copyOf(tokens, atToken)), + document); + } + + /** + * Takes a target document and resolves the node represented by this instance. + * + * The evaluation semantics are described in + * RFC 6901 sectino 4. + * + * @param document The target document against which to evaluate the JSON pointer. + * @return The {@link JsonNode} resolved by evaluating this JSON pointer. + * @throws JsonPointerEvaluationException The pointer could not be evaluated. + */ + public JsonNode evaluate(final JsonNode document) throws JsonPointerEvaluationException { + JsonNode current = document; + + for (int idx = 0; idx < tokens.length; ++idx) { + final RefToken token = tokens[idx]; + + if (current.isArray()) { + if (!token.isArrayIndex()) + error(idx, "Can't reference field \"" + token.getField() + "\" on array", document); + if (token.getIndex() == LAST_INDEX || token.getIndex() >= current.size()) + error(idx, "Array index " + token.toString() + " is out of bounds", document); + current = current.get(token.getIndex()); + } else if (current.isObject()) { + if (!current.has(token.getField())) + error(idx, "Missing field \"" + token.getField() + "\"", document); + current = current.get(token.getField()); + } else + error(idx, "Can't reference past scalar value", document); + } + + return current; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + JsonPointer that = (JsonPointer) o; + + // Probably incorrect - comparing Object[] arrays with Arrays.equals + return Arrays.equals(tokens, that.tokens); + } + + @Override + public int hashCode() { + return Arrays.hashCode(tokens); + } + + /** Represents a single JSON Pointer reference token. */ + static class RefToken { + private String decodedToken; + transient private Integer index = null; + + public RefToken(String decodedToken) { + if (decodedToken == null) + throw new IllegalArgumentException("Token can't be null"); + this.decodedToken = decodedToken; + } + + private static final Pattern DECODED_TILDA_PATTERN = Pattern.compile("~0"); + private static final Pattern DECODED_SLASH_PATTERN = Pattern.compile("~1"); + + private static String decodePath(Object object) { + String path = object.toString(); // see http://tools.ietf.org/html/rfc6901#section-4 + path = DECODED_SLASH_PATTERN.matcher(path).replaceAll("/"); + return DECODED_TILDA_PATTERN.matcher(path).replaceAll("~"); + } + + private static final Pattern ENCODED_TILDA_PATTERN = Pattern.compile("~"); + private static final Pattern ENCODED_SLASH_PATTERN = Pattern.compile("/"); + + private static String encodePath(Object object) { + String path = object.toString(); // see http://tools.ietf.org/html/rfc6901#section-4 + path = ENCODED_TILDA_PATTERN.matcher(path).replaceAll("~0"); + return ENCODED_SLASH_PATTERN.matcher(path).replaceAll("~1"); + } + + private static final Pattern VALID_ARRAY_IND = Pattern.compile("-|0|(?:[1-9][0-9]*)"); + + public static RefToken parse(String rawToken) { + if (rawToken == null) + throw new IllegalArgumentException("Token can't be null"); + return new RefToken(decodePath(rawToken)); + } + + public boolean isArrayIndex() { + if (index != null) + return true; + Matcher matcher = VALID_ARRAY_IND.matcher(decodedToken); + if (matcher.matches()) { + index = matcher.group().equals("-") ? LAST_INDEX : Integer.parseInt(matcher.group()); + return true; + } + return false; + } + + public int getIndex() { + if (!isArrayIndex()) + throw new IllegalStateException("Object operation on array target"); + return index; + } + + public String getField() { + return decodedToken; + } + + @Override + public String toString() { + return encodePath(decodedToken); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + RefToken refToken = (RefToken) o; + + return decodedToken.equals(refToken.decodedToken); + } + + @Override + public int hashCode() { + return decodedToken.hashCode(); + } + } + + /** + * Represents an array index pointing past the end of the array. + * + * Such an index is represented by the JSON pointer reference token "{@code -}"; see + * RFC 6901 section 4 for + * more details. + */ + final static int LAST_INDEX = Integer.MIN_VALUE; +} diff --git a/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/JsonPointerEvaluationException.java b/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/JsonPointerEvaluationException.java new file mode 100644 index 00000000000..0c895ab3ca5 --- /dev/null +++ b/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/JsonPointerEvaluationException.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * Licensed 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 io.fabric8.zjsonpatch; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * This class is ported from FlipKart + * JSONPatch repository + */ +public class JsonPointerEvaluationException extends Exception { + private final JsonPointer path; + private final JsonNode target; + + public JsonPointerEvaluationException(String message, JsonPointer path, JsonNode target) { + super(message); + this.path = path; + this.target = target; + } + + public JsonPointer getPath() { + return path; + } + + public JsonNode getTarget() { + return target; + } +} diff --git a/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/NodeType.java b/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/NodeType.java new file mode 100644 index 00000000000..ce66eccf6c7 --- /dev/null +++ b/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/NodeType.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * Licensed 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 io.fabric8.zjsonpatch; + +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.EnumMap; +import java.util.Map; + +/** + * This class is ported from FlipKart + * JSONPatch repository + */ +enum NodeType { + /** + * Array nodes + */ + ARRAY("array"), + /** + * Boolean nodes + */ + BOOLEAN("boolean"), + /** + * Integer nodes + */ + INTEGER("integer"), + /** + * Number nodes (ie, decimal numbers) + */ + NULL("null"), + /** + * Object nodes + */ + NUMBER("number"), + /** + * Null nodes + */ + OBJECT("object"), + /** + * String nodes + */ + STRING("string"); + + /** + * The name for this type, as encountered in a JSON schema + */ + private final String name; + + private static final Map TOKEN_MAP = new EnumMap<>(JsonToken.class); + + static { + TOKEN_MAP.put(JsonToken.START_ARRAY, ARRAY); + TOKEN_MAP.put(JsonToken.VALUE_TRUE, BOOLEAN); + TOKEN_MAP.put(JsonToken.VALUE_FALSE, BOOLEAN); + TOKEN_MAP.put(JsonToken.VALUE_NUMBER_INT, INTEGER); + TOKEN_MAP.put(JsonToken.VALUE_NUMBER_FLOAT, NUMBER); + TOKEN_MAP.put(JsonToken.VALUE_NULL, NULL); + TOKEN_MAP.put(JsonToken.START_OBJECT, OBJECT); + TOKEN_MAP.put(JsonToken.VALUE_STRING, STRING); + + } + + NodeType(final String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } + + public static NodeType getNodeType(final JsonNode node) { + final JsonToken token = node.asToken(); + final NodeType ret = TOKEN_MAP.get(token); + if (ret == null) + throw new NullPointerException("unhandled token type " + token); + return ret; + } +} diff --git a/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/internal/collections4/ListUtils.java b/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/internal/collections4/ListUtils.java new file mode 100644 index 00000000000..e141ca58abe --- /dev/null +++ b/zjsonpatch/src/main/java/io/fabric8/zjsonpatch/internal/collections4/ListUtils.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * Licensed 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 io.fabric8.zjsonpatch.internal.collections4; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Ported from Apache + * Commons Collections + */ +public class ListUtils { + private ListUtils() { + } + + public static List longestCommonSubsequence(final List list1, final List list2) { + Objects.requireNonNull(list1, "listA"); + Objects.requireNonNull(list2, "listB"); + int[][] dp = new int[list1.size() + 1][list2.size() + 1]; + + for (int list1Index = 1; list1Index <= list1.size(); list1Index++) { + for (int list2Index = 1; list2Index <= list2.size(); list2Index++) { + if (list1.get(list1Index - 1).equals(list2.get(list2Index - 1))) { + dp[list1Index][list2Index] = dp[list1Index - 1][list2Index - 1] + 1; + } else { + dp[list1Index][list2Index] = Math.max(dp[list1Index - 1][list2Index], dp[list1Index][list2Index - 1]); + } + } + } + + List lcs = new ArrayList<>(); + int list1Index = list1.size(); + int list2Index = list2.size(); + while (list1Index > 0 && list2Index > 0) { + if (list1.get(list1Index - 1).equals(list2.get(list2Index - 1))) { + lcs.add(list1.get(list1Index - 1)); + list1Index--; + list2Index--; + } else if (dp[list1Index - 1][list2Index] >= dp[list1Index][list2Index - 1]) { + list1Index--; + } else { + list2Index--; + } + } + + java.util.Collections.reverse(lcs); + return lcs; + } +} diff --git a/zjsonpatch/src/test/java/io/fabric8/zjsonpatch/JsonDiffTest.java b/zjsonpatch/src/test/java/io/fabric8/zjsonpatch/JsonDiffTest.java new file mode 100644 index 00000000000..595eba41dd4 --- /dev/null +++ b/zjsonpatch/src/test/java/io/fabric8/zjsonpatch/JsonDiffTest.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * Licensed 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 io.fabric8.zjsonpatch; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class JsonDiffTest { + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("asJson should use ported ListUtils.longestCommonSubsequence in absence of Apache Commons Collections") + void asJson_inAbsenceOfCommonsCollectionsDependency_shouldNotThrowError() { + // Given + Map m1 = Collections.singletonMap("key1", "value1"); + Map m2 = new HashMap<>(); + m2.put("key1", "value1"); + m2.put("key2", "value2"); + ArrayNode node1 = objectMapper.createArrayNode(); + node1.add(objectMapper.convertValue(m1, JsonNode.class)); + ArrayNode node2 = objectMapper.createArrayNode(); + node2.add(objectMapper.convertValue(m2, JsonNode.class)); + + // When + JsonNode jsonNode = JsonDiff.asJson(node1, node2); + + // Then + assertThat(jsonNode) + .satisfies(j -> assertThat(j.isArray()).isTrue()) + .satisfies(n -> assertThat(n.get(0).get("op").asText()).isEqualTo("add")) + .satisfies(n -> assertThat(n.get(0).get("path").asText()).isEqualTo("/0/key2")) + .satisfies(n -> assertThat(n.get(0).get("value").asText()).isEqualTo("value2")) + .isNotNull(); + } +} diff --git a/zjsonpatch/src/test/java/io/fabric8/zjsonpatch/internal/collections4/ListUtilsTest.java b/zjsonpatch/src/test/java/io/fabric8/zjsonpatch/internal/collections4/ListUtilsTest.java new file mode 100644 index 00000000000..f77c9704278 --- /dev/null +++ b/zjsonpatch/src/test/java/io/fabric8/zjsonpatch/internal/collections4/ListUtilsTest.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2015 Red Hat, Inc. + * + * Licensed 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 io.fabric8.zjsonpatch.internal.collections4; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +class ListUtilsTest { + @Test + void longestCommonSubsequence() { + assertThatNullPointerException() + .isThrownBy(() -> ListUtils.longestCommonSubsequence((List) null, null)) + .withMessageContaining("listA"); + assertThatNullPointerException() + .isThrownBy(() -> ListUtils.longestCommonSubsequence(Collections.singletonList('A'), null)) + .withMessageContaining("listB"); + assertThatNullPointerException() + .isThrownBy(() -> ListUtils.longestCommonSubsequence(null, Collections.singletonList('A'))) + .withMessageContaining("listA"); + + assertThat(ListUtils.longestCommonSubsequence(Collections.emptyList(), Collections.emptyList())).isEmpty(); + + final List list1 = Arrays.asList('B', 'A', 'N', 'A', 'N', 'A'); + final List list2 = Arrays.asList('A', 'N', 'A', 'N', 'A', 'S'); + assertThat(ListUtils.longestCommonSubsequence(list1, list2)) + .containsExactly('A', 'N', 'A', 'N', 'A'); + + final List list3 = Arrays.asList('A', 'T', 'A', 'N', 'A'); + assertThat(ListUtils.longestCommonSubsequence(list1, list3)) + .containsExactly('A', 'A', 'N', 'A'); + + assertThat(ListUtils.longestCommonSubsequence(list1, Arrays.asList('Z', 'O', 'R', 'R', 'O'))).isEmpty(); + } +}