blob: 172321de26421b5afb4d3bc52b1e7565341d3d0f [file] [log] [blame]
// 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.RestApiServlet.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.registration.DynamicMap;
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.Injector;
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";
private static final ImmutableSet<String> RESERVED_KEYS =
ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields", TRACE_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<String, String> config,
ImmutableListMultimap<String, String> 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<String, String> config();
abstract ImmutableListMultimap<String, String> 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<String, String> config = MultimapBuilder.hashKeys(4).arrayListValues().build();
ListMultimap<String, String> params = MultimapBuilder.hashKeys().arrayListValues().build();
String queryString = req.getQueryString();
if (!Strings.isNullOrEmpty(queryString)) {
for (String kvPair : Splitter.on('&').split(queryString)) {
Iterator<String> 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;
private final Injector injector;
private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
@Inject
ParameterParser(
CmdLineParser.Factory pf,
Injector injector,
DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
this.parserFactory = pf;
this.injector = injector;
this.dynamicBeans = dynamicBeans;
}
<T> boolean parse(
T param, ListMultimap<String, String> in, HttpServletRequest req, HttpServletResponse res)
throws IOException {
CmdLineParser clp = parserFactory.create(param);
DynamicOptions pluginOptions = new DynamicOptions(param, injector, dynamicBeans);
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<String> query(HttpServletRequest req) {
Set<String> 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.
*
* <p>Given an input such as:
*
* <pre>
* message=Does+not+compile.&labels.Verified=-1
* </pre>
*
* which is easily created using the curl command line tool:
*
* <pre>
* curl --data 'message=Does not compile.' --data labels.Verified=-1
* </pre>
*
* converts to a JSON object structure that is normally expected:
*
* <pre>
* {
* "message": "Does not compile.",
* "labels": {
* "Verified": "-1"
* }
* }
* </pre>
*
* 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.
*
* <p>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<String, String[]> map = req.getParameterMap();
return formToJson(map, query(req));
}
@VisibleForTesting
static JsonObject formToJson(Map<String, String[]> map, Set<String> query)
throws BadRequestException {
JsonObject inputObject = new JsonObject();
for (Map.Entry<String, String[]> 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;
}
}