GQLServlet.java

package net.morimekta.providence.graphql;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.morimekta.providence.PApplicationException;
import net.morimekta.providence.PMessage;
import net.morimekta.providence.PMessageVariant;
import net.morimekta.providence.PProcessor;
import net.morimekta.providence.PServiceCall;
import net.morimekta.providence.PServiceCallType;
import net.morimekta.providence.PUnion;
import net.morimekta.providence.descriptor.PField;
import net.morimekta.providence.descriptor.PMessageDescriptor;
import net.morimekta.providence.graphql.errors.GQLError;
import net.morimekta.providence.graphql.errors.GQLErrorLocation;
import net.morimekta.providence.graphql.errors.GQLErrorResponse;
import net.morimekta.providence.graphql.gql.GQLField;
import net.morimekta.providence.graphql.gql.GQLFragment;
import net.morimekta.providence.graphql.gql.GQLIntrospection;
import net.morimekta.providence.graphql.gql.GQLMethodCall;
import net.morimekta.providence.graphql.gql.GQLOperation;
import net.morimekta.providence.graphql.gql.GQLQuery;
import net.morimekta.providence.graphql.gql.GQLSelection;
import net.morimekta.providence.graphql.introspection.Type;
import net.morimekta.providence.graphql.introspection.TypeArguments;
import net.morimekta.providence.graphql.parser.GQLException;
import net.morimekta.providence.graphql.parser.GQLParser;
import net.morimekta.providence.graphql.utils.FieldFieldProvider;
import net.morimekta.providence.graphql.utils.InputValueFieldProvider;
import net.morimekta.providence.graphql.utils.TypeFieldProvider;
import net.morimekta.providence.serializer.JsonSerializer;
import net.morimekta.util.Binary;
import net.morimekta.util.Pair;
import net.morimekta.util.Tuple;
import net.morimekta.util.collect.UnmodifiableMap;
import net.morimekta.util.io.IOUtils;
import net.morimekta.util.io.IndentedPrintWriter;
import net.morimekta.util.json.JsonWriter;
import net.morimekta.util.json.PrettyJsonWriter;
import net.morimekta.util.lexer.LexerException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.OverridingMethodsMustInvokeSuper;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import static java.util.Objects.requireNonNull;
import static net.morimekta.providence.PApplicationExceptionType.PROTOCOL_ERROR;
import static net.morimekta.util.collect.UnmodifiableList.listOf;

/**
 * A servlet for serving graphql given a GQL service and associated providers.
 *
 * Spec for serving GraphQL over HTTP can be found <a href="https://graphql.org/learn/serving-over-http/">here</a>.
 */
@SuppressWarnings("rawtypes")
public class GQLServlet extends HttpServlet {
    private static final Logger LOGGER = LoggerFactory.getLogger(GQLServlet.class);

    public static final String CONTENT_TYPE = "Content-Type";
    public static final String MEDIA_TYPE   = "application/graphql";

    private static final String       PRETTY_PARAM = "pretty";
    private static final String       TRUE         = "true";
    private static final ObjectMapper MAPPER       = new ObjectMapper();
    private static final JavaType     VARIABLES;

    static {
        VARIABLES = MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class);
    }

    private final GQLContextFactory                contextFactory;
    private final GQLDefinition                       definition;
    private final GQLProcessorProvider             queryProvider;
    private final GQLProcessorProvider             mutationProvider;
    private final GQLParser                           parser;
    private final ExecutorService                     executorService;
    private final Map<PField<?>, GQLFieldProvider<?>> fieldProviderMap;

    @SuppressWarnings("unused")
    @JsonIgnoreProperties(ignoreUnknown = true)
    private static class JsonRequest {
        @JsonProperty("query")
        String query;
        @JsonProperty("operationName")
        String operationName;
        @JsonProperty("variables")
        Map<String, Object> variables;
    }

    public static class Builder {
        private GQLDefinition definition;
        private GQLContextFactory<?> contextFactory;
        private GQLProcessorProvider<?> queryProvider;
        private GQLProcessorProvider<?> mutationProvider;
        private Collection<GQLFieldProvider<?>> fieldProviders;
        private ExecutorService executorService;

        private Builder(@Nonnull GQLDefinition definition) {
            this.fieldProviders = new ArrayList<>();
            this.definition = definition;
        }

        public Builder context(GQLContextFactory<?> contextFactory) {
            this.contextFactory = contextFactory;
            return this;
        }

        public Builder query(@Nonnull GQLProcessorProvider<?> queryProvider) {
            this.queryProvider = queryProvider;
            return this;
        }

        public Builder mutation(@Nonnull GQLProcessorProvider<?> mutationProvider) {
            this.mutationProvider = mutationProvider;
            return this;
        }

        public Builder fieldProvider(GQLFieldProvider<?>... fieldProviders) {
            this.fieldProviders.addAll(Arrays.asList(fieldProviders));
            return this;
        }

        public Builder executor(@Nonnull ExecutorService executorService) {
            this.executorService = executorService;
            return this;
        }

        @SuppressWarnings("unchecked")
        public GQLServlet build() {
            if (queryProvider == null) throw new IllegalStateException("No query provider");
            return new GQLServlet(definition,
                                  queryProvider,
                                  mutationProvider,
                                  fieldProviders,
                                  Optional.ofNullable(contextFactory)
                                          .orElse(GQLContextFactory.DEFAULT_INSTANCE),
                                  Optional.ofNullable(executorService)
                                          .orElseGet(() -> Executors.newFixedThreadPool(10)));
        }
    }

    public static Builder builder(GQLDefinition definition) {
        return new Builder(definition);
    }

    @SuppressWarnings("unchecked")
    public GQLServlet(@Nonnull GQLDefinition definition,
                      @Nonnull GQLProcessorProvider<?> queryProvider,
                      @Nullable GQLProcessorProvider<?> mutationProvider,
                      @Nonnull Collection<GQLFieldProvider<?>> fieldProviders,
                      @Nullable GQLContextFactory<?> contextFactory,
                      @Nonnull ExecutorService executorService) {
        if ((definition.getMutation() == null) != (mutationProvider == null)) {
            throw new IllegalArgumentException("Definition " +
                                               (definition.getMutation() == null ? "does not have" : "has") +
                                               " mutation, while provider is " +
                                               (mutationProvider == null ? "not " : "") +
                                               "present");
        }
        Map<PField<?>, GQLFieldProvider<?>> providerMap = new HashMap<>();
        buildMutatorMap(providerMap, new FieldFieldProvider(definition));
        buildMutatorMap(providerMap, new InputValueFieldProvider(definition));
        buildMutatorMap(providerMap, new TypeFieldProvider(definition));
        for (GQLFieldProvider<?> provider : fieldProviders) {
            buildMutatorMap(providerMap, provider);
        }

        this.contextFactory = Optional
                .ofNullable(contextFactory)
                .orElse(GQLContextFactory.DEFAULT_INSTANCE);
        this.definition = definition;
        this.queryProvider = queryProvider;
        this.mutationProvider = mutationProvider;
        this.fieldProviderMap = UnmodifiableMap.copyOf(providerMap);
        this.parser = new GQLParser(definition);
        this.executorService = executorService;
    }

    /**
     * Only non-mutation GQL queries may be handed to a get request, putting the
     * actual query in the query= parameter.
     *
     * @param req The HTTP request.
     * @param resp The HTTP response.
     */
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        GQLQuery query;
        String operationName;
        try {
            Map<String, Object> rawVariables = Collections.emptyMap();
            String variables = req.getParameter("variables");
            if (variables != null) {
                rawVariables = MAPPER.readValue(variables, VARIABLES);
            }
            operationName = req.getParameter("operationName");
            String q = req.getParameter("query");
            if (q == null) {
                resp.setHeader(CONTENT_TYPE, JsonSerializer.JSON_MEDIA_TYPE);
                resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                writeError(makeWriter(resp, true),
                           new GQLError("No query param in request", null));
                return;
            }
            query = parser.parseQuery(q, rawVariables);
        } catch (LexerException e) {
            LOGGER.debug("Error parsing query:\n{}", e.displayString(), e);
            resp.setHeader(CONTENT_TYPE, JsonSerializer.JSON_MEDIA_TYPE);
            resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            writeError(makeWriter(resp, true),
                       new GQLError(e.getMessage(),
                                 listOf(new GQLErrorLocation(e.getLineNo(), e.getLinePos()))));
            return;
        } catch (IOException | IllegalArgumentException e) {
            LOGGER.debug("Exception in parsing query: {}", e.getMessage(), e);
            resp.setHeader(CONTENT_TYPE, JsonSerializer.JSON_MEDIA_TYPE);
            resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            writeError(makeWriter(resp, true), new GQLError(e.getMessage(), null));
            return;
        }
        if (operationName == null && !query.isDefaultOperationAvailable()) {
            resp.setHeader(CONTENT_TYPE, JsonSerializer.JSON_MEDIA_TYPE);
            resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            writeError(makeWriter(resp, true), new GQLError(
                    "No default query defined", null));
            return;
        }

        GQLOperation operation;
        try {
            operation = query.getOperation(operationName).orElse(null);
        } catch (IllegalArgumentException e) {
            resp.setHeader(CONTENT_TYPE, JsonSerializer.JSON_MEDIA_TYPE);
            resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            writeError(makeWriter(resp, true), new GQLError(
                    e.getMessage(), null));
            return;
        }
        if (operation == null) {
            resp.setHeader(CONTENT_TYPE, JsonSerializer.JSON_MEDIA_TYPE);
            resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            writeError(makeWriter(resp, true), new GQLError(
                    "No such operation " + operationName + " in query", null));
            return;
        }

        if (operation.isMutation()) {
            resp.setHeader(CONTENT_TYPE, JsonSerializer.JSON_MEDIA_TYPE);
            resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            writeError(makeWriter(resp, true), new GQLError(
                    "mutation not allowed in GET request", null));
            return;
        }

        handleOperation(req, resp, operation, false);
    }

    /**
     * Both normal GQL queries and mutations may be posted. The
     *
     * @param req The HTTP request.
     * @param resp The HTTP response.
     */
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String contentType = req.getContentType();
        if (contentType == null) {
            contentType = MEDIA_TYPE;
        } else {
            contentType = contentType.split(";")[0].trim().toLowerCase(Locale.US);
        }

        GQLOperation operation;
        try {
            GQLQuery query = null;
            switch (contentType) {
                case MEDIA_TYPE: {
                    String q = IOUtils.readString(req.getReader());
                    query = parser.parseQuery(q, new HashMap<>());
                    operation = query.getOperation(null)
                                     .orElseThrow(() -> new IllegalArgumentException("No default operation defined in query"));
                    break;
                }
                case JsonSerializer.JSON_MEDIA_TYPE: {
                    JsonRequest content;
                    try {
                        content = MAPPER.readValue(req.getReader(), JsonRequest.class);
                    } catch (JsonParseException e) {
                        throw e;
                    } catch (Exception e) {
                        throw new IllegalArgumentException(e.getMessage(), e);
                    }
                    if (content.query != null) {
                        query = parser.parseQuery(content.query, content.variables);
                    }
                    if (query == null) {
                        String q = req.getParameter("query");
                        if (q == null) {
                            throw new IllegalArgumentException("No query in request");
                        }
                        query = parser.parseQuery(q, Optional.ofNullable(content.variables)
                                                             .orElse(new HashMap<>()));
                    }
                    operation = query.getOperation(null).orElseThrow(() -> new IllegalArgumentException("No default operation defined in query"));
                    break;
                }
                default: {
                    throw new IllegalArgumentException("Unknown content-type: " + contentType);
                }
            }
        } catch (JsonParseException e) {
            LOGGER.debug("Error parsing JSON: {}", e.getMessage(), e);
            resp.setHeader(CONTENT_TYPE, JsonSerializer.JSON_MEDIA_TYPE);
            resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            writeError(makeWriter(resp, true), new GQLError(e.getMessage(), listOf(
                    new GQLErrorLocation(e.getLocation().getLineNr(), e.getLocation().getColumnNr()))));
            return;
        } catch (LexerException e) {
            LOGGER.debug("Error parsing query:\n{}", e.displayString(), e);
            resp.setHeader(CONTENT_TYPE, JsonSerializer.JSON_MEDIA_TYPE);
            resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            writeError(makeWriter(resp, true),
                       new GQLError(e.getMessage(),
                                 listOf(new GQLErrorLocation(e.getLineNo(), e.getLinePos()))));
            return;
        } catch (IOException | IllegalArgumentException e) {
            LOGGER.debug("Exception in parsing query: {}", e.getMessage(), e);
            resp.setHeader(CONTENT_TYPE, JsonSerializer.JSON_MEDIA_TYPE);
            resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            writeError(makeWriter(resp, true), new GQLError(e.getMessage(), null));
            return;
        }

        handleOperation(req, resp, operation, operation.isMutation());
    }

    /**
     * Handle operation.
     *
     * @param req The HTTP request.
     * @param resp The HTTP response.
     * @param operation The operation to be done.
     * @param handleSerially If the operation should be handled serially.
     *                       If true will create a single context and handle all method
     *                       calls after one another and stop on first error.
     *                       If false will spawn one thread per method call, and write
     *                       a data response or error separately for each call.
     * @throws IOException If operation handle failed.
     */
    @OverridingMethodsMustInvokeSuper
    protected void handleOperation(@Nonnull HttpServletRequest req,
                                   @Nonnull HttpServletResponse resp,
                                   @Nonnull GQLOperation operation,
                                   boolean handleSerially) throws IOException {
        try {
            if (handleSerially) {
                handleSerially(req, resp, operation);
            } else {
                handleParallel(req, resp, operation);
            }
        } catch (IOException e) {
            LOGGER.error("Error handling {}: {}",
                         operation.isMutation() ? "mutation" : "query",
                         e.getMessage(),
                         e);
            throw e;
        }
    }

    private JsonWriter makeWriter(HttpServletResponse resp, boolean pretty) throws IOException {
        return pretty ?
               new PrettyJsonWriter(new IndentedPrintWriter(resp.getWriter(), "  ", "\n")) :
               new JsonWriter(resp.getWriter());
    }

    @SuppressWarnings("unchecked,rawtypes")
    private void handleSerially(HttpServletRequest req,
                                HttpServletResponse resp,
                                GQLOperation operation) throws IOException {
        boolean pretty = TRUE.equalsIgnoreCase(req.getParameter(PRETTY_PARAM));

        resp.setStatus(HttpServletResponse.SC_OK);
        resp.setHeader("Content-Type", JsonSerializer.JSON_MEDIA_TYPE);

        JsonWriter writer = makeWriter(resp, pretty);

        writer.object()
              .key("data")
              .object();

        // run serially
        int i = 0;
        PServiceCall<?> request = null;
        try (GQLContext context = contextFactory.createContext(req, operation)) {
            try {
                for (GQLSelection entry : operation.getSelectionSet()) {
                    if (entry instanceof GQLMethodCall) {
                        GQLMethodCall methodEntry = (GQLMethodCall) entry;
                        request = new PServiceCall<>(requireNonNull(methodEntry.getMethod()).getName(),
                                                     PServiceCallType.CALL,
                                                     ++i,
                                                     (PMessage) methodEntry.getArguments());
                        PProcessor      processor = mutationProvider.processorFor(context, methodEntry);
                        PServiceCall<?> response  = processor.handleCall(request);
                        if (response.getType() == PServiceCallType.EXCEPTION) {
                            throw (PApplicationException) response.getMessage();
                        } else {
                            PUnion<?> union = (PUnion) response.getMessage();
                            if (union.unionField().getId() == 0) {
                                Object out = union.get(0);
                                if (methodEntry.getAlias() != null) {
                                    writer.key(methodEntry.getAlias());
                                } else {
                                    writer.key(methodEntry.getMethod().getName());
                                }
                                writeAndHandleResponse(writer, out, methodEntry);
                            } else {
                                throw (Exception) union.get(union.unionField().getId());
                            }
                        }
                    } else if (entry instanceof GQLIntrospection) {
                        GQLIntrospection intro = (GQLIntrospection) entry;
                        switch (intro.getField()) {
                            case __schema: {
                                if (intro.getAlias() != null) {
                                    writer.key(intro.getAlias());
                                } else {
                                    writer.key(intro.getField().name());
                                }
                                writeAndHandleResponse(writer, definition.getIntrospectionSchema(),
                                                       new GQLField(Type._Field.NAME, intro.getSelectionSet()));
                                break;
                            }
                            case __type: {
                                TypeArguments ta = (TypeArguments) intro.getArguments();
                                if (ta == null || ta.getName() == null) {
                                    throw new PApplicationException("No name for type lookup", PROTOCOL_ERROR);
                                } else {
                                    Type type = definition.getIntrospectionType(ta.getName());
                                    if (type == null) {
                                        throw new PApplicationException("Unknown type named '" + ta.getName() + "'",
                                                                        PROTOCOL_ERROR);
                                    } else {
                                        if (intro.getAlias() != null) {
                                            writer.key(intro.getAlias());
                                        } else {
                                            writer.key(intro.getField().name());
                                        }
                                        writeAndHandleResponse(writer, type,
                                                               new GQLField(Type._Field.NAME, intro.getSelectionSet()));
                                    }
                                }
                                break;
                            }
                            case __typename: {
                                throw new PApplicationException("Introspection __typename not allowed at root",
                                                                PROTOCOL_ERROR);
                            }
                        }
                    } else {
                        throw new GQLException("Unhandled root selection: " + entry.toString());
                    }
                }

                context.commit();
                writer.endObject()
                      .endObject();
            } catch (Exception e) {
                LOGGER.debug("Call exception: {}", e.getMessage(), e);
                context.abort(e);
                writer.endObject();
                writeErrors(writer, listOf(Pair.create(request, e)));
                writer.endObject();
            }
        } catch (Exception e) {
            LOGGER.debug("Unhandled exception in abort: {}", e.getMessage(), e);
            writer.endObject();
            writeErrors(writer, listOf(Pair.create(request, e)));
            writer.endObject();
        } finally {
            writer.flush();
        }
    }

    private void handleParallel(HttpServletRequest req,
                                HttpServletResponse resp,
                                GQLOperation operation) throws IOException {
        boolean pretty = TRUE.equalsIgnoreCase(req.getParameter(PRETTY_PARAM));

        List<Tuple.Tuple3<GQLSelection, PServiceCall, Future<PServiceCall>>> futures = new ArrayList<>();

        // run in parallel
        int i = 0;
        for (GQLSelection entry : operation.getSelectionSet()) {
            if (entry instanceof GQLIntrospection) {
                GQLIntrospection intro = (GQLIntrospection) entry;
                futures.add(Tuple.tuple(intro, null, null));
            } else if (entry instanceof GQLMethodCall) {
                GQLMethodCall methodEntry = (GQLMethodCall) entry;
                @SuppressWarnings("unchecked")
                PMessage arguments = methodEntry.getArguments();
                if (arguments == null) {
                    arguments = methodEntry.getMethod().getRequestType().builder().build();
                }
                @SuppressWarnings("unchecked")
                PServiceCall<?> psc = new PServiceCall<>(
                        methodEntry.getMethod().getName(),
                        PServiceCallType.CALL,
                        ++i,
                        arguments);
                futures.add(Tuple.tuple(methodEntry, psc, executorService.submit(() -> {
                    try (GQLContext context = contextFactory.createContext(req, operation)) {
                        try {
                            @SuppressWarnings("unchecked")
                            PProcessor processor = queryProvider.processorFor(context, methodEntry);
                            PServiceCall response = processor.handleCall(psc);
                            context.commit();
                            return response;
                        } catch (Exception e) {
                            LOGGER.trace("Exception handling {}", methodEntry.getMethod().getName(), e);
                            context.abort(e);
                            throw e;
                        }
                    }
                })));
            } else {
                throw new GQLException("Unhandled root selection: " + entry.toString());
            }
        }

        resp.setStatus(HttpServletResponse.SC_OK);
        resp.setHeader(CONTENT_TYPE, JsonSerializer.JSON_MEDIA_TYPE);

        JsonWriter writer = makeWriter(resp, pretty);
        try {
            writer.object()
                  .key("data")
                  .object();

            List<Pair<PServiceCall, Exception>> errors = new ArrayList<>();
            for (Tuple.Tuple3<GQLSelection, PServiceCall, Future<PServiceCall>> future : futures) {
                if (future.first() instanceof GQLIntrospection) {
                    GQLIntrospection intro = (GQLIntrospection) future.first();
                    switch (intro.getField()) {
                        case __schema: {
                            if (intro.getAlias() != null) {
                                writer.key(intro.getAlias());
                            } else {
                                writer.key(intro.getField().name());
                            }
                            writeAndHandleResponse(writer, definition.getIntrospectionSchema(),
                                                   new GQLField(Type._Field.NAME, intro.getSelectionSet()));
                            break;
                        }
                        case __type: {
                            TypeArguments ta = (TypeArguments) intro.getArguments();
                            if (ta == null || ta.getName() == null) {
                                errors.add(Pair.create(new PServiceCall<>("__type",
                                                                          PServiceCallType.CALL,
                                                                          0,
                                                                          Type.builder().build()),
                                                       new IllegalArgumentException("No name for type lookup")));
                            } else {
                                Type type = definition.getIntrospectionType(ta.getName());
                                if (type == null) {
                                    errors.add(Pair.create(new PServiceCall<>("__type",
                                                                              PServiceCallType.CALL,
                                                                              0,
                                                                              Type.builder().build()),
                                                           new IllegalArgumentException(
                                                                   "Unknown type named '" + ta.getName() + "'")));
                                } else {
                                    if (intro.getAlias() != null) {
                                        writer.key(intro.getAlias());
                                    } else {
                                        writer.key(intro.getField().name());
                                    }
                                    writeAndHandleResponse(writer, type,
                                                           new GQLField(Type._Field.NAME, intro.getSelectionSet()));
                                }
                            }
                            break;
                        }
                        case __typename: {
                            errors.add(Pair.create(new PServiceCall<>("__typename",
                                                                      PServiceCallType.CALL,
                                                                      0,
                                                                      Type.builder().build()),
                                                   new IllegalArgumentException(
                                                           "Introspection __typename not allowed at root")));
                        }
                    }
                } else {
                    GQLMethodCall field = (GQLMethodCall) future.first();
                    PServiceCall  call  = future.second();
                    try {
                        PServiceCall reply = future.third().get(3L, TimeUnit.MINUTES);
                        if (reply.getType() == PServiceCallType.EXCEPTION) {
                            errors.add(Pair.create(call, (PApplicationException) reply.getMessage()));
                        } else {
                            PUnion<?> response = (PUnion) reply.getMessage();
                            if (response.unionField().getId() == 0) {
                                Object out = response.get(0);
                                if (field.getAlias() != null) {
                                    writer.key(field.getAlias());
                                } else {
                                    writer.key(field.getMethod().getName());
                                }
                                writeAndHandleResponse(writer, out, field);
                            } else {
                                errors.add(Pair.create(call, response.get(response.unionField().getId())));
                            }
                        }
                    } catch (InterruptedException | ExecutionException |
                            TimeoutException | RuntimeException e) {
                        LOGGER.warn("Exception calling {}: {}", call.getMethod(), e.getMessage(), e);
                        errors.add(Pair.create(call, e));
                    }
                }
            }

            writer.endObject();
            writeErrors(writer, errors);
            writer.endObject();
        } finally {
            writer.flush();
        }
    }

    private void writeErrors(JsonWriter writer, List<Pair<PServiceCall, Exception>> errors) throws IOException {
        if (errors.size() > 0) {
            writer.key("errors")
                  .array();

            for (Pair<PServiceCall, Exception> error : errors) {
                if (error.second instanceof GQLError) {
                    writeAndHandleResponse(writer, error.second, new GQLField(GQLErrorResponse._Field.ERRORS));
                } else if (error.first != null){
                    writer.object()
                          .key("message")
                          .value("Exception in call to " + error.first.getMethod() +
                                 ": " + error.second.getMessage())
                          .endObject();
                } else {
                    writer.object()
                          .key("message")
                          .value("Exception in handling: " + error.second.getMessage())
                          .endObject();
                }
            }

            writer.endArray();
        }
    }
    private void writeError(JsonWriter writer, GQLError error) throws IOException {
        writeAndHandleResponse(writer,
                               new GQLErrorResponse(listOf(error)),
                               new GQLField(GQLErrorResponse._Field.ERRORS));
    }

    private void writeAndHandleMessageResponse(JsonWriter writer, PMessage<?> message, GQLSelection msgField) throws IOException {
        PMessageDescriptor descriptor = message.descriptor();

        if (descriptor.getVariant() == PMessageVariant.UNION &&
            descriptor.getImplementing() != null) {
            PUnion union = (PUnion) message;
            writeAndHandleResponse(writer, union.get(union.unionField().getId()), msgField);
            return;
        }

        writer.object();

        if (msgField.getSelectionSet() == null) {
            // NOTE: This is not really supported by graphql, but can significantly
            // simplify making test requests and looking at the possible fields to use
            // before landing on a complete query.
            for (PField field : descriptor.getFields()) {
                if (message.has(field.getId())) {
                    if (field.getName().startsWith("__")) {
                        continue;
                    }
                    writeAndHandleField(writer, message, new GQLField(field));
                }
            }
        } else {
            writeAndHandleSelection(writer, message, msgField.getSelectionSet());
        }

        writer.endObject();
    }

    private void writeAndHandleSelection(
            @Nonnull JsonWriter writer,
            @Nonnull PMessage<?> message,
            @Nonnull List<GQLSelection> selection) throws IOException {
        PMessageDescriptor<?> descriptor = message.descriptor();
        for (GQLSelection entry : selection) {
            if (entry instanceof GQLIntrospection) {
                GQLIntrospection intro = (GQLIntrospection) entry;
                switch (intro.getField()) {
                    case __type: {
                        Type type = this.definition.getIntrospectionType(descriptor, false);
                        writer.key(GQLIntrospection.Field.__type.name());
                        writeAndHandleMessageResponse(writer, type, intro);
                        break;
                    }
                    case __typename: {
                        Type type = this.definition.getIntrospectionType(descriptor, false);
                        writer.key(GQLIntrospection.Field.__typename.name());
                        writer.value(type.getName());
                        break;
                    }
                    case __schema: {
                        // ignore.
                        break;
                    }
                }
            } else if (entry instanceof GQLFragment) {
                GQLFragment fragment = (GQLFragment) entry;
                if (fragment.isApplicableFor(descriptor)) {
                    writeAndHandleSelection(writer, message, fragment.getSelectionSet());
                }
            } else if (entry instanceof GQLField) {
                writeAndHandleField(writer, message, (GQLField) entry);
            } else {
                throw new IOException("Unknown GQL entry " + entry.getClass().getSimpleName() + ": " + entry.toString());
            }
        }
    }

    @SuppressWarnings("unchecked")
    private void writeAndHandleField(JsonWriter writer, PMessage<?> message, GQLField gqlField) throws IOException {
        Object value;
        // Up the field to make sure we have the message type
        // field for the name. This is needed where the field
        // requested is from an interface.
        PField<?> actualField = requireNonNull(gqlField.getField());
        actualField = message.descriptor().fieldForName(actualField.getName());

        @SuppressWarnings("rawtypes")
        GQLFieldProvider mutator = fieldProviderMap.get(actualField);
        if (mutator != null) {
            value = mutator.provide(message, actualField, gqlField);
        } else if (message.has(actualField.getId())){
            value = message.get(actualField.getId());
        } else {
            value = null;
        }

        if (gqlField.getAlias() != null) {
            writer.key(gqlField.getAlias());
        } else {
            writer.key(actualField.getName());
        }
        writeAndHandleResponse(writer, value, gqlField);
    }

    @SuppressWarnings("rawtypes")
    private void writeAndHandleResponse(JsonWriter writer, Object out, GQLSelection field) throws IOException {
        if (out == null) {
            writer.value((String) null);
            return;
        }

        if (out instanceof PMessage) {
            writeAndHandleMessageResponse(writer, (PMessage<?>) out, field);
        } else {
            if (out instanceof Integer ||
                out instanceof Long ||
                out instanceof Short ||
                out instanceof Byte) {
                writer.value(((Number) out).longValue());
            } else if (out instanceof Double ||
                       out instanceof Float) {
                writer.value(((Number) out).doubleValue());
            } else if (out instanceof CharSequence) {
                writer.value((CharSequence) out);
            } else if (out instanceof Binary) {
                writer.value((Binary) out);
            } else if (out instanceof Boolean) {
                writer.value((Boolean) out);
            } else if (out instanceof Collection) {
                writer.array();
                for (Object o : (Collection) out) {
                    writeAndHandleResponse(writer, o, field);
                }
                writer.endArray();
            } else {
                writer.value(out.toString());
            }
        }
    }

    private static void buildMutatorMap(Map<PField<?>, GQLFieldProvider<?>> providerMap,
                                        GQLFieldProvider<?> provider) {
        for (PField<?> field : provider.getFields()) {
            if (providerMap.containsKey(field)) {
                throw new IllegalArgumentException(
                        "Two field providers claim to handle " +
                        provider.getDescriptor().getQualifiedName() + "." + field.getName() +
                        " at the same time.");
            }
            providerMap.put(field, provider);
        }
    }
}