package restx.tests.json; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectWriter; import com.google.common.base.Charsets; import com.google.common.collect.ImmutableMap; import com.google.common.collect.LinkedListMultimap; import com.google.common.collect.Multimap; import com.google.common.io.Files; import restx.common.Mustaches; import java.io.File; import java.io.IOException; import java.net.URL; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import static restx.common.MoreStrings.indent; /** * Date: 4/2/14 * Time: 21:34 */ public class JsonAssertions { public static JsonAssertions assertThat(String json) { return new JsonAssertions(new StringJsonSource("actual", json)); } public static JsonAssertions assertThat(File json, Charset cs) { return new JsonAssertions(new FileJsonSource(json, cs)); } public static JsonAssertions assertThat(URL json, Charset cs) { return new JsonAssertions(new URLJsonSource(json, cs)); } public static JsonAssertions assertThat(JsonSource json) { return new JsonAssertions(json); } private final JsonDiffer differ; private final JsonSource actual; private final ObjectMapper objectMapper = new ObjectMapper(); private boolean allowingExtraUnexpectedFields; private int contentLengthHtmlReportThreshold = 600; private JsonAssertions(JsonSource actual) { this.actual = actual; differ = new JsonDiffer(); } public JsonAssertions allowingExtraUnexpectedFields() { allowingExtraUnexpectedFields = true; differ.getLeftConfig().setIgnoreExtraFields(true); return this; } public JsonAssertions withJsonDiffComparator(JsonDiffComparator jsonDiffComparator) { differ.setJsonDiffComparator(jsonDiffComparator); return this; } public JsonAssertions withContentLengthHtmlReportThreshold(final int contentLengthHtmlReportThreshold) { this.contentLengthHtmlReportThreshold = contentLengthHtmlReportThreshold; return this; } public JsonAssertions isSameJsonAs(String expected) { return isSameJsonAs(new StringJsonSource("expected", expected)); } public JsonAssertions isSameJsonAs(File expected, Charset cs) { return isSameJsonAs(new FileJsonSource(expected, cs)); } public JsonAssertions isSameJsonAs(URL expected, Charset cs) { return isSameJsonAs(new URLJsonSource(expected, cs)); } public JsonAssertions isSameJsonAs(JsonSource expected) { JsonDiff diff = differ.compare(actual, expected); if (!diff.isSame()) { if (expected.content().length() < contentLengthHtmlReportThreshold && actual.content().length() < contentLengthHtmlReportThreshold) { throw new AssertionError(fullTextReport(expected, diff).toString()); } else { StringBuilder sb = new StringBuilder(); sb.append("Expecting:\n") .append(indent(limit(actual.content(), contentLengthHtmlReportThreshold), 2)).append("\n") .append("\nto be same json as:\n") .append(indent(limit(expected.content(), contentLengthHtmlReportThreshold), 2)).append("\n") .append("\nbut following differences were found:\n\n"); int i = 1; for (JsonDiff.Difference difference : diff.getDifferences()) { appendDifferenceInfo(i, sb, difference); i++; } try { File htmlReport = File.createTempFile("json-diff", ".html"); List<Object> differences = new ArrayList<>(); ObjectWriter objectWriter = objectMapper.writerWithDefaultPrettyPrinter(); i = 1; for (JsonDiff.Difference difference : diff.getDifferences()) { StringBuilder diffSb = new StringBuilder(); appendDifferenceInfo(i, diffSb, difference); String actualKeyOfInterest; String expectedKeyOfInterest; String actualContextPath; String expectedContextPath; // for added and removed keys, the interesting context is at captured path. // for others it's more interesting to get up one level if (difference instanceof JsonDiff.AddedKey) { actualKeyOfInterest = ""; actualContextPath = difference.getLeftPath(); expectedKeyOfInterest = ((JsonDiff.AddedKey) difference).getKey(); expectedContextPath = difference.getRightPath(); } else if (difference instanceof JsonDiff.RemovedKey) { actualKeyOfInterest = ((JsonDiff.RemovedKey) difference).getKey(); actualContextPath = difference.getLeftPath(); expectedKeyOfInterest = ""; expectedContextPath = difference.getRightPath(); } else { actualKeyOfInterest = diff.getLastElementPath(difference.getLeftPath()); expectedKeyOfInterest = diff.getLastElementPath(difference.getRightPath()); actualContextPath = diff.getParentPath(difference.getLeftPath()); expectedContextPath = diff.getParentPath(difference.getRightPath()); } differences.add( ImmutableMap.builder() .put("number", String.valueOf(i)) .put("difference", diffSb.toString().replace("\"", "\\\"").replace("\n", "\\n")) .put("actual-path", difference.getLeftPath()) .put("actual-keyOfInterest", actualKeyOfInterest) .put("actual-context", objectWriter.writeValueAsString( toContext(diff.getLeftAt(actualContextPath), actualKeyOfInterest)) .replace("\"", "\\\"").replace("\n", "\\n")) .put("expected-path", difference.getRightPath()) .put("expected-keyOfInterest", expectedKeyOfInterest) .put("expected-context", objectWriter.writeValueAsString( toContext(diff.getRightAt(expectedContextPath), expectedKeyOfInterest)) .replace("\"", "\\\"").replace("\n", "\\n")) .build() ); i++; } String r = Mustaches.execute(Mustaches.compile(JsonAssertions.class, "json-diff.html"), ImmutableMap.of( "actual", actual.content(), "expected", expected.content(), "diff", diff, "differences", differences, "fix-expected", new JsonMerger().mergeToRight(diff) )); Files.write(r, htmlReport, Charsets.UTF_8); sb.append("\n\nA detailed HTML REPORT has been generated in ") .append(htmlReport.toURI().toURL()).append("\n"); } catch (IOException e) { sb.append("\n\nERROR occured when generating html report: " + e + "\n\n"); } throw new AssertionError(sb.toString()); } } return this; } @SuppressWarnings("unchecked") private Object toContext(Object o, String keyOfInterest) { if (o instanceof Map) { Map<String, Object> map = (Map) o; Map<String, Object> context = new LinkedHashMap<>(); for (String key : map.keySet()) { Object v = map.get(key); if (key.equals(keyOfInterest)) { context.put(key, v); } else { if (v instanceof Map) { context.put(key, "/object with " + ((Map) v).size() + " entries/"); } else if (v instanceof List) { context.put(key, "/array with " + ((List) v).size() + " entries/"); } else { context.put(key, v); } } } return context; } return o; } private String limit(String s, int threshold) { if (s.length() <= threshold) { return s; } return s.substring(0, threshold) + "[...]\n[" + (s.length() - threshold) + " chars stripped]"; } protected StringBuilder fullTextReport(JsonSource expected, JsonDiff diff) { StringBuilder sb = new StringBuilder(); sb.append("Expecting:\n") .append(indent(actual.content(), 2)).append("\n") .append("to be same json as:\n") .append(indent(expected.content(), 2)).append("\n") .append("but following differences were found:\n"); Multimap<JsonObjectLocation, JsonDiff.Difference> differencesPerLocation = LinkedListMultimap.create(); for (JsonDiff.Difference difference : diff.getDifferences()) { differencesPerLocation.put(difference.getLeftContext(), difference); } int i = 1; for (JsonObjectLocation context : differencesPerLocation.keySet()) { sb.append(String.format("- within [L%dC%d]-[L%dC%d]:\n", context.getFrom().getLineNr(), context.getFrom().getColumnNr(), context.getTo().getLineNr(), context.getTo().getColumnNr())) .append(indent(context.getJson(), 2)) .append("\n"); for (JsonDiff.Difference difference : differencesPerLocation.get(context)) { appendDifferenceInfo(i, sb, difference); i++; } } // merging can be done with simple copy paste when not allowing extra fields, // but with extra fields allowed copying the actual content when it's the expectation which is not // up to date leads to adding previously ignored fields to the expectation. // therefore we dump a merged expect in this case to ease test maintenance. if (allowingExtraUnexpectedFields) { sb.append("\n\nif the expectation is not up to date, here is a merged" + " expect that you can use to fix your test:\n"); sb.append(indent(new JsonMerger().mergeToRight(diff), 2)).append("\n\n"); } return sb; } private void appendDifferenceInfo(int i, StringBuilder sb, JsonDiff.Difference difference) { if (difference instanceof JsonDiff.AddedKey) { JsonDiff.AddedKey addedKey = (JsonDiff.AddedKey) difference; sb.append(String.format("%02d) ", i)) .append("missing key '").append(addedKey.getKey()).append("'") .append(" at path '").append(addedKey.getLeftPath()).append("'") .append(" expected value:\n") .append(indent(asJson(addedKey.getValue()), 6)) .append("\n");; } if (difference instanceof JsonDiff.RemovedKey) { JsonDiff.RemovedKey removedKey = (JsonDiff.RemovedKey) difference; sb.append(String.format("%02d) ", i)) .append("extra key '").append(removedKey.getKey()).append("'") .append(" at path '").append(removedKey.getLeftPath()).append("'") .append(" with value:\n") .append(indent(asJson(removedKey.getValue()), 6)) .append("\n");; } if (difference instanceof JsonDiff.ValueDiff) { JsonDiff.ValueDiff valueDiff = (JsonDiff.ValueDiff) difference; sb.append(String.format("%02d) ", i)) .append("expected value ").append(asJson(valueDiff.getRightValue())) .append(" but was ").append(asJson(valueDiff.getLeftValue())) .append(" at path '").append(valueDiff.getLeftPath()).append("'\n"); } if (difference instanceof JsonDiff.ArrayInsertedValue) { JsonDiff.ArrayInsertedValue arrayInsertedValue = (JsonDiff.ArrayInsertedValue) difference; sb.append(String.format("%02d) ", i)) .append("missing element(s) in array at position ").append(arrayInsertedValue.getLeftPosition()) .append(" at path '").append(arrayInsertedValue.getLeftPath()).append("'") .append(" expected value(s):\n") .append(indent(asJson(arrayInsertedValue.getValues()), 6)) .append("\n"); } if (difference instanceof JsonDiff.ArrayDeletedValue) { JsonDiff.ArrayDeletedValue arrayDeletedValue = (JsonDiff.ArrayDeletedValue) difference; sb.append(String.format("%02d) ", i)) .append("extra element(s) in array at position ").append(arrayDeletedValue.getLeftPosition()) .append(" at path '").append(arrayDeletedValue.getLeftPath()).append("'") .append(" with value(s):\n") .append(indent(asJson(arrayDeletedValue.getValues()), 6)) .append("\n"); } } private String asJson(Object o) { if (o == null) { return "null"; } try { return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(o); } catch (JsonProcessingException e) { e.printStackTrace(); return o.toString(); } } }