// 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_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.HttpAuditEvent;
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.IdString;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
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.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.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";

  /**
   * 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;

    try {
      checkUserSession(req);

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

      RestResource rsrc = TopLevelResource.INSTANCE;
      ViewData 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)) {
        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 RestModifyView<?, ?>) {
        @SuppressWarnings("unchecked")
        RestModifyView<RestResource, Object> m =
            (RestModifyView<RestResource, Object>) viewData.view;

        inputRequestBody = parseRequest(req, inputType(m));
        result = m.apply(rsrc, inputRequestBody);
      } else if (viewData.view instanceof RestReadView<?>) {
        result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
      } else {
        throw new ResourceNotFoundException();
      }

      if (result instanceof Response) {
        @SuppressWarnings("rawtypes")
        Response<?> r = (Response) result;
        status = r.statusCode();
        configureCaching(req, res, rsrc, 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 (Exception e) {
      status = SC_INTERNAL_SERVER_ERROR;
      handleException(e, req, res);
    } finally {
      globals.auditService.dispatch(new HttpAuditEvent(globals.webSession.get()
          .getSessionId(), globals.currentUser.get(), req.getRequestURI(),
          auditStartTs, params, req.getMethod(), inputRequestBody, status,
          result));
    }
  }

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

  private static boolean notModified(HttpServletRequest req, RestResource rsrc) {
    if (!isGetOrHead(req)) {
      return false;
    }

    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 <T> void configureCaching(HttpServletRequest req,
      HttpServletResponse res, RestResource rsrc, CacheControl c) {
    if (isGetOrHead(req)) {
      switch (c.getType()) {
        case NONE:
        default:
          CacheHeaders.setNotCacheable(res);
          break;
        case PRIVATE:
          addResourceStateHeaders(res, rsrc);
          CacheHeaders.setCacheablePrivate(res,
              c.getAge(), c.getUnit(),
              c.isMustRevalidate());
          break;
        case PUBLIC:
          addResourceStateHeaders(res, rsrc);
          CacheHeaders.setCacheable(req, res,
              c.getAge(), c.getUnit(),
              c.isMustRevalidate());
          break;
      }
    } else {
      CacheHeaders.setNotCacheable(res);
    }
  }

  private static void addResourceStateHeaders(
      HttpServletResponse res, RestResource rsrc) {
    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("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())) {
      BufferedReader br = req.getReader();
      try {
        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);
      } finally {
        br.close();
      }
    } 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())) {
      BufferedReader br = req.getReader();
      try {
        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);
      } finally {
        br.close();
      }
    } 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(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;
        }
      });
    }
  }

  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()) {
        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())) {
        OutputStream dst = res.getOutputStream();
        try {
          bin.writeTo(dst);
        } finally {
          dst.close();
        }
      }
    } finally {
      appResult.close();
    }
  }

  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 {
          OutputStream e = BaseEncoding.base64().encodingStream(
              new OutputStreamWriter(out, ISO_8859_1));
          src.writeTo(e);
          e.flush();
        }
      };
    }
    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 (256 <= len && 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, 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 BinaryResult base64(BinaryResult bin)
      throws IOException {
    int max = 4 * IntMath.divide((int) bin.getContentLength(), 3, CEILING);
    TemporaryBuffer.Heap buf = heap(max);
    OutputStream encoded = BaseEncoding.base64().encodingStream(
        new OutputStreamWriter(buf, ISO_8859_1));
    bin.writeTo(encoded);
    encoded.close();
    return asBinaryResult(buf);
  }

  private static BinaryResult compress(BinaryResult bin)
      throws IOException {
    TemporaryBuffer.Heap buf = heap(20 << 20);
    GZIPOutputStream gz = new GZIPOutputStream(buf);
    bin.writeTo(gz);
    gz.close();
    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 max) {
    return new TemporaryBuffer.Heap(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;
    }
  }
}
