| // 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<String> 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<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; | |
| @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. | |
| */ | |
| <T> boolean parse( | |
| T param, | |
| DynamicOptions pluginOptions, | |
| ListMultimap<String, String> 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<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; | |
| } | |
| } |