summaryrefslogtreecommitdiffstats
path: root/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/google/gerrit/httpd/restapi/RestApiServlet.java')
-rw-r--r--java/com/google/gerrit/httpd/restapi/RestApiServlet.java1564
1 files changed, 1564 insertions, 0 deletions
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
new file mode 100644
index 0000000000..588f349626
--- /dev/null
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -0,0 +1,1564 @@
+// 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.
+
+// WARNING: NoteDbUpdateManager cares about the package name RestApiServlet lives in.
+package com.google.gerrit.httpd.restapi;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.flogger.LazyArgs.lazy;
+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_MAX_AGE;
+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.AUTHORIZATION;
+import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
+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.Objects.requireNonNull;
+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 static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
+
+import com.google.common.annotations.VisibleForTesting;
+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.flogger.FluentLogger;
+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.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PluginName;
+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.RestCollectionCreateView;
+import com.google.gerrit.extensions.restapi.RestCollectionDeleteMissingView;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.RestCollectionView;
+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.httpd.restapi.ParameterParser.QueryParams;
+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.audit.ExtendedHttpAuditEvent;
+import com.google.gerrit.server.cache.PerThreadCache;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.group.GroupAuditService;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.util.http.CacheHeaders;
+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.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.TypeLiteral;
+import com.google.inject.util.Providers;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
+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.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+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.Stream;
+import java.util.zip.GZIPOutputStream;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.util.TemporaryBuffer;
+import org.eclipse.jgit.util.TemporaryBuffer.Heap;
+
+public class RestApiServlet extends HttpServlet {
+ private static final long serialVersionUID = 1L;
+
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ /** 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";
+
+ @VisibleForTesting public static final String X_GERRIT_TRACE = "X-Gerrit-Trace";
+
+ // 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 String X_GERRIT_AUTH = "X-Gerrit-Auth";
+ static final ImmutableSet<String> ALLOWED_CORS_METHODS =
+ ImmutableSet.of("GET", "HEAD", "POST", "PUT", "DELETE");
+ private static final ImmutableSet<String> ALLOWED_CORS_REQUEST_HEADERS =
+ Stream.of(AUTHORIZATION, CONTENT_TYPE, X_GERRIT_AUTH, X_REQUESTED_WITH)
+ .map(s -> s.toLowerCase(Locale.US))
+ .collect(ImmutableSet.toImmutableSet());
+
+ public static final String XD_AUTHORIZATION = "access_token";
+ public static final String XD_CONTENT_TYPE = "$ct";
+ public static final String XD_METHOD = "$m";
+
+ private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
+ private static final String PLAIN_TEXT = "text/plain";
+ private static final Pattern TYPE_SPLIT_PATTERN = Pattern.compile("[ ,;][ ,;]*");
+
+ /**
+ * 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 PermissionBackend permissionBackend;
+ final GroupAuditService auditService;
+ final RestApiMetrics metrics;
+ final Pattern allowOrigin;
+
+ @Inject
+ Globals(
+ Provider<CurrentUser> currentUser,
+ DynamicItem<WebSession> webSession,
+ Provider<ParameterParser> paramParser,
+ PermissionBackend permissionBackend,
+ GroupAuditService auditService,
+ RestApiMetrics metrics,
+ @GerritServerConfig Config cfg) {
+ this.currentUser = currentUser;
+ this.webSession = webSession;
+ this.paramParser = paramParser;
+ this.permissionBackend = permissionBackend;
+ 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<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>>) requireNonNull((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;
+ QueryParams qp = null;
+ Object inputRequestBody = null;
+ RestResource rsrc = TopLevelResource.INSTANCE;
+ ViewData viewData = null;
+
+ try (TraceContext traceContext = enableTracing(req, res)) {
+ try (PerThreadCache ignored = PerThreadCache.create()) {
+ logger.atFinest().log(
+ "Received REST request: %s %s (parameters: %s)",
+ req.getMethod(), req.getRequestURI(), getParameterNames(req));
+ logger.atFinest().log("Calling user: %s", globals.currentUser.get().getLoggableName());
+
+ if (isCorsPreflight(req)) {
+ doCorsPreflight(req, res);
+ return;
+ }
+
+ qp = ParameterParser.getQueryParams(req);
+ checkCors(req, res, qp.hasXdOverride());
+ if (qp.hasXdOverride()) {
+ req = applyXdOverrides(req, qp);
+ }
+ checkUserSession(req);
+
+ List<IdString> path = splitPath(req);
+ RestCollection<RestResource, RestResource> rc = members.get();
+ globals
+ .permissionBackend
+ .currentUser()
+ .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
+
+ viewData = new ViewData(null, null);
+
+ if (path.isEmpty()) {
+ if (rc instanceof NeedsParams) {
+ ((NeedsParams) rc).setParams(qp.params());
+ }
+
+ if (isRead(req)) {
+ viewData = new ViewData(null, rc.list());
+ } else if (isPost(req)) {
+ RestView<RestResource> restCollectionView =
+ rc.views().get(PluginName.GERRIT, "POST_ON_COLLECTION./");
+ if (restCollectionView != null) {
+ viewData = new ViewData(null, restCollectionView);
+ } else {
+ throw methodNotAllowed(req);
+ }
+ } else {
+ // DELETE on root collections is not supported
+ throw methodNotAllowed(req);
+ }
+ } else {
+ IdString id = path.remove(0);
+ try {
+ rsrc = rc.parse(rsrc, id);
+ if (path.isEmpty()) {
+ checkPreconditions(req);
+ }
+ } catch (ResourceNotFoundException e) {
+ if (!path.isEmpty()) {
+ throw e;
+ }
+
+ if (isPost(req) || isPut(req)) {
+ RestView<RestResource> createView = rc.views().get(PluginName.GERRIT, "CREATE./");
+ if (createView != null) {
+ viewData = new ViewData(null, createView);
+ status = SC_CREATED;
+ path.add(id);
+ } else {
+ throw e;
+ }
+ } else if (isDelete(req)) {
+ RestView<RestResource> deleteView =
+ rc.views().get(PluginName.GERRIT, "DELETE_MISSING./");
+ if (deleteView != null) {
+ viewData = new ViewData(null, deleteView);
+ status = SC_NO_CONTENT;
+ path.add(id);
+ } else {
+ throw e;
+ }
+ } else {
+ throw e;
+ }
+ }
+ if (viewData.view == null) {
+ viewData = view(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 (isRead(req)) {
+ viewData = new ViewData(null, c.list());
+ } else if (isPost(req)) {
+ RestView<RestResource> restCollectionView =
+ c.views().get(viewData.pluginName, "POST_ON_COLLECTION./");
+ if (restCollectionView != null) {
+ viewData = new ViewData(null, restCollectionView);
+ } else {
+ throw methodNotAllowed(req);
+ }
+ } else if (isDelete(req)) {
+ RestView<RestResource> restCollectionView =
+ c.views().get(viewData.pluginName, "DELETE_ON_COLLECTION./");
+ if (restCollectionView != null) {
+ viewData = new ViewData(null, restCollectionView);
+ } else {
+ throw methodNotAllowed(req);
+ }
+ } else {
+ throw methodNotAllowed(req);
+ }
+ break;
+ }
+ IdString id = path.remove(0);
+ try {
+ rsrc = c.parse(rsrc, id);
+ checkPreconditions(req);
+ viewData = new ViewData(null, null);
+ } catch (ResourceNotFoundException e) {
+ if (!path.isEmpty()) {
+ throw e;
+ }
+
+ if (isPost(req) || isPut(req)) {
+ RestView<RestResource> createView = c.views().get(PluginName.GERRIT, "CREATE./");
+ if (createView != null) {
+ viewData = new ViewData(null, createView);
+ status = SC_CREATED;
+ path.add(id);
+ } else {
+ throw e;
+ }
+ } else if (isDelete(req)) {
+ RestView<RestResource> deleteView =
+ c.views().get(PluginName.GERRIT, "DELETE_MISSING./");
+ if (deleteView != null) {
+ viewData = new ViewData(null, deleteView);
+ status = SC_NO_CONTENT;
+ path.add(id);
+ } else {
+ throw e;
+ }
+ } else {
+ throw e;
+ }
+ }
+ if (viewData.view == null) {
+ viewData = view(c, req.getMethod(), path);
+ }
+ checkRequiresCapability(viewData);
+ }
+
+ if (notModified(req, rsrc, viewData.view)) {
+ logger.atFinest().log("REST call succeeded: %d", SC_NOT_MODIFIED);
+ res.sendError(SC_NOT_MODIFIED);
+ return;
+ }
+
+ if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
+ return;
+ }
+
+ if (viewData.view instanceof RestReadView<?> && isRead(req)) {
+ result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
+ } else if (viewData.view instanceof RestModifyView<?, ?>) {
+ @SuppressWarnings("unchecked")
+ RestModifyView<RestResource, Object> m =
+ (RestModifyView<RestResource, Object>) viewData.view;
+
+ Type type = inputType(m);
+ inputRequestBody = parseRequest(req, type);
+ result = m.apply(rsrc, inputRequestBody);
+ if (inputRequestBody instanceof RawInput) {
+ try (InputStream is = req.getInputStream()) {
+ ServletUtils.consumeRequestBody(is);
+ }
+ }
+ } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
+ @SuppressWarnings("unchecked")
+ RestCollectionCreateView<RestResource, RestResource, Object> m =
+ (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
+
+ Type type = inputType(m);
+ inputRequestBody = parseRequest(req, type);
+ result = m.apply(rsrc, path.get(0), inputRequestBody);
+ if (inputRequestBody instanceof RawInput) {
+ try (InputStream is = req.getInputStream()) {
+ ServletUtils.consumeRequestBody(is);
+ }
+ }
+ } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
+ @SuppressWarnings("unchecked")
+ RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
+ (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
+
+ Type type = inputType(m);
+ inputRequestBody = parseRequest(req, type);
+ result = m.apply(rsrc, path.get(0), inputRequestBody);
+ if (inputRequestBody instanceof RawInput) {
+ try (InputStream is = req.getInputStream()) {
+ ServletUtils.consumeRequestBody(is);
+ }
+ }
+ } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
+ @SuppressWarnings("unchecked")
+ RestCollectionModifyView<RestResource, RestResource, Object> m =
+ (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
+
+ Type type = inputType(m);
+ inputRequestBody = parseRequest(req, type);
+ result = m.apply(rsrc, inputRequestBody);
+ if (inputRequestBody instanceof RawInput) {
+ try (InputStream is = req.getInputStream()) {
+ ServletUtils.consumeRequestBody(is);
+ }
+ }
+ } 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);
+ String location = ((Response.Redirect) result).location();
+ res.sendRedirect(location);
+ logger.atFinest().log("REST call redirected to: %s", location);
+ return;
+ } else if (result instanceof Response.Accepted) {
+ CacheHeaders.setNotCacheable(res);
+ res.setStatus(SC_ACCEPTED);
+ res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location());
+ logger.atFinest().log("REST call succeeded: %d", SC_ACCEPTED);
+ return;
+ } else {
+ CacheHeaders.setNotCacheable(res);
+ }
+ res.setStatus(status);
+ logger.atFinest().log("REST call succeeded: %d", status);
+
+ if (result != Response.none()) {
+ result = Response.unwrap(result);
+ if (result instanceof BinaryResult) {
+ responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
+ } else {
+ responseBytes = replyJson(req, res, false, qp.config(), result);
+ }
+ }
+ } catch (MalformedJsonException | 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 (UpdateException e) {
+ Throwable t = e.getCause();
+ if (t instanceof LockFailureException) {
+ responseBytes =
+ replyError(
+ req, res, status = SC_SERVICE_UNAVAILABLE, messageOr(t, "Lock failure"), e);
+ } else {
+ status = SC_INTERNAL_SERVER_ERROR;
+ responseBytes = handleException(e, req, res);
+ }
+ } 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,
+ qp != null ? qp.params() : ImmutableListMultimap.of(),
+ inputRequestBody,
+ status,
+ result,
+ rsrc,
+ viewData == null ? null : viewData.view));
+ }
+ }
+ }
+
+ private static HttpServletRequest applyXdOverrides(HttpServletRequest req, QueryParams qp)
+ throws BadRequestException {
+ if (!isPost(req)) {
+ throw new BadRequestException("POST required");
+ }
+
+ String method = qp.xdMethod();
+ String contentType = qp.xdContentType();
+ if (method.equals("POST") || method.equals("PUT")) {
+ if (!isType(PLAIN_TEXT, req.getContentType())) {
+ throw new BadRequestException("invalid " + CONTENT_TYPE);
+ }
+ if (Strings.isNullOrEmpty(contentType)) {
+ throw new BadRequestException(XD_CONTENT_TYPE + " required");
+ }
+ }
+
+ return new HttpServletRequestWrapper(req) {
+ @Override
+ public String getMethod() {
+ return method;
+ }
+
+ @Override
+ public String getContentType() {
+ return contentType;
+ }
+ };
+ }
+
+ private void checkCors(HttpServletRequest req, HttpServletResponse res, boolean isXd)
+ throws BadRequestException {
+ String origin = req.getHeader(ORIGIN);
+ if (isXd) {
+ // Cross-domain, non-preflighted requests must come from an approved origin.
+ if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
+ throw new BadRequestException("origin not allowed");
+ }
+ res.addHeader(VARY, ORIGIN);
+ res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+ res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+ } else if (!Strings.isNullOrEmpty(origin)) {
+ // All other requests must be processed, but conditionally set CORS headers.
+ if (globals.allowOrigin != null) {
+ res.addHeader(VARY, ORIGIN);
+ }
+ if (isOriginAllowed(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);
+ setHeaderList(
+ res,
+ VARY,
+ ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS));
+
+ 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 (!ALLOWED_CORS_METHODS.contains(method)) {
+ throw new BadRequestException(method + " not allowed in CORS");
+ }
+
+ String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS);
+ if (headers != null) {
+ for (String reqHdr : Splitter.on(',').trimResults().split(headers)) {
+ if (!ALLOWED_CORS_REQUEST_HEADERS.contains(reqHdr.toLowerCase(Locale.US))) {
+ throw new BadRequestException(reqHdr + " not allowed in CORS");
+ }
+ }
+ }
+
+ res.setStatus(SC_OK);
+ setCorsHeaders(res, origin);
+ res.setContentType(PLAIN_TEXT);
+ res.setContentLength(0);
+ }
+
+ private static void setCorsHeaders(HttpServletResponse res, String origin) {
+ res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+ res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+ res.setHeader(ACCESS_CONTROL_MAX_AGE, "600");
+ setHeaderList(
+ res,
+ ACCESS_CONTROL_ALLOW_METHODS,
+ Iterables.concat(ALLOWED_CORS_METHODS, ImmutableList.of("OPTIONS")));
+ setHeaderList(res, ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_REQUEST_HEADERS);
+ }
+
+ private static void setHeaderList(HttpServletResponse res, String name, Iterable<String> values) {
+ res.setHeader(name, Joiner.on(", ").join(values));
+ }
+
+ 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<RestResource> 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 <R extends RestResource> void configureCaching(
+ HttpServletRequest req, HttpServletResponse res, R rsrc, RestView<R> 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 <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) {
+ // MyModifyView implements RestModifyView<SomeResource, MyInput>
+ TypeLiteral<?> typeLiteral = TypeLiteral.get(m.getClass());
+
+ // RestModifyView<SomeResource, MyInput>
+ // This is smart enough to resolve even when there are intervening subclasses, even if they have
+ // reordered type arguments.
+ TypeLiteral<?> supertypeLiteral = typeLiteral.getSupertype(RestModifyView.class);
+
+ Type supertype = supertypeLiteral.getType();
+ checkState(
+ supertype instanceof ParameterizedType,
+ "supertype of %s is not parameterized: %s",
+ typeLiteral,
+ supertypeLiteral);
+ return ((ParameterizedType) supertype).getActualTypeArguments()[1];
+ }
+
+ private static Type inputType(RestCollectionView<RestResource, RestResource, Object> m) {
+ // MyCollectionView implements RestCollectionView<SomeResource, SomeResource, MyInput>
+ TypeLiteral<?> typeLiteral = TypeLiteral.get(m.getClass());
+
+ // RestCollectionView<SomeResource, SomeResource, MyInput>
+ // This is smart enough to resolve even when there are intervening subclasses, even if they have
+ // reordered type arguments.
+ TypeLiteral<?> supertypeLiteral = typeLiteral.getSupertype(RestCollectionView.class);
+
+ Type supertype = supertypeLiteral.getType();
+ checkState(
+ supertype instanceof ParameterizedType,
+ "supertype of %s is not parameterized: %s",
+ typeLiteral,
+ supertypeLiteral);
+ return ((ParameterizedType) supertype).getActualTypeArguments()[2];
+ }
+
+ private Object parseRequest(HttpServletRequest req, Type type)
+ throws IOException, BadRequestException, SecurityException, IllegalArgumentException,
+ NoSuchMethodException, IllegalAccessException, InstantiationException,
+ InvocationTargetException, MethodNotAllowedException {
+ // HTTP/1.1 requires consuming the request body before writing non-error response (less than
+ // 400). Consume the request body for all but raw input request types here.
+ if (isType(JSON_TYPE, req.getContentType())) {
+ try (BufferedReader br = req.getReader();
+ JsonReader json = new JsonReader(br)) {
+ try {
+ 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 {
+ // Reader.close won't consume the rest of the input. Explicitly consume the request body.
+ br.skip(Long.MAX_VALUE);
+ }
+ }
+ }
+ String method = req.getMethod();
+ if (("PUT".equals(method) || "POST".equals(method)) && acceptsRawInput(type)) {
+ return parseRawInput(req, type);
+ }
+ if (isDelete(req) && hasNoBody(req)) {
+ return null;
+ }
+ if (hasNoBody(req)) {
+ return createInstance(type);
+ }
+ if (isType(PLAIN_TEXT, 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);
+ }
+ }
+ if (isPost(req) && isType(FORM_TYPE, req.getContentType())) {
+ return OutputFormat.JSON.newGson().fromJson(ParameterParser.formToJson(req), type);
+ }
+ 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(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);
+ if (Strings.isNullOrEmpty(value)) {
+ return obj;
+ }
+ Field[] fields = obj.getClass().getDeclaredFields();
+ 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");
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Object createInstance(Type type)
+ throws NoSuchMethodException, InstantiationException, IllegalAccessException,
+ InvocationTargetException {
+ if (type instanceof Class) {
+ Class<Object> clazz = (Class<Object>) type;
+ Constructor<Object> c = clazz.getDeclaredConstructor();
+ c.setAccessible(true);
+ return c.newInstance();
+ }
+ if (type instanceof ParameterizedType) {
+ Type rawType = ((ParameterizedType) type).getRawType();
+ if (rawType instanceof Class && List.class.isAssignableFrom((Class<Object>) rawType)) {
+ return new ArrayList<>();
+ }
+ if (rawType instanceof Class && Map.class.isAssignableFrom((Class<Object>) rawType)) {
+ return new HashMap<>();
+ }
+ }
+ throw new InstantiationException("Cannot make " + type);
+ }
+
+ /**
+ * Sets a JSON reply on the given HTTP servlet response.
+ *
+ * @param req the HTTP servlet request
+ * @param res the HTTP servlet response on which the reply should be set
+ * @param allowTracing whether it is allowed to log the reply if tracing is enabled, must not be
+ * set to {@code true} if the reply may contain sensitive data
+ * @param config config parameters for the JSON formatting
+ * @param result the object that should be formatted as JSON
+ * @return the length of the response
+ * @throws IOException
+ */
+ public static long replyJson(
+ @Nullable HttpServletRequest req,
+ HttpServletResponse res,
+ boolean allowTracing,
+ ListMultimap<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();
+
+ if (allowTracing) {
+ logger.atFinest().log(
+ "JSON response body:\n%s",
+ lazy(
+ () -> {
+ try {
+ ByteArrayOutputStream debugOut = new ByteArrayOutputStream();
+ buf.writeTo(debugOut, null);
+ return debugOut.toString(UTF_8.name());
+ } catch (IOException e) {
+ return "<JSON formatting failed>";
+ }
+ }));
+ }
+ return replyBinaryResult(
+ req, res, asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8));
+ }
+
+ private static Gson newGson(
+ ListMultimap<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, ListMultimap<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, ListMultimap<String, String> config) {
+ final Set<String> 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<String, String> 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 | 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, 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, 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(PLAIN_TEXT).setCharacterEncoding(ISO_8859_1);
+ }
+
+ private static BinaryResult stackGzip(HttpServletResponse res, BinaryResult src)
+ throws IOException {
+ BinaryResult gz;
+ long len = src.getContentLength();
+ if (len < 256) {
+ return src; // Do not compress very small payloads.
+ }
+ 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(
+ 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) {
+ return new ViewData(p.get(0), view);
+ }
+ throw new ResourceNotFoundException(projection);
+ }
+
+ String name = method + "." + p.get(0);
+ RestView<RestResource> core = views.get(PluginName.GERRIT, name);
+ if (core != null) {
+ return new ViewData(PluginName.GERRIT, core);
+ }
+
+ core = views.get(PluginName.GERRIT, "GET." + p.get(0));
+ if (core != null) {
+ return new ViewData(PluginName.GERRIT, core);
+ }
+
+ Map<String, RestView<RestResource>> r = new TreeMap<>();
+ 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());
+ }
+ if (r.isEmpty()) {
+ throw new ResourceNotFoundException(projection);
+ }
+ throw new AmbiguousViewException(
+ String.format(
+ "Projection %s is ambiguous: %s",
+ name, r.keySet().stream().map(in -> in + "~" + projection).collect(joining(", "))));
+ }
+
+ private static List<IdString> splitPath(HttpServletRequest req) {
+ String path = RequestUtil.getEncodedPathInfo(req);
+ if (Strings.isNullOrEmpty(path)) {
+ return Collections.emptyList();
+ }
+ List<IdString> 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<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 (isRead(req)) {
+ user.setAccessPath(AccessPath.REST_API);
+ } 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/).");
+ }
+ if (user.isIdentifiedUser()) {
+ user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId());
+ }
+ }
+
+ private List<String> getParameterNames(HttpServletRequest req) {
+ List<String> parameterNames = new ArrayList<>(req.getParameterMap().keySet());
+ Collections.sort(parameterNames);
+ return parameterNames;
+ }
+
+ private TraceContext enableTracing(HttpServletRequest req, HttpServletResponse res) {
+ // There are 2 ways to enable tracing for REST calls:
+ // 1. by using the 'trace' or 'trace=<trace-id>' request parameter
+ // 2. by setting the 'X-Gerrit-Trace:' or 'X-Gerrit-Trace:<trace-id>' header
+ String traceValueFromHeader = req.getHeader(X_GERRIT_TRACE);
+ String traceValueFromRequestParam = req.getParameter(ParameterParser.TRACE_PARAMETER);
+ boolean doTrace = traceValueFromHeader != null || traceValueFromRequestParam != null;
+
+ // Check whether no trace ID, one trace ID or 2 different trace IDs have been specified.
+ String traceId1;
+ String traceId2;
+ if (!Strings.isNullOrEmpty(traceValueFromHeader)) {
+ traceId1 = traceValueFromHeader;
+ if (!Strings.isNullOrEmpty(traceValueFromRequestParam)
+ && !traceValueFromHeader.equals(traceValueFromRequestParam)) {
+ traceId2 = traceValueFromRequestParam;
+ } else {
+ traceId2 = null;
+ }
+ } else {
+ traceId1 = Strings.emptyToNull(traceValueFromRequestParam);
+ traceId2 = null;
+ }
+
+ // Use the first trace ID to start tracing. If this trace ID is null, a trace ID will be
+ // generated.
+ TraceContext traceContext =
+ TraceContext.newTrace(
+ doTrace,
+ traceId1,
+ (tagName, traceId) -> res.setHeader(X_GERRIT_TRACE, traceId.toString()));
+ // If a second trace ID was specified, add a tag for it as well.
+ if (traceId2 != null) {
+ traceContext.addTag(RequestId.Type.TRACE_ID, traceId2);
+ res.addHeader(X_GERRIT_TRACE, traceId2);
+ }
+ return traceContext;
+ }
+
+ private boolean isDelete(HttpServletRequest req) {
+ return "DELETE".equals(req.getMethod());
+ }
+
+ private static boolean isPost(HttpServletRequest req) {
+ return "POST".equals(req.getMethod());
+ }
+
+ private boolean isPut(HttpServletRequest req) {
+ return "PUT".equals(req.getMethod());
+ }
+
+ private static boolean isRead(HttpServletRequest req) {
+ return "GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod());
+ }
+
+ private static MethodNotAllowedException methodNotAllowed(HttpServletRequest req) {
+ return new MethodNotAllowedException(
+ String.format("Not implemented: %s %s", req.getMethod(), requestUri(req)));
+ }
+
+ private static String requestUri(HttpServletRequest req) {
+ String uri = req.getRequestURI();
+ if (uri.startsWith("/a/")) {
+ return uri.substring(2);
+ }
+ return uri;
+ }
+
+ private void checkRequiresCapability(ViewData d)
+ throws AuthException, PermissionBackendException {
+ try {
+ globals.permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+ } catch (AuthException e) {
+ // Skiping
+ globals
+ .permissionBackend
+ .currentUser()
+ .checkAny(GlobalPermission.fromAnnotation(d.pluginName, d.view.getClass()));
+ }
+ }
+
+ private static long handleException(
+ Throwable err, HttpServletRequest req, HttpServletResponse res) throws IOException {
+ String uri = req.getRequestURI();
+ if (!Strings.isNullOrEmpty(req.getQueryString())) {
+ uri += "?" + LogRedactUtil.redactQueryString(req.getQueryString());
+ }
+ logger.atSevere().withCause(err).log("Error in %s %s", req.getMethod(), uri);
+ 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);
+ checkArgument(statusCode >= 400, "non-error status: %s", statusCode);
+ res.setStatus(statusCode);
+ logger.atFinest().log("REST call failed: %d", statusCode);
+ return replyText(req, res, true, msg);
+ }
+
+ /**
+ * Sets a text reply on the given HTTP servlet response.
+ *
+ * @param req the HTTP servlet request
+ * @param res the HTTP servlet response on which the reply should be set
+ * @param allowTracing whether it is allowed to log the reply if tracing is enabled, must not be
+ * set to {@code true} if the reply may contain sensitive data
+ * @param text the text reply
+ * @return the length of the response
+ * @throws IOException
+ */
+ static long replyText(
+ @Nullable HttpServletRequest req, HttpServletResponse res, boolean allowTracing, String text)
+ throws IOException {
+ if ((req == null || isRead(req)) && isMaybeHTML(text)) {
+ return replyJson(
+ req, res, allowTracing, ImmutableListMultimap.of("pp", "0"), new JsonPrimitive(text));
+ }
+ if (!text.endsWith("\n")) {
+ text += "\n";
+ }
+ if (allowTracing) {
+ logger.atFinest().log("Text response body:\n%s", text);
+ }
+ return replyBinaryResult(req, res, BinaryResult.create(text).setContentType(PLAIN_TEXT));
+ }
+
+ 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;
+ }
+ if (expect.equals(given)) {
+ return true;
+ }
+ if (given.startsWith(expect + ",")) {
+ return true;
+ }
+ for (String p : Splitter.on(TYPE_SPLIT_PATTERN).split(given)) {
+ 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(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);
+ }
+
+ private static class AmbiguousViewException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ AmbiguousViewException(String message) {
+ super(message);
+ }
+ }
+
+ static class ViewData {
+ String pluginName;
+ RestView<RestResource> view;
+
+ ViewData(String pluginName, RestView<RestResource> view) {
+ this.pluginName = pluginName;
+ this.view = view;
+ }
+ }
+}