com.google.gerrit.httpd.restapi.RestApiServlet.java Source code

Java tutorial

Introduction

Here is the source code for com.google.gerrit.httpd.restapi.RestApiServlet.java

Source

// Copyright (C) 2012 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.gerrit.httpd.restapi;

import static com.google.common.base.Preconditions.checkNotNull;
import static java.math.RoundingMode.CEILING;
import static java.nio.charset.StandardCharsets.ISO_8859_1;
import static java.nio.charset.StandardCharsets.UTF_8;
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
import static javax.servlet.http.HttpServletResponse.SC_CREATED;
import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED;
import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
import static javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED;
import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
import static javax.servlet.http.HttpServletResponse.SC_OK;
import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;

import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.Iterables;
import com.google.common.collect.LinkedHashMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Multimap;
import com.google.common.collect.Sets;
import com.google.common.io.BaseEncoding;
import com.google.common.math.IntMath;
import com.google.common.net.HttpHeaders;
import com.google.gerrit.audit.AuditService;
import com.google.gerrit.audit.ExtendedHttpAuditEvent;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.TimeUtil;
import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.restapi.AcceptsCreate;
import com.google.gerrit.extensions.restapi.AcceptsDelete;
import com.google.gerrit.extensions.restapi.AcceptsPost;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.CacheControl;
import com.google.gerrit.extensions.restapi.DefaultInput;
import com.google.gerrit.extensions.restapi.ETagView;
import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.NotImplementedException;
import com.google.gerrit.extensions.restapi.PreconditionFailedException;
import com.google.gerrit.extensions.restapi.RawInput;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestCollection;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.extensions.restapi.RestReadView;
import com.google.gerrit.extensions.restapi.RestResource;
import com.google.gerrit.extensions.restapi.RestView;
import com.google.gerrit.extensions.restapi.TopLevelResource;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.httpd.WebSession;
import com.google.gerrit.server.AccessPath;
import com.google.gerrit.server.AnonymousUser;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.OptionUtil;
import com.google.gerrit.server.OutputFormat;
import com.google.gerrit.server.account.CapabilityUtils;
import com.google.gerrit.util.http.RequestUtil;
import com.google.gson.ExclusionStrategy;
import com.google.gson.FieldAttributes;
import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;
import com.google.gson.stream.MalformedJsonException;
import com.google.gwtexpui.server.CacheHeaders;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.util.Providers;

import org.eclipse.jgit.util.TemporaryBuffer;
import org.eclipse.jgit.util.TemporaryBuffer.Heap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.EOFException;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.sql.Timestamp;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.zip.GZIPOutputStream;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class RestApiServlet extends HttpServlet {
    private static final long serialVersionUID = 1L;
    private static final Logger log = LoggerFactory.getLogger(RestApiServlet.class);

    /** MIME type used for a JSON response body. */
    private static final String JSON_TYPE = "application/json";
    private static final String FORM_TYPE = "application/x-www-form-urlencoded";

    private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.

    /**
     * Garbage prefix inserted before JSON output to prevent XSSI.
     * <p>
     * This prefix is ")]}'\n" and is designed to prevent a web browser from
     * executing the response body if the resource URI were to be referenced using
     * a &lt;script src="...&gt; HTML tag from another web site. Clients using the
     * HTTP interface will need to always strip the first line of response data to
     * remove this magic header.
     */
    public static final byte[] JSON_MAGIC;

    static {
        JSON_MAGIC = ")]}'\n".getBytes(UTF_8);
    }

    public static class Globals {
        final Provider<CurrentUser> currentUser;
        final DynamicItem<WebSession> webSession;
        final Provider<ParameterParser> paramParser;
        final AuditService auditService;

        @Inject
        Globals(Provider<CurrentUser> currentUser, DynamicItem<WebSession> webSession,
                Provider<ParameterParser> paramParser, AuditService auditService) {
            this.currentUser = currentUser;
            this.webSession = webSession;
            this.paramParser = paramParser;
            this.auditService = auditService;
        }
    }

    private final Globals globals;
    private final Provider<RestCollection<RestResource, RestResource>> members;

    public RestApiServlet(Globals globals, RestCollection<? extends RestResource, ? extends RestResource> members) {
        this(globals, Providers.of(members));
    }

    public RestApiServlet(Globals globals,
            Provider<? extends RestCollection<? extends RestResource, ? extends RestResource>> members) {
        @SuppressWarnings("unchecked")
        Provider<RestCollection<RestResource, RestResource>> n = (Provider<RestCollection<RestResource, RestResource>>) checkNotNull(
                (Object) members);
        this.globals = globals;
        this.members = n;
    }

    @Override
    protected final void service(HttpServletRequest req, HttpServletResponse res)
            throws ServletException, IOException {
        long auditStartTs = TimeUtil.nowMs();
        res.setHeader("Content-Disposition", "attachment");
        res.setHeader("X-Content-Type-Options", "nosniff");
        int status = SC_OK;
        Object result = null;
        Multimap<String, String> params = LinkedHashMultimap.create();
        Object inputRequestBody = null;
        RestResource rsrc = TopLevelResource.INSTANCE;
        ViewData viewData = null;

        try {
            checkUserSession(req);

            List<IdString> path = splitPath(req);
            RestCollection<RestResource, RestResource> rc = members.get();
            CapabilityUtils.checkRequiresCapability(globals.currentUser, null, rc.getClass());

            viewData = new ViewData(null, null);

            if (path.isEmpty()) {
                if (isGetOrHead(req)) {
                    viewData = new ViewData(null, rc.list());
                } else if (rc instanceof AcceptsPost && "POST".equals(req.getMethod())) {
                    @SuppressWarnings("unchecked")
                    AcceptsPost<RestResource> ac = (AcceptsPost<RestResource>) rc;
                    viewData = new ViewData(null, ac.post(rsrc));
                } else {
                    throw new MethodNotAllowedException();
                }
            } else {
                IdString id = path.remove(0);
                try {
                    rsrc = rc.parse(rsrc, id);
                    if (path.isEmpty()) {
                        checkPreconditions(req);
                    }
                } catch (ResourceNotFoundException e) {
                    if (rc instanceof AcceptsCreate && path.isEmpty()
                            && ("POST".equals(req.getMethod()) || "PUT".equals(req.getMethod()))) {
                        @SuppressWarnings("unchecked")
                        AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) rc;
                        viewData = new ViewData(null, ac.create(rsrc, id));
                        status = SC_CREATED;
                    } else {
                        throw e;
                    }
                }
                if (viewData.view == null) {
                    viewData = view(rsrc, rc, req.getMethod(), path);
                }
            }
            checkRequiresCapability(viewData);

            while (viewData.view instanceof RestCollection<?, ?>) {
                @SuppressWarnings("unchecked")
                RestCollection<RestResource, RestResource> c = (RestCollection<RestResource, RestResource>) viewData.view;

                if (path.isEmpty()) {
                    if (isGetOrHead(req)) {
                        viewData = new ViewData(null, c.list());
                    } else if (c instanceof AcceptsPost && "POST".equals(req.getMethod())) {
                        @SuppressWarnings("unchecked")
                        AcceptsPost<RestResource> ac = (AcceptsPost<RestResource>) c;
                        viewData = new ViewData(null, ac.post(rsrc));
                    } else if (c instanceof AcceptsDelete && "DELETE".equals(req.getMethod())) {
                        @SuppressWarnings("unchecked")
                        AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c;
                        viewData = new ViewData(null, ac.delete(rsrc, null));
                    } else {
                        throw new MethodNotAllowedException();
                    }
                    break;
                } else {
                    IdString id = path.remove(0);
                    try {
                        rsrc = c.parse(rsrc, id);
                        checkPreconditions(req);
                        viewData = new ViewData(null, null);
                    } catch (ResourceNotFoundException e) {
                        if (c instanceof AcceptsCreate && path.isEmpty()
                                && ("POST".equals(req.getMethod()) || "PUT".equals(req.getMethod()))) {
                            @SuppressWarnings("unchecked")
                            AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) c;
                            viewData = new ViewData(viewData.pluginName, ac.create(rsrc, id));
                            status = SC_CREATED;
                        } else if (c instanceof AcceptsDelete && path.isEmpty()
                                && "DELETE".equals(req.getMethod())) {
                            @SuppressWarnings("unchecked")
                            AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c;
                            viewData = new ViewData(viewData.pluginName, ac.delete(rsrc, id));
                            status = SC_NO_CONTENT;
                        } else {
                            throw e;
                        }
                    }
                    if (viewData.view == null) {
                        viewData = view(rsrc, c, req.getMethod(), path);
                    }
                }
                checkRequiresCapability(viewData);
            }

            if (notModified(req, rsrc, viewData.view)) {
                res.sendError(SC_NOT_MODIFIED);
                return;
            }

            Multimap<String, String> config = LinkedHashMultimap.create();
            ParameterParser.splitQueryString(req.getQueryString(), config, params);
            if (!globals.paramParser.get().parse(viewData.view, params, req, res)) {
                return;
            }

            if (viewData.view instanceof RestReadView<?> && "GET".equals(req.getMethod())) {
                result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
            } else if (viewData.view instanceof RestModifyView<?, ?>) {
                @SuppressWarnings("unchecked")
                RestModifyView<RestResource, Object> m = (RestModifyView<RestResource, Object>) viewData.view;

                inputRequestBody = parseRequest(req, inputType(m));
                result = m.apply(rsrc, inputRequestBody);
            } else {
                throw new ResourceNotFoundException();
            }

            if (result instanceof Response) {
                @SuppressWarnings("rawtypes")
                Response<?> r = (Response) result;
                status = r.statusCode();
                configureCaching(req, res, rsrc, viewData.view, r.caching());
            } else if (result instanceof Response.Redirect) {
                CacheHeaders.setNotCacheable(res);
                res.sendRedirect(((Response.Redirect) result).location());
                return;
            } else {
                CacheHeaders.setNotCacheable(res);
            }
            res.setStatus(status);

            if (result != Response.none()) {
                result = Response.unwrap(result);
                if (result instanceof BinaryResult) {
                    replyBinaryResult(req, res, (BinaryResult) result);
                } else {
                    replyJson(req, res, config, result);
                }
            }
        } catch (MalformedJsonException e) {
            replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
        } catch (JsonParseException e) {
            replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
        } catch (BadRequestException e) {
            replyError(req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
        } catch (AuthException e) {
            replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
        } catch (AmbiguousViewException e) {
            replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
        } catch (ResourceNotFoundException e) {
            replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e);
        } catch (MethodNotAllowedException e) {
            replyError(req, res, status = SC_METHOD_NOT_ALLOWED, messageOr(e, "Method Not Allowed"), e.caching(),
                    e);
        } catch (ResourceConflictException e) {
            replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e);
        } catch (PreconditionFailedException e) {
            replyError(req, res, status = SC_PRECONDITION_FAILED, messageOr(e, "Precondition Failed"), e.caching(),
                    e);
        } catch (UnprocessableEntityException e) {
            replyError(req, res, status = 422, messageOr(e, "Unprocessable Entity"), e.caching(), e);
        } catch (NotImplementedException e) {
            replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
        } catch (Exception e) {
            status = SC_INTERNAL_SERVER_ERROR;
            handleException(e, req, res);
        } finally {
            globals.auditService.dispatch(new ExtendedHttpAuditEvent(globals.webSession.get().getSessionId(),
                    globals.currentUser.get(), req, auditStartTs, params, inputRequestBody, status, result, rsrc,
                    viewData == null ? null : viewData.view));
        }
    }

    private static String messageOr(Throwable t, String defaultMessage) {
        if (!Strings.isNullOrEmpty(t.getMessage())) {
            return t.getMessage();
        }
        return defaultMessage;
    }

    @SuppressWarnings({ "unchecked", "rawtypes" })
    private static boolean notModified(HttpServletRequest req, RestResource rsrc, RestView<RestResource> view) {
        if (!isGetOrHead(req)) {
            return false;
        }

        if (view instanceof ETagView) {
            String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
            if (have != null) {
                return have.equals(((ETagView) view).getETag(rsrc));
            }
        }

        if (rsrc instanceof RestResource.HasETag) {
            String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
            if (have != null) {
                return have.equals(((RestResource.HasETag) rsrc).getETag());
            }
        }

        if (rsrc instanceof RestResource.HasLastModified) {
            Timestamp m = ((RestResource.HasLastModified) rsrc).getLastModified();
            long d = req.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE);

            // HTTP times are in seconds, database may have millisecond precision.
            return d / 1000L == m.getTime() / 1000L;
        }
        return false;
    }

    private static <R extends RestResource> void configureCaching(HttpServletRequest req, HttpServletResponse res,
            R rsrc, RestView<R> view, CacheControl c) {
        if (isGetOrHead(req)) {
            switch (c.getType()) {
            case NONE:
            default:
                CacheHeaders.setNotCacheable(res);
                break;
            case PRIVATE:
                addResourceStateHeaders(res, rsrc, view);
                CacheHeaders.setCacheablePrivate(res, c.getAge(), c.getUnit(), c.isMustRevalidate());
                break;
            case PUBLIC:
                addResourceStateHeaders(res, rsrc, view);
                CacheHeaders.setCacheable(req, res, c.getAge(), c.getUnit(), c.isMustRevalidate());
                break;
            }
        } else {
            CacheHeaders.setNotCacheable(res);
        }
    }

    private static <R extends RestResource> void addResourceStateHeaders(HttpServletResponse res, R rsrc,
            RestView<R> view) {
        if (view instanceof ETagView) {
            res.setHeader(HttpHeaders.ETAG, ((ETagView<R>) view).getETag(rsrc));
        } else if (rsrc instanceof RestResource.HasETag) {
            res.setHeader(HttpHeaders.ETAG, ((RestResource.HasETag) rsrc).getETag());
        }
        if (rsrc instanceof RestResource.HasLastModified) {
            res.setDateHeader(HttpHeaders.LAST_MODIFIED,
                    ((RestResource.HasLastModified) rsrc).getLastModified().getTime());
        }
    }

    private void checkPreconditions(HttpServletRequest req) throws PreconditionFailedException {
        if ("*".equals(req.getHeader(HttpHeaders.IF_NONE_MATCH))) {
            throw new PreconditionFailedException("Resource already exists");
        }
    }

    private static Type inputType(RestModifyView<RestResource, Object> m) {
        Type inputType = extractInputType(m.getClass());
        if (inputType == null) {
            throw new IllegalStateException(String.format("View %s does not correctly implement %s", m.getClass(),
                    RestModifyView.class.getSimpleName()));
        }
        return inputType;
    }

    @SuppressWarnings("rawtypes")
    private static Type extractInputType(Class clazz) {
        for (Type t : clazz.getGenericInterfaces()) {
            if (t instanceof ParameterizedType && ((ParameterizedType) t).getRawType() == RestModifyView.class) {
                return ((ParameterizedType) t).getActualTypeArguments()[1];
            }
        }

        if (clazz.getSuperclass() != null) {
            Type i = extractInputType(clazz.getSuperclass());
            if (i != null) {
                return i;
            }
        }

        for (Class t : clazz.getInterfaces()) {
            Type i = extractInputType(t);
            if (i != null) {
                return i;
            }
        }

        return null;
    }

    private Object parseRequest(HttpServletRequest req, Type type) throws IOException, BadRequestException,
            SecurityException, IllegalArgumentException, NoSuchMethodException, IllegalAccessException,
            InstantiationException, InvocationTargetException, MethodNotAllowedException {
        if (isType(JSON_TYPE, req.getContentType())) {
            try (BufferedReader br = req.getReader(); JsonReader json = new JsonReader(br)) {
                json.setLenient(true);

                JsonToken first;
                try {
                    first = json.peek();
                } catch (EOFException e) {
                    throw new BadRequestException("Expected JSON object");
                }
                if (first == JsonToken.STRING) {
                    return parseString(json.nextString(), type);
                }
                return OutputFormat.JSON.newGson().fromJson(json, type);
            }
        } else if (("PUT".equals(req.getMethod()) || "POST".equals(req.getMethod())) && acceptsRawInput(type)) {
            return parseRawInput(req, type);
        } else if ("DELETE".equals(req.getMethod()) && hasNoBody(req)) {
            return null;
        } else if (hasNoBody(req)) {
            return createInstance(type);
        } else if (isType("text/plain", req.getContentType())) {
            try (BufferedReader br = req.getReader()) {
                char[] tmp = new char[256];
                StringBuilder sb = new StringBuilder();
                int n;
                while (0 < (n = br.read(tmp))) {
                    sb.append(tmp, 0, n);
                }
                return parseString(sb.toString(), type);
            }
        } else if ("POST".equals(req.getMethod()) && isType(FORM_TYPE, req.getContentType())) {
            return OutputFormat.JSON.newGson().fromJson(ParameterParser.formToJson(req), type);
        } else {
            throw new BadRequestException("Expected Content-Type: " + JSON_TYPE);
        }
    }

    private static boolean hasNoBody(HttpServletRequest req) {
        int len = req.getContentLength();
        String type = req.getContentType();
        return (len <= 0 && type == null) || (len == 0 && isType(FORM_TYPE, type));
    }

    @SuppressWarnings("rawtypes")
    private static boolean acceptsRawInput(Type type) {
        if (type instanceof Class) {
            for (Field f : ((Class) type).getDeclaredFields()) {
                if (f.getType() == RawInput.class) {
                    return true;
                }
            }
        }
        return false;
    }

    private Object parseRawInput(final HttpServletRequest req, Type type)
            throws SecurityException, NoSuchMethodException, IllegalArgumentException, InstantiationException,
            IllegalAccessException, InvocationTargetException, MethodNotAllowedException {
        Object obj = createInstance(type);
        for (Field f : obj.getClass().getDeclaredFields()) {
            if (f.getType() == RawInput.class) {
                f.setAccessible(true);
                f.set(obj, new RawInput() {
                    @Override
                    public String getContentType() {
                        return req.getContentType();
                    }

                    @Override
                    public long getContentLength() {
                        return req.getContentLength();
                    }

                    @Override
                    public InputStream getInputStream() throws IOException {
                        return req.getInputStream();
                    }
                });
                return obj;
            }
        }
        throw new MethodNotAllowedException();
    }

    private Object parseString(String value, Type type)
            throws BadRequestException, SecurityException, NoSuchMethodException, IllegalArgumentException,
            IllegalAccessException, InstantiationException, InvocationTargetException {
        if (type == String.class) {
            return value;
        }

        Object obj = createInstance(type);
        Field[] fields = obj.getClass().getDeclaredFields();
        if (fields.length == 0 && Strings.isNullOrEmpty(value)) {
            return obj;
        }
        for (Field f : fields) {
            if (f.getAnnotation(DefaultInput.class) != null && f.getType() == String.class) {
                f.setAccessible(true);
                f.set(obj, value);
                return obj;
            }
        }
        throw new BadRequestException("Expected JSON object");
    }

    private static Object createInstance(Type type) throws NoSuchMethodException, InstantiationException,
            IllegalAccessException, InvocationTargetException {
        if (type instanceof Class) {
            @SuppressWarnings("unchecked")
            Class<Object> clazz = (Class<Object>) type;
            Constructor<Object> c = clazz.getDeclaredConstructor();
            c.setAccessible(true);
            return c.newInstance();
        }
        throw new InstantiationException("Cannot make " + type);
    }

    public static void replyJson(@Nullable HttpServletRequest req, HttpServletResponse res,
            Multimap<String, String> config, Object result) throws IOException {
        TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, Integer.MAX_VALUE);
        buf.write(JSON_MAGIC);
        Writer w = new BufferedWriter(new OutputStreamWriter(buf, UTF_8));
        Gson gson = newGson(config, req);
        if (result instanceof JsonElement) {
            gson.toJson((JsonElement) result, w);
        } else {
            gson.toJson(result, w);
        }
        w.write('\n');
        w.flush();
        replyBinaryResult(req, res,
                asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8.name()));
    }

    private static Gson newGson(Multimap<String, String> config, @Nullable HttpServletRequest req) {
        GsonBuilder gb = OutputFormat.JSON_COMPACT.newGsonBuilder();

        enablePrettyPrint(gb, config, req);
        enablePartialGetFields(gb, config);

        return gb.create();
    }

    private static void enablePrettyPrint(GsonBuilder gb, Multimap<String, String> config,
            @Nullable HttpServletRequest req) {
        String pp = Iterables.getFirst(config.get("pp"), null);
        if (pp == null) {
            pp = Iterables.getFirst(config.get("prettyPrint"), null);
            if (pp == null && req != null) {
                pp = acceptsJson(req) ? "0" : "1";
            }
        }
        if ("1".equals(pp) || "true".equals(pp)) {
            gb.setPrettyPrinting();
        }
    }

    private static void enablePartialGetFields(GsonBuilder gb, Multimap<String, String> config) {
        final Set<String> want = Sets.newHashSet();
        for (String p : config.get("fields")) {
            Iterables.addAll(want, OptionUtil.splitOptionValue(p));
        }
        if (!want.isEmpty()) {
            gb.addSerializationExclusionStrategy(new ExclusionStrategy() {
                private final Map<String, String> names = Maps.newHashMap();

                @Override
                public boolean shouldSkipField(FieldAttributes field) {
                    String name = names.get(field.getName());
                    if (name == null) {
                        // Names are supplied by Gson in terms of Java source.
                        // Translate and cache the JSON lower_case_style used.
                        try {
                            name = FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES.translateName(//
                                    field.getDeclaringClass().getDeclaredField(field.getName()));
                            names.put(field.getName(), name);
                        } catch (SecurityException e) {
                            return true;
                        } catch (NoSuchFieldException e) {
                            return true;
                        }
                    }
                    return !want.contains(name);
                }

                @Override
                public boolean shouldSkipClass(Class<?> clazz) {
                    return false;
                }
            });
        }
    }

    @SuppressWarnings("resource")
    static void replyBinaryResult(@Nullable HttpServletRequest req, HttpServletResponse res, BinaryResult bin)
            throws IOException {
        final BinaryResult appResult = bin;
        try {
            if (bin.getAttachmentName() != null) {
                res.setHeader("Content-Disposition", "attachment; filename=\"" + bin.getAttachmentName() + "\"");
            }
            if (bin.isBase64()) {
                if (req != null && JSON_TYPE.equals(req.getHeader(HttpHeaders.ACCEPT))) {
                    bin = stackJsonString(res, bin);
                } else {
                    bin = stackBase64(res, bin);
                }
            }
            if (bin.canGzip() && acceptsGzip(req)) {
                bin = stackGzip(res, bin);
            }

            res.setContentType(bin.getContentType());
            long len = bin.getContentLength();
            if (0 <= len && len < Integer.MAX_VALUE) {
                res.setContentLength((int) len);
            } else if (0 <= len) {
                res.setHeader("Content-Length", Long.toString(len));
            }

            if (req == null || !"HEAD".equals(req.getMethod())) {
                try (OutputStream dst = res.getOutputStream()) {
                    bin.writeTo(dst);
                }
            }
        } finally {
            appResult.close();
        }
    }

    private static BinaryResult stackJsonString(HttpServletResponse res, final BinaryResult src)
            throws IOException {
        TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, Integer.MAX_VALUE);
        buf.write(JSON_MAGIC);
        try (Writer w = new BufferedWriter(new OutputStreamWriter(buf, UTF_8));
                JsonWriter json = new JsonWriter(w)) {
            json.setLenient(true);
            json.setHtmlSafe(true);
            json.value(src.asString());
            w.write('\n');
        }
        res.setHeader("X-FYI-Content-Encoding", "json");
        res.setHeader("X-FYI-Content-Type", src.getContentType());
        return asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8.name());
    }

    private static BinaryResult stackBase64(HttpServletResponse res, final BinaryResult src) throws IOException {
        BinaryResult b64;
        long len = src.getContentLength();
        if (0 <= len && len <= (7 << 20)) {
            b64 = base64(src);
        } else {
            b64 = new BinaryResult() {
                @Override
                public void writeTo(OutputStream out) throws IOException {
                    try (OutputStreamWriter w = new OutputStreamWriter(new FilterOutputStream(out) {
                        @Override
                        public void close() {
                            // Do not close out, but only w and e.
                        }
                    }, ISO_8859_1); OutputStream e = BaseEncoding.base64().encodingStream(w)) {
                        src.writeTo(e);
                    }
                }
            };
        }
        res.setHeader("X-FYI-Content-Encoding", "base64");
        res.setHeader("X-FYI-Content-Type", src.getContentType());
        return b64.setContentType("text/plain").setCharacterEncoding("ISO-8859-1");
    }

    private static BinaryResult stackGzip(HttpServletResponse res, final BinaryResult src) throws IOException {
        BinaryResult gz;
        long len = src.getContentLength();
        if (len < 256) {
            return src; // Do not compress very small payloads.
        } else if (len <= (10 << 20)) {
            gz = compress(src);
            if (len <= gz.getContentLength()) {
                return src;
            }
        } else {
            gz = new BinaryResult() {
                @Override
                public void writeTo(OutputStream out) throws IOException {
                    GZIPOutputStream gz = new GZIPOutputStream(out);
                    src.writeTo(gz);
                    gz.finish();
                    gz.flush();
                }
            };
        }
        res.setHeader("Content-Encoding", "gzip");
        return gz.setContentType(src.getContentType());
    }

    private ViewData view(RestResource rsrc, RestCollection<RestResource, RestResource> rc, String method,
            List<IdString> path) throws AmbiguousViewException, RestApiException {
        DynamicMap<RestView<RestResource>> views = rc.views();
        final IdString projection = path.isEmpty() ? IdString.fromUrl("/") : path.remove(0);
        if (!path.isEmpty()) {
            // If there are path components still remaining after this projection
            // is chosen, look for the projection based upon GET as the method as
            // the client thinks it is a nested collection.
            method = "GET";
        } else if ("HEAD".equals(method)) {
            method = "GET";
        }

        List<String> p = splitProjection(projection);
        if (p.size() == 2) {
            String viewname = p.get(1);
            if (Strings.isNullOrEmpty(viewname)) {
                viewname = "/";
            }
            RestView<RestResource> view = views.get(p.get(0), method + "." + viewname);
            if (view != null) {
                return new ViewData(p.get(0), view);
            }
            view = views.get(p.get(0), "GET." + viewname);
            if (view != null) {
                if (view instanceof AcceptsPost && "POST".equals(method)) {
                    @SuppressWarnings("unchecked")
                    AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) view;
                    return new ViewData(p.get(0), ap.post(rsrc));
                }
            }
            throw new ResourceNotFoundException(projection);
        }

        String name = method + "." + p.get(0);
        RestView<RestResource> core = views.get("gerrit", name);
        if (core != null) {
            return new ViewData(null, core);
        } else {
            core = views.get("gerrit", "GET." + p.get(0));
            if (core instanceof AcceptsPost && "POST".equals(method)) {
                @SuppressWarnings("unchecked")
                AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) core;
                return new ViewData(null, ap.post(rsrc));
            }
        }

        Map<String, RestView<RestResource>> r = Maps.newTreeMap();
        for (String plugin : views.plugins()) {
            RestView<RestResource> action = views.get(plugin, name);
            if (action != null) {
                r.put(plugin, action);
            }
        }

        if (r.size() == 1) {
            Map.Entry<String, RestView<RestResource>> entry = Iterables.getOnlyElement(r.entrySet());
            return new ViewData(entry.getKey(), entry.getValue());
        } else if (r.isEmpty()) {
            throw new ResourceNotFoundException(projection);
        } else {
            throw new AmbiguousViewException(String.format("Projection %s is ambiguous: %s", name,
                    Joiner.on(", ").join(Iterables.transform(r.keySet(), new Function<String, String>() {
                        @Override
                        public String apply(String in) {
                            return in + "~" + projection;
                        }
                    }))));
        }
    }

    private static List<IdString> splitPath(HttpServletRequest req) {
        String path = RequestUtil.getEncodedPathInfo(req);
        if (Strings.isNullOrEmpty(path)) {
            return Collections.emptyList();
        }
        List<IdString> out = Lists.newArrayList();
        for (String p : Splitter.on('/').split(path)) {
            out.add(IdString.fromUrl(p));
        }
        if (out.size() > 0 && out.get(out.size() - 1).isEmpty()) {
            out.remove(out.size() - 1);
        }
        return out;
    }

    private static List<String> splitProjection(IdString projection) {
        List<String> p = Lists.newArrayListWithCapacity(2);
        Iterables.addAll(p, Splitter.on('~').limit(2).split(projection.get()));
        return p;
    }

    private void checkUserSession(HttpServletRequest req) throws AuthException {
        CurrentUser user = globals.currentUser.get();
        if (isStateChange(req)) {
            if (user instanceof AnonymousUser) {
                throw new AuthException("Authentication required");
            } else if (!globals.webSession.get().isAccessPathOk(AccessPath.REST_API)) {
                throw new AuthException("Invalid authentication method. In order to authenticate, "
                        + "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
            }
        }
        user.setAccessPath(AccessPath.REST_API);
    }

    private static boolean isGetOrHead(HttpServletRequest req) {
        return "GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod());
    }

    private static boolean isStateChange(HttpServletRequest req) {
        return !isGetOrHead(req);
    }

    private void checkRequiresCapability(ViewData viewData) throws AuthException {
        CapabilityUtils.checkRequiresCapability(globals.currentUser, viewData.pluginName, viewData.view.getClass());
    }

    private static void handleException(Throwable err, HttpServletRequest req, HttpServletResponse res)
            throws IOException {
        String uri = req.getRequestURI();
        if (!Strings.isNullOrEmpty(req.getQueryString())) {
            uri += "?" + req.getQueryString();
        }
        log.error(String.format("Error in %s %s", req.getMethod(), uri), err);

        if (!res.isCommitted()) {
            res.reset();
            replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error", err);
        }
    }

    public static void replyError(HttpServletRequest req, HttpServletResponse res, int statusCode, String msg,
            @Nullable Throwable err) throws IOException {
        replyError(req, res, statusCode, msg, CacheControl.NONE, err);
    }

    public static void replyError(HttpServletRequest req, HttpServletResponse res, int statusCode, String msg,
            CacheControl c, @Nullable Throwable err) throws IOException {
        if (err != null) {
            RequestUtil.setErrorTraceAttribute(req, err);
        }
        configureCaching(req, res, null, null, c);
        res.setStatus(statusCode);
        replyText(req, res, msg);
    }

    static void replyText(@Nullable HttpServletRequest req, HttpServletResponse res, String text)
            throws IOException {
        if ((req == null || isGetOrHead(req)) && isMaybeHTML(text)) {
            replyJson(req, res, ImmutableMultimap.of("pp", "0"), new JsonPrimitive(text));
        } else {
            if (!text.endsWith("\n")) {
                text += "\n";
            }
            replyBinaryResult(req, res, BinaryResult.create(text).setContentType("text/plain"));
        }
    }

    private static final Pattern IS_HTML = Pattern.compile("[<&]");

    private static boolean isMaybeHTML(String text) {
        return IS_HTML.matcher(text).find();
    }

    private static boolean acceptsJson(HttpServletRequest req) {
        return req != null && isType(JSON_TYPE, req.getHeader(HttpHeaders.ACCEPT));
    }

    private static boolean acceptsGzip(HttpServletRequest req) {
        if (req != null) {
            String accepts = req.getHeader(HttpHeaders.ACCEPT_ENCODING);
            return accepts != null && accepts.contains("gzip");
        }
        return false;
    }

    private static boolean isType(String expect, String given) {
        if (given == null) {
            return false;
        } else if (expect.equals(given)) {
            return true;
        } else if (given.startsWith(expect + ",")) {
            return true;
        }
        for (String p : given.split("[ ,;][ ,;]*")) {
            if (expect.equals(p)) {
                return true;
            }
        }
        return false;
    }

    private static int base64MaxSize(long n) {
        return 4 * IntMath.divide((int) n, 3, CEILING);
    }

    private static BinaryResult base64(BinaryResult bin) throws IOException {
        int maxSize = base64MaxSize(bin.getContentLength());
        int estSize = Math.min(base64MaxSize(HEAP_EST_SIZE), maxSize);
        TemporaryBuffer.Heap buf = heap(estSize, maxSize);
        try (OutputStream encoded = BaseEncoding.base64().encodingStream(new OutputStreamWriter(buf, ISO_8859_1))) {
            bin.writeTo(encoded);
        }
        return asBinaryResult(buf);
    }

    private static BinaryResult compress(BinaryResult bin) throws IOException {
        TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, 20 << 20);
        try (GZIPOutputStream gz = new GZIPOutputStream(buf)) {
            bin.writeTo(gz);
        }
        return asBinaryResult(buf).setContentType(bin.getContentType());
    }

    @SuppressWarnings("resource")
    private static BinaryResult asBinaryResult(final TemporaryBuffer.Heap buf) {
        return new BinaryResult() {
            @Override
            public void writeTo(OutputStream os) throws IOException {
                buf.writeTo(os, null);
            }
        }.setContentLength(buf.length());
    }

    private static Heap heap(int est, int max) {
        return new TemporaryBuffer.Heap(est, max);
    }

    @SuppressWarnings("serial")
    private static class AmbiguousViewException extends Exception {
        AmbiguousViewException(String message) {
            super(message);
        }
    }

    private static class ViewData {
        String pluginName;
        RestView<RestResource> view;

        ViewData(String pluginName, RestView<RestResource> view) {
            this.pluginName = pluginName;
            this.view = view;
        }
    }
}