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;
  }
}