All Downloads are FREE. Search and download functionalities are using the official Maven repository.

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 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 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