ca.ibodrov.mica.server.data.ViewRenderer Maven / Gradle / Ivy
package ca.ibodrov.mica.server.data;
import ca.ibodrov.mica.api.model.EntityLike;
import ca.ibodrov.mica.api.model.ViewLike;
import ca.ibodrov.mica.server.exceptions.ViewProcessorException;
import com.fasterxml.jackson.core.JsonProcessingException;
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.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.flipkart.zjsonpatch.InvalidJsonPatchException;
import com.flipkart.zjsonpatch.JsonPatch;
import com.flipkart.zjsonpatch.JsonPatchApplicationException;
import com.google.common.collect.ImmutableList;
import java.util.List;
import java.util.Optional;
import java.util.Spliterator;
import java.util.stream.Stream;
import static java.util.Objects.requireNonNull;
import static java.util.Spliterators.spliteratorUnknownSize;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.StreamSupport.stream;
public class ViewRenderer {
private final JsonPathEvaluator jsonPathEvaluator;
private final ObjectMapper objectMapper;
public ViewRenderer(JsonPathEvaluator jsonPathEvaluator, ObjectMapper objectMapper) {
this.jsonPathEvaluator = requireNonNull(jsonPathEvaluator);
this.objectMapper = requireNonNull(objectMapper);
}
public RenderedView render(ViewLike view, Stream extends EntityLike> entities) {
return render(view, RenderOverrides.none(), entities);
}
/**
* Render a /mica/view/v1 using the given entities and parameters.
*/
public RenderedView render(ViewLike view, RenderOverrides overrides, Stream extends EntityLike> entities) {
// interpolate JSON path using the supplied parameters
var jsonPath = requireNonNull(view.data().jsonPath());
var entityNames = ImmutableList.builder();
// apply JSON path
var data = entities
// ...while we are at it, collect the entity names
.peek(entity -> entityNames.add(entity.name()))
.map(row -> applyAllJsonPaths(row.name(), objectMapper.convertValue(row, JsonNode.class), jsonPath))
.flatMap(Optional::stream)
.toList();
if (data.isEmpty()) {
return RenderedView.empty(view, entityNames.build());
}
// flatten - convert an array of arrays by concatenating them into a single
// array
var flatten = view.data().flatten().orElse(false);
if (flatten && data.stream().allMatch(JsonNode::isArray)) {
data = data.stream()
.flatMap(node -> stream(spliteratorUnknownSize(node.elements(), Spliterator.ORDERED), false))
.toList();
}
// mergeBy - group by a JSON path and merge the groups
var mergeBy = view.data().mergeBy().filter(JsonNode::isTextual)
.map(JsonNode::asText);
if (mergeBy.isPresent()) {
data = data.stream()
.collect(groupingBy(
node -> jsonPathEvaluator.apply(entityNames.build().toString(), node, mergeBy.get())
.orElse(NullNode.getInstance())))
.entrySet().stream()
.flatMap(entry -> {
var rows = entry.getValue();
return rows.stream()
.reduce((a, b) -> deepMerge((ObjectNode) a, (ObjectNode) b))
.stream();
})
.toList();
} else {
// merge - convert an array of objects into a single object
var merge = overrides.alwaysMerge() || view.data().merge().orElse(false);
if (merge && data.stream().allMatch(JsonNode::isObject)) {
var mergedData = data.stream()
.reduce((a, b) -> deepMerge((ObjectNode) a, (ObjectNode) b))
.orElseThrow(() -> new ViewProcessorException("Expected a merge result, got nothing"));
data = List.of(mergedData);
}
}
// apply JSON patch
var patch = view.data().jsonPatch().filter(p -> !p.isNull());
if (patch.isPresent()) {
var patchData = patch.get();
try {
JsonPatch.validate(patchData);
} catch (InvalidJsonPatchException e) {
throw new ViewProcessorException("Invalid data.jsonPatch: " + e.getMessage());
}
data = data.stream()
.map(node -> applyJsonPatch(node, patchData))
.toList();
}
// drop properties if requested
var dropProperties = view.data().dropProperties().orElse(List.of());
if (!dropProperties.isEmpty()) {
data.forEach(node -> {
if (!node.isObject()) {
throw new ViewProcessorException(
"dropProperties can only be applied to arrays of objects. The data is an array of %ss"
.formatted(node.getNodeType()));
}
((ObjectNode) node).remove(dropProperties);
});
}
// apply "map"
var map = view.data().map();
if (map.isPresent()) {
data = data.stream()
.map(node -> {
if (!node.isObject()) {
throw new ViewProcessorException(
"map can only be applied to arrays of objects. The data is an array of %ss"
.formatted(node.getNodeType()));
}
var result = objectMapper.createObjectNode();
map.get().forEach((key, value) -> {
var output = applyAllJsonPaths(entityNames.build().toString(), node, value);
output.ifPresent(jsonNode -> result.set(key, jsonNode));
});
return (JsonNode) result;
})
.toList();
}
return new RenderedView(view, data, entityNames.build());
}
private Optional applyAllJsonPaths(String entityName, JsonNode data, JsonNode jsonPath) {
if (jsonPath.isTextual()) {
return jsonPathEvaluator.apply(entityName, data, jsonPath.asText());
} else if (jsonPath.isArray()) {
var result = data;
for (int i = 0; i < jsonPath.size(); i++) {
var node = jsonPath.get(i);
String jsonPath1 = node.asText();
var output = jsonPathEvaluator.apply(entityName, result, jsonPath1);
if (output.isEmpty()) {
return Optional.empty();
}
result = output.get();
}
return Optional.of(result);
} else {
throw new ViewProcessorException(
"Expected a string or an array of strings as JSON path, got a " + jsonPath.getNodeType());
}
}
private JsonNode applyJsonPatch(JsonNode node, JsonNode patchData) {
if (!node.isContainerNode()) {
throw new ViewProcessorException(
"JSON patch can only be applied to arrays of objects and array of arrays. The data is an array of %ss"
.formatted(node.getNodeType()));
}
try {
return JsonPatch.apply(patchData, node);
} catch (JsonPatchApplicationException e) {
throw new ViewProcessorException(
"Error while applying data.jsonPatch: " + e.getMessage());
}
}
private ObjectNode deepMerge(ObjectNode left, ObjectNode right) {
var mapper = objectMapper.copy();
mapper.configOverride(ArrayNode.class)
.setMergeable(false);
try {
return mapper.updateValue(left, right);
} catch (JsonProcessingException e) {
throw new ViewProcessorException("Error while merging JSON objects: " + e.getMessage());
}
}
public record RenderOverrides(boolean alwaysMerge) {
public static RenderOverrides none() {
return new RenderOverrides(false);
}
public static RenderOverrides merged() {
return new RenderOverrides(true);
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy