1559 lines
58 KiB
Java
1559 lines
58 KiB
Java
// 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.checkNotNull;
|
|
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.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.common.TimeUtil;
|
|
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.AuditService;
|
|
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.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.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 <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> currentUser;
|
|
final DynamicItem<WebSession> webSession;
|
|
final Provider<ParameterParser> paramParser;
|
|
final PermissionBackend permissionBackend;
|
|
final AuditService auditService;
|
|
final RestApiMetrics metrics;
|
|
final Pattern allowOrigin;
|
|
|
|
@Inject
|
|
Globals(
|
|
Provider<CurrentUser> currentUser,
|
|
DynamicItem<WebSession> webSession,
|
|
Provider<ParameterParser> paramParser,
|
|
PermissionBackend permissionBackend,
|
|
AuditService 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>>) 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;
|
|
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)) {
|
|
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 {
|
|
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 += "?" + 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);
|
|
}
|
|
|
|
@SuppressWarnings("serial")
|
|
private static class AmbiguousViewException extends Exception {
|
|
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;
|
|
}
|
|
}
|
|
}
|