299 lines
10 KiB
Java
299 lines
10 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.
|
|
|
|
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.gson.JsonArray;
|
|
import com.google.gson.JsonElement;
|
|
import com.google.gson.JsonObject;
|
|
import com.google.gson.JsonPrimitive;
|
|
import com.google.gwtexpui.server.CacheHeaders;
|
|
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 {
|
|
private static final ImmutableSet<String> RESERVED_KEYS =
|
|
ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields");
|
|
|
|
@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;
|
|
}
|
|
}
|