com.google.gerrit.httpd.restapi.ParameterParser Maven / Gradle / Ivy
// Copyright (C) 2012 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.httpd.restapi;
import static com.google.gerrit.httpd.restapi.CorsResponder.ALLOWED_CORS_METHODS;
import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_AUTHORIZATION;
import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_CONTENT_TYPE;
import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_METHOD;
import static com.google.gerrit.httpd.restapi.RestApiServlet.replyBinaryResult;
import static com.google.gerrit.httpd.restapi.RestApiServlet.replyError;
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
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.MultimapBuilder;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.Url;
import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.util.cli.CmdLineParser;
import com.google.gerrit.util.http.CacheHeaders;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
import java.io.StringWriter;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.kohsuke.args4j.CmdLineException;
public class ParameterParser {
public static final String TRACE_PARAMETER = "trace";
public static final String EXPERIMENT_PARAMETER = "experiment";
private static final ImmutableSet RESERVED_KEYS =
ImmutableSet.of(
"pp",
"prettyPrint",
"strict",
"callback",
"alt",
"fields",
TRACE_PARAMETER,
EXPERIMENT_PARAMETER);
@AutoValue
public abstract static class QueryParams {
static final String I = QueryParams.class.getName();
static QueryParams create(
@Nullable String accessToken,
@Nullable String xdMethod,
@Nullable String xdContentType,
ImmutableListMultimap config,
ImmutableListMultimap params) {
return new AutoValue_ParameterParser_QueryParams(
accessToken, xdMethod, xdContentType, config, params);
}
@Nullable
public abstract String accessToken();
@Nullable
abstract String xdMethod();
@Nullable
abstract String xdContentType();
abstract ImmutableListMultimap config();
abstract ImmutableListMultimap params();
boolean hasXdOverride() {
return xdMethod() != null || xdContentType() != null;
}
}
public static QueryParams getQueryParams(HttpServletRequest req) throws BadRequestException {
QueryParams qp = (QueryParams) req.getAttribute(QueryParams.I);
if (qp != null) {
return qp;
}
String accessToken = null;
String xdMethod = null;
String xdContentType = null;
ListMultimap config = MultimapBuilder.hashKeys(4).arrayListValues().build();
ListMultimap params = MultimapBuilder.hashKeys().arrayListValues().build();
String queryString = req.getQueryString();
if (!Strings.isNullOrEmpty(queryString)) {
for (String kvPair : Splitter.on('&').split(queryString)) {
Iterator i = Splitter.on('=').limit(2).split(kvPair).iterator();
String key = Url.decode(i.next());
String val = i.hasNext() ? Url.decode(i.next()) : "";
if (XD_AUTHORIZATION.equals(key)) {
if (accessToken != null) {
throw new BadRequestException("duplicate " + XD_AUTHORIZATION);
}
accessToken = val;
} else if (XD_METHOD.equals(key)) {
if (xdMethod != null) {
throw new BadRequestException("duplicate " + XD_METHOD);
} else if (!ALLOWED_CORS_METHODS.contains(val)) {
throw new BadRequestException("invalid " + XD_METHOD);
}
xdMethod = val;
} else if (XD_CONTENT_TYPE.equals(key)) {
if (xdContentType != null) {
throw new BadRequestException("duplicate " + XD_CONTENT_TYPE);
}
xdContentType = val;
} else if (RESERVED_KEYS.contains(key)) {
config.put(key, val);
} else {
params.put(key, val);
}
}
}
qp =
QueryParams.create(
accessToken,
xdMethod,
xdContentType,
ImmutableListMultimap.copyOf(config),
ImmutableListMultimap.copyOf(params));
req.setAttribute(QueryParams.I, qp);
return qp;
}
private final CmdLineParser.Factory parserFactory;
@Inject
ParameterParser(CmdLineParser.Factory pf) {
this.parserFactory = pf;
}
/**
* Parses query parameters ({@code in}) into annotated option fields of {@code param}.
*
* @return true if parsing was successful. Requesting help is considered failure and returns
* false.
*/
boolean parse(
T param,
DynamicOptions pluginOptions,
ListMultimap in,
HttpServletRequest req,
HttpServletResponse res)
throws IOException {
if (param.getClass().getAnnotation(Singleton.class) != null) {
// Command-line parsing mutates the object, so we can't have options on @Singleton.
return true;
}
CmdLineParser clp = parserFactory.create(param);
pluginOptions.setBean(param);
pluginOptions.startLifecycleListeners();
pluginOptions.parseDynamicBeans(clp);
pluginOptions.setDynamicBeans();
pluginOptions.onBeanParseStart();
try {
clp.parseOptionMap(in);
} catch (CmdLineException | NumberFormatException e) {
if (!clp.wasHelpRequestedByOption()) {
replyError(req, res, SC_BAD_REQUEST, e.getMessage(), e);
return false;
}
}
if (clp.wasHelpRequestedByOption()) {
StringWriter msg = new StringWriter();
clp.printQueryStringUsage(req.getRequestURI(), msg);
msg.write('\n');
msg.write('\n');
clp.printUsage(msg, null);
msg.write('\n');
CacheHeaders.setNotCacheable(res);
replyBinaryResult(req, res, BinaryResult.create(msg.toString()).setContentType("text/plain"));
return false;
}
pluginOptions.onBeanParseEnd();
return true;
}
private static Set query(HttpServletRequest req) {
Set params = new HashSet<>();
if (!Strings.isNullOrEmpty(req.getQueryString())) {
for (String kvPair : Splitter.on('&').split(req.getQueryString())) {
params.add(Iterables.getFirst(Splitter.on('=').limit(2).split(kvPair), null));
}
}
return params;
}
/**
* Convert a standard URL encoded form input into a parsed JSON tree.
*
* Given an input such as:
*
*
* message=Does+not+compile.&labels.Verified=-1
*
*
* which is easily created using the curl command line tool:
*
*
* curl --data 'message=Does not compile.' --data labels.Verified=-1
*
*
* converts to a JSON object structure that is normally expected:
*
*
* {
* "message": "Does not compile.",
* "labels": {
* "Verified": "-1"
* }
* }
*
*
* This input can then be further processed into the Java input type expected by a view using
* Gson. Here we rely on Gson to perform implicit conversion of a string {@code "-1"} to a number
* type when the Java input type expects a number.
*
* Conversion assumes any field name that does not contain {@code "."} will be a property of
* the top level input object. Any field with a dot will use the first segment as the top level
* property name naming an object, and the rest of the field name as a property in the nested
* object.
*
* @param req request to parse form input from and create JSON tree.
* @return the converted JSON object tree.
* @throws BadRequestException the request cannot be cast, as there are conflicting definitions
* for a nested object.
*/
static JsonObject formToJson(HttpServletRequest req) throws BadRequestException {
Map map = req.getParameterMap();
return formToJson(map, query(req));
}
@VisibleForTesting
static JsonObject formToJson(Map map, Set query)
throws BadRequestException {
JsonObject inputObject = new JsonObject();
for (Map.Entry ent : map.entrySet()) {
String key = ent.getKey();
String[] values = ent.getValue();
if (query.contains(key) || values.length == 0) {
// Disallow processing query parameters as input body fields.
// Implementations of views should avoid duplicate naming.
continue;
}
JsonObject obj = inputObject;
int dot = key.indexOf('.');
if (0 <= dot) {
String property = key.substring(0, dot);
JsonElement e = inputObject.get(property);
if (e == null) {
obj = new JsonObject();
inputObject.add(property, obj);
} else if (e.isJsonObject()) {
obj = e.getAsJsonObject();
} else {
throw new BadRequestException(String.format("key %s conflicts with %s", key, property));
}
key = key.substring(dot + 1);
}
if (obj.get(key) != null) {
// This error should never happen. If all form values are handled
// together in a single pass properties are set only once. Setting
// again indicates something has gone very wrong.
throw new BadRequestException("invalid form input, use JSON instead");
} else if (values.length == 1) {
obj.addProperty(key, values[0]);
} else {
JsonArray list = new JsonArray();
for (String v : values) {
list.add(new JsonPrimitive(v));
}
obj.add(key, list);
}
}
return inputObject;
}
}