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

com.google.gerrit.httpd.restapi.RestApiServlet Maven / Gradle / Ivy

There is a newer version: 3.10.0-rc4
Show newest version
// 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 com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
import static com.google.common.net.HttpHeaders.ORIGIN;
import static com.google.common.net.HttpHeaders.VARY;
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 java.util.stream.Collectors.joining;
import static javax.servlet.http.HttpServletResponse.SC_ACCEPTED;
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.CharMatcher;
import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.MultimapBuilder;
import com.google.common.io.BaseEncoding;
import com.google.common.io.CountingOutputStream;
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.RawInputUtil;
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.NeedsParams;
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.server.config.GerritServerConfig;
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 java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.EOFException;
import java.io.FilterOutputStream;
import java.io.IOException;
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.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import java.util.stream.StreamSupport;
import java.util.zip.GZIPOutputStream;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.util.TemporaryBuffer;
import org.eclipse.jgit.util.TemporaryBuffer.Heap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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";

  // HTTP 422 Unprocessable Entity.
  // TODO: Remove when HttpServletResponse.SC_UNPROCESSABLE_ENTITY is available
  private static final int SC_UNPROCESSABLE_ENTITY = 422;
  private static final String X_REQUESTED_WITH = "X-Requested-With";
  private static final ImmutableSet ALLOWED_CORS_REQUEST_HEADERS =
      ImmutableSet.of(X_REQUESTED_WITH);

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

  /**
   * Garbage prefix inserted before JSON output to prevent XSSI.
   *
   * 

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 <script src="...> 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; final DynamicItem webSession; final Provider paramParser; final AuditService auditService; final RestApiMetrics metrics; final Pattern allowOrigin; @Inject Globals( Provider currentUser, DynamicItem webSession, Provider paramParser, AuditService auditService, RestApiMetrics metrics, @GerritServerConfig Config cfg) { this.currentUser = currentUser; this.webSession = webSession; this.paramParser = paramParser; this.auditService = auditService; this.metrics = metrics; allowOrigin = makeAllowOrigin(cfg); } private static Pattern makeAllowOrigin(Config cfg) { String[] allow = cfg.getStringList("site", null, "allowOriginRegex"); if (allow.length > 0) { return Pattern.compile(Joiner.on('|').join(allow)); } return null; } } private final Globals globals; private final Provider> members; public RestApiServlet( Globals globals, RestCollection members) { this(globals, Providers.of(members)); } public RestApiServlet( Globals globals, Provider> members) { @SuppressWarnings("unchecked") Provider> n = (Provider>) checkNotNull((Object) members); this.globals = globals; this.members = n; } @Override protected final void service(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException { final long startNanos = System.nanoTime(); long auditStartTs = TimeUtil.nowMs(); res.setHeader("Content-Disposition", "attachment"); res.setHeader("X-Content-Type-Options", "nosniff"); int status = SC_OK; long responseBytes = -1; Object result = null; ListMultimap params = MultimapBuilder.hashKeys().arrayListValues().build(); ListMultimap config = MultimapBuilder.hashKeys().arrayListValues().build(); Object inputRequestBody = null; RestResource rsrc = TopLevelResource.INSTANCE; ViewData viewData = null; try { if (isCorsPreflight(req)) { doCorsPreflight(req, res); return; } checkCors(req, res); checkUserSession(req); ParameterParser.splitQueryString(req.getQueryString(), config, params); List path = splitPath(req); RestCollection rc = members.get(); CapabilityUtils.checkRequiresCapability(globals.currentUser, null, rc.getClass()); viewData = new ViewData(null, null); if (path.isEmpty()) { if (rc instanceof NeedsParams) { ((NeedsParams) rc).setParams(params); } if (isRead(req)) { viewData = new ViewData(null, rc.list()); } else if (rc instanceof AcceptsPost && "POST".equals(req.getMethod())) { @SuppressWarnings("unchecked") AcceptsPost ac = (AcceptsPost) 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 ac = (AcceptsCreate) 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 c = (RestCollection) viewData.view; if (path.isEmpty()) { if (isRead(req)) { viewData = new ViewData(null, c.list()); } else if (c instanceof AcceptsPost && "POST".equals(req.getMethod())) { @SuppressWarnings("unchecked") AcceptsPost ac = (AcceptsPost) c; viewData = new ViewData(null, ac.post(rsrc)); } else if (c instanceof AcceptsDelete && "DELETE".equals(req.getMethod())) { @SuppressWarnings("unchecked") AcceptsDelete ac = (AcceptsDelete) c; viewData = new ViewData(null, ac.delete(rsrc, null)); } else { throw new MethodNotAllowedException(); } break; } 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 ac = (AcceptsCreate) 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 ac = (AcceptsDelete) 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; } if (!globals.paramParser.get().parse(viewData.view, params, req, res)) { return; } if (viewData.view instanceof RestReadView && isRead(req)) { result = ((RestReadView) viewData.view).apply(rsrc); } else if (viewData.view instanceof RestModifyView) { @SuppressWarnings("unchecked") RestModifyView m = (RestModifyView) 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 if (result instanceof Response.Accepted) { CacheHeaders.setNotCacheable(res); res.setStatus(SC_ACCEPTED); res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location()); return; } else { CacheHeaders.setNotCacheable(res); } res.setStatus(status); if (result != Response.none()) { result = Response.unwrap(result); if (result instanceof BinaryResult) { responseBytes = replyBinaryResult(req, res, (BinaryResult) result); } else { responseBytes = replyJson(req, res, config, result); } } } catch (MalformedJsonException e) { responseBytes = replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e); } catch (JsonParseException e) { responseBytes = replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e); } catch (BadRequestException e) { responseBytes = replyError( req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e); } catch (AuthException e) { responseBytes = replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e); } catch (AmbiguousViewException e) { responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e); } catch (ResourceNotFoundException e) { responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e); } catch (MethodNotAllowedException e) { responseBytes = replyError( req, res, status = SC_METHOD_NOT_ALLOWED, messageOr(e, "Method Not Allowed"), e.caching(), e); } catch (ResourceConflictException e) { responseBytes = replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e); } catch (PreconditionFailedException e) { responseBytes = replyError( req, res, status = SC_PRECONDITION_FAILED, messageOr(e, "Precondition Failed"), e.caching(), e); } catch (UnprocessableEntityException e) { responseBytes = replyError( req, res, status = SC_UNPROCESSABLE_ENTITY, messageOr(e, "Unprocessable Entity"), e.caching(), e); } catch (NotImplementedException e) { responseBytes = replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e); } catch (Exception e) { status = SC_INTERNAL_SERVER_ERROR; responseBytes = handleException(e, req, res); } finally { String metric = viewData != null && viewData.view != null ? globals.metrics.view(viewData) : "_unknown"; globals.metrics.count.increment(metric); if (status >= SC_BAD_REQUEST) { globals.metrics.errorCount.increment(metric, status); } if (responseBytes != -1) { globals.metrics.responseBytes.record(metric, responseBytes); } globals.metrics.serverLatency.record( metric, System.nanoTime() - startNanos, TimeUnit.NANOSECONDS); 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 void checkCors(HttpServletRequest req, HttpServletResponse res) { String origin = req.getHeader(ORIGIN); if (isRead(req) && !Strings.isNullOrEmpty(origin) && isOriginAllowed(origin)) { res.addHeader(VARY, ORIGIN); setCorsHeaders(res, origin); } } private static boolean isCorsPreflight(HttpServletRequest req) { return "OPTIONS".equals(req.getMethod()) && !Strings.isNullOrEmpty(req.getHeader(ORIGIN)) && !Strings.isNullOrEmpty(req.getHeader(ACCESS_CONTROL_REQUEST_METHOD)); } private void doCorsPreflight(HttpServletRequest req, HttpServletResponse res) throws BadRequestException { CacheHeaders.setNotCacheable(res); res.setHeader( VARY, Joiner.on(", ").join(ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD))); String origin = req.getHeader(ORIGIN); if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) { throw new BadRequestException("CORS not allowed"); } String method = req.getHeader(ACCESS_CONTROL_REQUEST_METHOD); if (!"GET".equals(method) && !"HEAD".equals(method)) { throw new BadRequestException(method + " not allowed in CORS"); } String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS); if (headers != null) { res.addHeader(VARY, ACCESS_CONTROL_REQUEST_HEADERS); String badHeader = StreamSupport.stream(Splitter.on(',').trimResults().split(headers).spliterator(), false) .filter(h -> !ALLOWED_CORS_REQUEST_HEADERS.contains(h)) .findFirst() .orElse(null); if (badHeader != null) { throw new BadRequestException(badHeader + " not allowed in CORS"); } } res.setStatus(SC_OK); setCorsHeaders(res, origin); res.setContentType("text/plain"); res.setContentLength(0); } private void setCorsHeaders(HttpServletResponse res, String origin) { res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin); res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); res.setHeader(ACCESS_CONTROL_ALLOW_METHODS, "GET, OPTIONS"); res.setHeader(ACCESS_CONTROL_ALLOW_HEADERS, Joiner.on(", ").join(ALLOWED_CORS_REQUEST_HEADERS)); } private boolean isOriginAllowed(String origin) { return globals.allowOrigin != null && globals.allowOrigin.matcher(origin).matches(); } 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 view) { if (!isRead(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 void configureCaching( HttpServletRequest req, HttpServletResponse res, R rsrc, RestView view, CacheControl c) { if (isRead(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 void addResourceStateHeaders( HttpServletResponse res, R rsrc, RestView view) { if (view instanceof ETagView) { res.setHeader(HttpHeaders.ETAG, ((ETagView) 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 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, RawInputUtil.create(req)); 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 clazz = (Class) type; Constructor c = clazz.getDeclaredConstructor(); c.setAccessible(true); return c.newInstance(); } throw new InstantiationException("Cannot make " + type); } public static long replyJson( @Nullable HttpServletRequest req, HttpServletResponse res, ListMultimap 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(); return replyBinaryResult( req, res, asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8)); } private static Gson newGson( ListMultimap 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, ListMultimap 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, ListMultimap config) { final Set want = new HashSet<>(); for (String p : config.get("fields")) { Iterables.addAll(want, OptionUtil.splitOptionValue(p)); } if (!want.isEmpty()) { gb.addSerializationExclusionStrategy( new ExclusionStrategy() { private final Map names = new HashMap<>(); @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 long 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 (CountingOutputStream dst = new CountingOutputStream(res.getOutputStream())) { bin.writeTo(dst); return dst.getCount(); } } return 0; } 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); } 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 rc, String method, List path) throws AmbiguousViewException, RestApiException { DynamicMap> 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 p = splitProjection(projection); if (p.size() == 2) { String viewname = p.get(1); if (Strings.isNullOrEmpty(viewname)) { viewname = "/"; } RestView 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 ap = (AcceptsPost) view; return new ViewData(p.get(0), ap.post(rsrc)); } } throw new ResourceNotFoundException(projection); } String name = method + "." + p.get(0); RestView core = views.get("gerrit", name); if (core != null) { return new ViewData(null, core); } core = views.get("gerrit", "GET." + p.get(0)); if (core instanceof AcceptsPost && "POST".equals(method)) { @SuppressWarnings("unchecked") AcceptsPost ap = (AcceptsPost) core; return new ViewData(null, ap.post(rsrc)); } Map> r = new TreeMap<>(); for (String plugin : views.plugins()) { RestView action = views.get(plugin, name); if (action != null) { r.put(plugin, action); } } if (r.size() == 1) { Map.Entry> 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, r.keySet().stream().map(in -> in + "~" + projection).collect(joining(", ")))); } } private static List splitPath(HttpServletRequest req) { String path = RequestUtil.getEncodedPathInfo(req); if (Strings.isNullOrEmpty(path)) { return Collections.emptyList(); } List out = new ArrayList<>(); 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 splitProjection(IdString projection) { List 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 (isRead(req)) { user.setAccessPath(AccessPath.REST_API); user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId()); } else 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/)."); } } private static boolean isRead(HttpServletRequest req) { return "GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod()); } private void checkRequiresCapability(ViewData viewData) throws AuthException { CapabilityUtils.checkRequiresCapability( globals.currentUser, viewData.pluginName, viewData.view.getClass()); } private static long handleException( Throwable err, HttpServletRequest req, HttpServletResponse res) throws IOException { String uri = req.getRequestURI(); if (!Strings.isNullOrEmpty(req.getQueryString())) { uri += "?" + req.getQueryString(); } log.error("Error in {} {}", req.getMethod(), uri, err); if (!res.isCommitted()) { res.reset(); return replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error", err); } return 0; } public static long replyError( HttpServletRequest req, HttpServletResponse res, int statusCode, String msg, @Nullable Throwable err) throws IOException { return replyError(req, res, statusCode, msg, CacheControl.NONE, err); } public static long 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); return replyText(req, res, msg); } static long replyText(@Nullable HttpServletRequest req, HttpServletResponse res, String text) throws IOException { if ((req == null || isRead(req)) && isMaybeHTML(text)) { return replyJson(req, res, ImmutableListMultimap.of("pp", "0"), new JsonPrimitive(text)); } if (!text.endsWith("\n")) { text += "\n"; } return replyBinaryResult(req, res, BinaryResult.create(text).setContentType("text/plain")); } private static boolean isMaybeHTML(String text) { return CharMatcher.anyOf("<&").matchesAnyOf(text); } 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); } } static class ViewData { String pluginName; RestView view; ViewData(String pluginName, RestView view) { this.pluginName = pluginName; this.view = view; } } }