com.yahoo.elide.extensions.JsonApiPatch.java Source code

Java tutorial

Introduction

Here is the source code for com.yahoo.elide.extensions.JsonApiPatch.java

Source

/*
 * Copyright 2015, Yahoo Inc.
 * Licensed under the Apache License, Version 2.0
 * See LICENSE file in project root for terms.
 */
package com.yahoo.elide.extensions;

import com.yahoo.elide.Elide;
import com.yahoo.elide.core.DataStore;
import com.yahoo.elide.core.HttpStatus;
import com.yahoo.elide.core.RequestScope;
import com.yahoo.elide.core.SecurityMode;
import com.yahoo.elide.core.exceptions.HttpStatusException;
import com.yahoo.elide.core.exceptions.InvalidEntityBodyException;
import com.yahoo.elide.jsonapi.models.Data;
import com.yahoo.elide.jsonapi.models.JsonApiDocument;
import com.yahoo.elide.jsonapi.models.Patch;
import com.yahoo.elide.jsonapi.models.Resource;
import com.yahoo.elide.parsers.DeleteVisitor;
import com.yahoo.elide.parsers.PatchVisitor;
import com.yahoo.elide.parsers.PostVisitor;

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.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.tuple.Pair;

import java.io.IOException;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Collectors;

/**
 * Json API patch extension.
 * See: http://jsonapi.org/extensions/jsonpatch/
 */
public class JsonApiPatch {
    private static class PatchAction {
        public final Patch patch;

        // Failure
        public HttpStatusException cause;

        // Post Processing
        public boolean isPostProcessing;
        public JsonApiDocument doc;
        public String path;

        public PatchAction(Patch patch) {
            this.patch = patch;
            this.cause = null;
        }

        public void postProcess(PatchRequestScope requestScope) {
            if (isPostProcessing) {
                try {
                    // Only update relationships
                    clearAllExceptRelationships(doc);
                    PatchVisitor visitor = new PatchVisitor(new PatchRequestScope(doc, requestScope));
                    visitor.visit(Elide.parse(path));
                } catch (HttpStatusException e) {
                    cause = e;
                    throw e;
                }
            }
        }
    }

    private final List<PatchAction> actions;
    private final String rootUri;

    private static final ObjectNode ERR_NODE_ERR_IN_SUBSEQUENT_OPERATION;
    private static final ObjectNode ERR_NODE_OPERATION_NOT_RUN;

    static {
        ERR_NODE_OPERATION_NOT_RUN = JsonNodeFactory.instance.objectNode();
        ERR_NODE_OPERATION_NOT_RUN.set("detail",
                JsonNodeFactory.instance.textNode("Operation not executed. Terminated by earlier failure."));

        ERR_NODE_ERR_IN_SUBSEQUENT_OPERATION = JsonNodeFactory.instance.objectNode();
        ERR_NODE_ERR_IN_SUBSEQUENT_OPERATION.set("detail",
                JsonNodeFactory.instance.textNode("Subsequent operation failed."));
    }

    /**
     * Process json patch.
     *
     * @param dataStore the dataStore
     * @param uri the uri
     * @param patchDoc the patch doc
     * @param requestScope request scope
     * @return pair
     * @throws IOException the iO exception
     */
    public static Supplier<Pair<Integer, JsonNode>> processJsonPatch(DataStore dataStore, String uri,
            String patchDoc, PatchRequestScope requestScope) throws IOException {
        List<Patch> actions = requestScope.getMapper().readJsonApiPatchExtDoc(patchDoc);
        JsonApiPatch processor = new JsonApiPatch(dataStore, actions, uri, requestScope);
        return processor.processActions(requestScope);
    }

    /**
     * Constructor.
     *
     * @param dataStore Data Store
     * @param actions List of patch actions
     * @param rootUri root URI
     */
    private JsonApiPatch(DataStore dataStore, List<Patch> actions, String rootUri, RequestScope requestScope) {
        this.actions = actions.stream().map(PatchAction::new).collect(Collectors.toList());
        this.rootUri = rootUri;
    }

    /**
     * Process json patch actions.
     *
     * @return Pair (return code, JsonNode)
     */
    private Supplier<Pair<Integer, JsonNode>> processActions(PatchRequestScope requestScope) {
        try {
            List<Supplier<Pair<Integer, JsonNode>>> results = handleActions(requestScope);

            postProcessRelationships(requestScope);

            // Avoid any lazy loading issues
            results.forEach(Supplier::get);

            return () -> {
                try {
                    return Pair.of(HttpStatus.SC_OK, mergeResponse(results));
                } catch (HttpStatusException e) {
                    return buildErrorResponse(e, requestScope.getSecurityMode());
                }
            };
        } catch (HttpStatusException e) {
            return () -> buildErrorResponse(e, requestScope.getSecurityMode());
        }
    }

    /**
     * Handle a patch action.
     *
     * @param requestScope outer request scope
     * @return List of responders
     */
    private List<Supplier<Pair<Integer, JsonNode>>> handleActions(PatchRequestScope requestScope) {
        return actions.stream().map(action -> {
            Supplier<Pair<Integer, JsonNode>> result;
            try {
                String[] combined = ArrayUtils.addAll(rootUri.split("/"), action.patch.getPath().split("/"));
                String fullPath = String.join("/", combined).replace("/-", "");
                switch (action.patch.getOperation()) {
                case ADD:
                    result = handleAddOp(fullPath, action.patch.getValue(), requestScope, action);
                    break;
                case REPLACE:
                    result = handleReplaceOp(fullPath, action.patch.getValue(), requestScope);
                    break;
                case REMOVE:
                    result = handleRemoveOp(fullPath, action.patch.getValue(), requestScope);
                    break;
                default:
                    throw new InvalidEntityBodyException(
                            "Could not parse patch extension operation:" + action.patch.getOperation());
                }
                return result;
            } catch (HttpStatusException e) {
                action.cause = e;
                throw e;
            }
        }).collect(Collectors.toList());
    }

    /**
     * Add a document via patch extension.
     */
    private Supplier<Pair<Integer, JsonNode>> handleAddOp(String path, JsonNode patchValue,
            PatchRequestScope requestScope, PatchAction action) {
        try {
            JsonApiDocument value = requestScope.getMapper().readJsonApiPatchExtValue(patchValue);
            Data<Resource> data = value.getData();
            if (data == null || data.get() == null) {
                throw new InvalidEntityBodyException("Expected an entity body but received none.");
            }
            Collection<Resource> resources = data.get();
            if (!path.contains("relationships")) { // Reserved key for relationships
                String id = getSingleResource(resources).getId();
                String fullPath = path + "/" + id;
                // Defer relationship updating until the end
                getSingleResource(resources).setRelationships(null);
                // Reparse since we mangle it first
                action.doc = requestScope.getMapper().readJsonApiPatchExtValue(patchValue);
                action.path = fullPath;
                action.isPostProcessing = true;
            }
            PostVisitor visitor = new PostVisitor(new PatchRequestScope(value, requestScope));
            return visitor.visit(Elide.parse(path));
        } catch (HttpStatusException e) {
            action.cause = e;
            throw e;
        } catch (IOException e) {
            throw new InvalidEntityBodyException("Could not parse patch extension value: " + patchValue);
        }
    }

    /**
     * Replace data via patch extension.
     */
    private Supplier<Pair<Integer, JsonNode>> handleReplaceOp(String path, JsonNode patchVal,
            PatchRequestScope requestScope) {
        try {
            JsonApiDocument value = requestScope.getMapper().readJsonApiPatchExtValue(patchVal);
            // Defer relationship updating until the end
            PatchVisitor visitor = new PatchVisitor(new PatchRequestScope(value, requestScope));
            return visitor.visit(Elide.parse(path));
        } catch (IOException e) {
            throw new InvalidEntityBodyException("Could not parse patch extension value: " + patchVal);
        }
    }

    /**
     * Remove data via patch extension.
     */
    private Supplier<Pair<Integer, JsonNode>> handleRemoveOp(String path, JsonNode patchValue,
            PatchRequestScope requestScope) {
        try {
            JsonApiDocument value = requestScope.getMapper().readJsonApiPatchExtValue(patchValue);
            String fullPath;
            if (path.contains("relationships")) { // Reserved keyword for relationships
                fullPath = path;
            } else {
                Data<Resource> data = value.getData();
                if (data == null || data.get() == null) {
                    fullPath = path;
                } else {
                    Collection<Resource> resources = data.get();
                    String id = getSingleResource(resources).getId();
                    fullPath = path + "/" + id;
                }
            }
            DeleteVisitor visitor = new DeleteVisitor(new PatchRequestScope(value, requestScope));
            return visitor.visit(Elide.parse(fullPath));
        } catch (IOException e) {
            throw new InvalidEntityBodyException("Could not parse patch extension value: " + patchValue);
        }
    }

    /**
     * Post-process relationships after all objects for request have been created.
     *
     * This is required since we have no way of determining which object should be created first. That is,
     * in the case of a cyclic relationship between 2 or more newly created objects, some object needs to be created
     * first. In our case, we will create all objects and then add the relationships in memory. Finally, at the end, we
     * rely on the commit of DataStoreTransaction to handle the creation properly.
     *
     * @param requestScope request scope
     */
    private void postProcessRelationships(PatchRequestScope requestScope) {
        actions.forEach(action -> action.postProcess(requestScope));
    }

    /**
     * Turn an exception into a proper error response from patch extension.
     */
    private Pair<Integer, JsonNode> buildErrorResponse(HttpStatusException e, SecurityMode securityMode) {
        if (e.getStatus() == HttpStatus.SC_FORBIDDEN) {
            return securityMode == SecurityMode.SECURITY_ACTIVE_VERBOSE ? e.getVerboseErrorResponse()
                    : e.getErrorResponse();
        }

        ObjectNode errorContainer = getErrorContainer();
        ArrayNode errorList = (ArrayNode) errorContainer.get("errors");

        boolean failed = false;
        for (PatchAction action : actions) {
            failed = processAction(errorList, failed, action);
        }
        return Pair.of(HttpStatus.SC_BAD_REQUEST, errorContainer);
    }

    private ObjectNode getErrorContainer() {
        ObjectNode container = JsonNodeFactory.instance.objectNode();
        container.set("errors", JsonNodeFactory.instance.arrayNode());
        return container;
    }

    private boolean processAction(ArrayNode errorList, boolean failed, PatchAction action) {
        if (action.cause != null) {
            // this is the failed operation
            errorList.add(toErrorNode(action.cause.getMessage(), action.cause.getStatus()));
            failed = true;
        } else if (!failed) {
            // this operation succeeded
            errorList.add(ERR_NODE_ERR_IN_SUBSEQUENT_OPERATION);
        } else {
            // this operation never ran
            errorList.add(ERR_NODE_OPERATION_NOT_RUN);
        }
        return failed;
    }

    /**
     * Clear all relationships for all resources in document.
     */
    private static void clearAllExceptRelationships(JsonApiDocument doc) {
        Data<Resource> data = doc.getData();
        if (data == null || data.get() == null) {
            return;
        }
        data.get().forEach(JsonApiPatch::clearAllExceptRelationships);
    }

    /**
     * Clear all properties except the relationships.
     */
    private static void clearAllExceptRelationships(Resource resource) {
        resource.setAttributes(null);
        resource.setLinks(null);
        resource.setMeta(null);
    }

    /**
     * Convert a message and status to an error node.
     *
     */
    private static JsonNode toErrorNode(String detail, Integer status) {
        ObjectNode formattedError = JsonNodeFactory.instance.objectNode();
        formattedError.set("detail", JsonNodeFactory.instance.textNode(detail));
        if (status != null) {
            formattedError.set("status", JsonNodeFactory.instance.numberNode(status));
        }
        return formattedError;
    }

    /**
     * Merge response documents to create final response.
     */
    private static JsonNode mergeResponse(List<Supplier<Pair<Integer, JsonNode>>> results) {
        ArrayNode list = JsonNodeFactory.instance.arrayNode();
        for (Supplier<Pair<Integer, JsonNode>> result : results) {
            JsonNode node = result.get().getRight();
            if (node == null || node instanceof NullNode) {
                node = JsonNodeFactory.instance.objectNode().set("data", null);
            }
            list.add(node);
        }
        return list;
    }

    /**
     * Determine whether or not ext = jsonpatch is present in header.
     *
     * @param header the header
     * @return True if Json patch, false otherwise
     */
    public static boolean isPatchExtension(String header) {
        if (header == null) {
            return false;
        }

        // Find ext=jsonpatch
        return Arrays.asList(header.split(";")).stream().map(key -> key.split("="))
                .filter(value -> value.length == 2)
                .anyMatch(value -> value[0].trim().equals("ext") && value[1].trim().equals("jsonpatch"));
    }

    private static Resource getSingleResource(Collection<Resource> resources) {
        if (resources == null || resources.size() != 1) {
            throw new InvalidEntityBodyException("Expected single resource.");
        }
        return resources.iterator().next();
    }
}