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

com.yahoo.elide.graphql.QueryRunner Maven / Gradle / Ivy

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

import com.yahoo.elide.Elide;
import com.yahoo.elide.ElideResponse;
import com.yahoo.elide.core.datastore.DataStoreTransaction;
import com.yahoo.elide.core.exceptions.CustomErrorException;
import com.yahoo.elide.core.exceptions.ErrorObjects;
import com.yahoo.elide.core.exceptions.ForbiddenAccessException;
import com.yahoo.elide.core.exceptions.HttpStatus;
import com.yahoo.elide.core.exceptions.HttpStatusException;
import com.yahoo.elide.core.exceptions.InvalidEntityBodyException;
import com.yahoo.elide.core.exceptions.TimeoutException;
import com.yahoo.elide.core.exceptions.TransactionException;
import com.yahoo.elide.core.security.User;
import com.yahoo.elide.graphql.parser.GraphQLEntityProjectionMaker;
import com.yahoo.elide.graphql.parser.GraphQLProjectionInfo;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.Version;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import org.apache.commons.lang3.tuple.Pair;
import org.owasp.encoder.Encode;
import graphql.ExecutionInput;
import graphql.ExecutionResult;
import graphql.GraphQL;
import graphql.GraphQLError;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;

/**
 * Entry point for REST endpoints to execute GraphQL queries.
 */
@Slf4j
public class QueryRunner {
    private final Elide elide;
    private GraphQL api;
    private String apiVersion;

    private static final String QUERY = "query";
    private static final String OPERATION_NAME = "operationName";
    private static final String VARIABLES = "variables";
    private static final String MUTATION = "mutation";

    /**
     * Builds a new query runner.
     * @param elide The singular elide instance for this service.
     */
    public QueryRunner(Elide elide, String apiVersion) {
        this.elide = elide;
        this.apiVersion = apiVersion;

        NonEntityDictionary nonEntityDictionary = new NonEntityDictionary();
        PersistentResourceFetcher fetcher = new PersistentResourceFetcher(nonEntityDictionary);
        ModelBuilder builder = new ModelBuilder(elide.getElideSettings().getDictionary(),
                nonEntityDictionary, fetcher, apiVersion);

        this.api = new GraphQL(builder.build());

        // TODO - add serializers to allow for custom handling of ExecutionResult and GraphQLError objects
        GraphQLErrorSerializer errorSerializer = new GraphQLErrorSerializer();
        SimpleModule module = new SimpleModule("ExecutionResultSerializer", Version.unknownVersion());
        module.addSerializer(ExecutionResult.class, new ExecutionResultSerializer(errorSerializer));
        module.addSerializer(GraphQLError.class, errorSerializer);
        elide.getElideSettings().getMapper().getObjectMapper().registerModule(module);
    }

    /**
     * Execute a GraphQL query and return the response.
     * @param baseUrlEndPoint base URL with prefix endpoint
     * @param graphQLDocument The graphQL document (wrapped in JSON payload).
     * @param user The user who issued the query.
     * @return The response.
     */
    public ElideResponse run(String baseUrlEndPoint, String graphQLDocument, User user) {
        return run(baseUrlEndPoint, graphQLDocument, user, UUID.randomUUID());
    }

    /**
     * Check if a query string is mutation.
     * @param query The graphQL Query to verify.
     * @return is a mutation.
     */
    public static boolean isMutation(String query) {
        return query != null && query.trim().startsWith(MUTATION);
    }

    /**
     * Extracts the top level JsonNode from GraphQL document.
     * @param mapper ObjectMapper instance.
     * @param graphQLDocument The graphQL document (wrapped in JSON payload).
     * @return The JsonNode after parsing graphQLDocument.
     * @throws IOException IOException
     */
    public static JsonNode getTopLevelNode(ObjectMapper mapper, String graphQLDocument) throws IOException {
        return mapper.readTree(graphQLDocument);
    }

    /**
     * Execute a GraphQL query and return the response.
     * @param graphQLDocument The graphQL document (wrapped in JSON payload).
     * @param user The user who issued the query.
     * @param requestId the Request ID.
     * @return The response.
     */
    public ElideResponse run(String baseUrlEndPoint, String graphQLDocument, User user, UUID requestId) {
        return run(baseUrlEndPoint, graphQLDocument, user, requestId, null);
    }

    /**
     * Execute a GraphQL query and return the response.
     * @param graphQLDocument The graphQL document (wrapped in JSON payload).
     * @param user The user who issued the query.
     * @param requestId the Request ID.
     * @return The response.
     */
    public ElideResponse run(String baseUrlEndPoint, String graphQLDocument, User user, UUID requestId,
                             Map> requestHeaders) {
        ObjectMapper mapper = elide.getMapper().getObjectMapper();

        JsonNode topLevel;

        try {
            topLevel = getTopLevelNode(mapper, graphQLDocument);
        } catch (IOException e) {
            log.debug("Invalid json body provided to GraphQL", e);
            // NOTE: Can't get at isVerbose setting here for hardcoding to false. If necessary, we can refactor
            // so this can be set appropriately.
            return buildErrorResponse(elide, new InvalidEntityBodyException(graphQLDocument), false);
        }

        Function executeRequest =
                (node) -> executeGraphQLRequest(baseUrlEndPoint, mapper, user, graphQLDocument, node, requestId,
                                                requestHeaders);

        if (topLevel.isArray()) {
            Iterator nodeIterator = topLevel.iterator();
            Iterable nodeIterable = () -> nodeIterator;
            // NOTE: Create a non-parallel stream
            // It's unclear whether or not the expectations of the caller would be that requests are intended
            // to run serially even outside of a single transaction. We should revisit this.
            Stream nodeStream = StreamSupport.stream(nodeIterable.spliterator(), false);
            ArrayNode result = nodeStream
                    .map(executeRequest)
                    .map(response -> {
                        try {
                            return mapper.readTree(response.getBody());
                        } catch (IOException e) {
                            log.debug("Caught an IO exception while trying to read response body");
                            return JsonNodeFactory.instance.objectNode();
                        }
                    })
                    .reduce(JsonNodeFactory.instance.arrayNode(),
                            (arrayNode, node) -> arrayNode.add(node),
                            (left, right) -> left.addAll(right));
            try {
                return ElideResponse.builder()
                        .responseCode(HttpStatus.SC_OK)
                        .body(mapper.writeValueAsString(result))
                        .build();
            } catch (IOException e) {
                log.error("An unexpected error occurred trying to serialize array response.", e);
                return ElideResponse.builder()
                        .responseCode(HttpStatus.SC_INTERNAL_SERVER_ERROR)
                        .build();
            }
        }

        return executeRequest.apply(topLevel);
    }

    /**
     * Extracts the executable query from Json Node.
     * @param jsonDocument The JsonNode object.
     * @return query to execute.
     */
    public static String extractQuery(JsonNode jsonDocument) {
        return jsonDocument.has(QUERY) ? jsonDocument.get(QUERY).asText() : null;
    }

    /**
     * Extracts the variables for the query from Json Node.
     * @param mapper ObjectMapper instance.
     * @param jsonDocument The JsonNode object.
     * @return variables to pass.
     */
    public static Map extractVariables(ObjectMapper mapper, JsonNode jsonDocument) {
        // get variables from request for constructing entityProjections
        Map variables = new HashMap<>();
        if (jsonDocument.has(VARIABLES) && !jsonDocument.get(VARIABLES).isNull()) {
            variables = mapper.convertValue(jsonDocument.get(VARIABLES), Map.class);
        }

        return variables;
    }

    /**
     * Extracts the operation name from Json Node.
     * @param jsonDocument The JsonNode object.
     * @return variables to pass.
     */
    public static String extractOperation(JsonNode jsonDocument) {
        if (jsonDocument.has(OPERATION_NAME) && !jsonDocument.get(OPERATION_NAME).isNull()) {
            return jsonDocument.get(OPERATION_NAME).asText();
        }

        return null;
    }

    private ElideResponse executeGraphQLRequest(String baseUrlEndPoint, ObjectMapper mapper, User principal,
                                                String graphQLDocument, JsonNode jsonDocument, UUID requestId,
                                                Map> requestHeaders) {
        boolean isVerbose = false;
        try (DataStoreTransaction tx = elide.getDataStore().beginTransaction()) {
            elide.getTransactionRegistry().addRunningTransaction(requestId, tx);
            if (!jsonDocument.has(QUERY)) {
                return ElideResponse.builder().responseCode(HttpStatus.SC_BAD_REQUEST)
                        .body("A `query` key is required.").build();
            }
            String query = extractQuery(jsonDocument);

            // get variables from request for constructing entityProjections
            Map variables = extractVariables(mapper, jsonDocument);

            //TODO - get API version.
            GraphQLProjectionInfo projectionInfo = new GraphQLEntityProjectionMaker(elide.getElideSettings(), variables,
                    apiVersion).make(query);
            GraphQLRequestScope requestScope = new GraphQLRequestScope(baseUrlEndPoint, tx, principal, apiVersion,
                    elide.getElideSettings(), projectionInfo, requestId, requestHeaders);

            isVerbose = requestScope.getPermissionExecutor().isVerbose();

            // Logging all queries. It is recommended to put any private information that shouldn't be logged into
            // the "variables" section of your query. Variable values are not logged.
            log.info("Processing GraphQL query:\n{}", query);

            ExecutionInput.Builder executionInput = new ExecutionInput.Builder().context(requestScope).query(query);

            String operationName = extractOperation(jsonDocument);

            if (operationName != null) {
                executionInput.operationName(operationName);
            }

            executionInput.variables(variables);

            ExecutionResult result = api.execute(executionInput);

            tx.preCommit(requestScope);
            requestScope.runQueuedPreSecurityTriggers();
            requestScope.getPermissionExecutor().executeCommitChecks();
            if (isMutation(query)) {
                if (!result.getErrors().isEmpty()) {
                    HashMap abortedResponseObject = new HashMap<>();
                    abortedResponseObject.put("errors", result.getErrors());
                    abortedResponseObject.put("data", null);
                    // Do not commit. Throw OK response to process tx.close correctly.
                    throw new WebApplicationException(
                            Response.ok(mapper.writeValueAsString(abortedResponseObject)).build());
                }
                requestScope.saveOrCreateObjects();
            }
            tx.flush(requestScope);

            requestScope.runQueuedPreCommitTriggers();
            elide.getAuditLogger().commit();
            tx.commit(requestScope);
            requestScope.runQueuedPostCommitTriggers();

            if (log.isTraceEnabled()) {
                requestScope.getPermissionExecutor().logCheckStats();
            }

            return ElideResponse.builder().responseCode(HttpStatus.SC_OK).body(mapper.writeValueAsString(result))
                    .build();
        } catch (JsonProcessingException e) {
            log.debug("Invalid json body provided to GraphQL", e);
            return buildErrorResponse(elide, new InvalidEntityBodyException(graphQLDocument), isVerbose);
        } catch (IOException e) {
            log.error("Uncaught IO Exception by Elide in GraphQL", e);
            return buildErrorResponse(elide, new TransactionException(e), isVerbose);
        } catch (WebApplicationException e) {
            log.debug("WebApplicationException", e);
            String body = e.getResponse().getEntity() != null ? e.getResponse().getEntity().toString() : e.getMessage();
            return ElideResponse.builder().responseCode(e.getResponse().getStatus()).body(body).build();
        } catch (HttpStatusException e) {
            if (e instanceof ForbiddenAccessException) {
                if (log.isDebugEnabled()) {
                    log.debug("{}", ((ForbiddenAccessException) e).getLoggedMessage());
                }
            } else {
                log.debug("Caught HTTP status exception {}", e.getStatus(), e);
            }
            return buildErrorResponse(elide, new HttpStatusException(200, e.getMessage()) {
                @Override
                public int getStatus() {
                    return 200;
                }

                @Override
                public Pair getErrorResponse() {
                    return e.getErrorResponse();
                }

                @Override
                public Pair getVerboseErrorResponse() {
                    return e.getVerboseErrorResponse();
                }

                @Override
                public String getVerboseMessage() {
                    return e.getVerboseMessage();
                }

                @Override
                public String toString() {
                    return e.toString();
                }
            }, isVerbose);
        } catch (ConstraintViolationException e) {
            log.debug("Constraint violation exception caught", e);
            String message = "Constraint violation";
            final ErrorObjects.ErrorObjectsBuilder errorObjectsBuilder = ErrorObjects.builder();
            for (ConstraintViolation constraintViolation : e.getConstraintViolations()) {
                errorObjectsBuilder.addError()
                        .withDetail(constraintViolation.getMessage());
                final String propertyPathString = constraintViolation.getPropertyPath().toString();
                if (!propertyPathString.isEmpty()) {
                    Map source = new HashMap<>(1);
                    source.put("property", propertyPathString);
                    errorObjectsBuilder.with("source", source);
                }
            }
            return buildErrorResponse(elide,
                    new CustomErrorException(HttpStatus.SC_BAD_REQUEST, message, errorObjectsBuilder.build()),
                    isVerbose
            );
        } catch (Exception | Error e) {
            if (e instanceof InterruptedException) {
                log.debug("Request Thread interrupted.", e);
                return buildErrorResponse(elide, new TimeoutException(e), isVerbose);
            }
            log.error("Unhandled error or exception.", e);
            throw e;
        } finally {
            elide.getTransactionRegistry().removeRunningTransaction(requestId);
            elide.getAuditLogger().clear();
        }
    }
    public static ElideResponse buildErrorResponse(Elide elide, HttpStatusException error, boolean isVerbose) {
        ObjectMapper mapper = elide.getMapper().getObjectMapper();
        JsonNode errorNode;
        if (!(error instanceof CustomErrorException)) {
            // get the error message and optionally encode it
            String errorMessage = isVerbose ? error.getVerboseMessage() : error.getMessage();
            errorMessage = Encode.forHtml(errorMessage);
            ErrorObjects errors = ErrorObjects.builder().addError()
                    .with("message", errorMessage).build();
            errorNode = mapper.convertValue(errors, JsonNode.class);
        } else {
            errorNode = isVerbose
                    ? error.getVerboseErrorResponse().getRight()
                    : error.getErrorResponse().getRight();
        }
        String errorBody;
        try {
            errorBody = mapper.writeValueAsString(errorNode);
        } catch (JsonProcessingException e) {
            errorBody = errorNode.toString();
        }
        return ElideResponse.builder()
                .responseCode(error.getStatus())
                .body(errorBody)
                .build();
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy