Line data Source code
1 : // Copyright (C) 2012 The Android Open Source Project
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // http://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : package com.google.gerrit.httpd.restapi;
16 :
17 : import static com.google.gerrit.httpd.restapi.RestApiServlet.ALLOWED_CORS_METHODS;
18 : import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_AUTHORIZATION;
19 : import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_CONTENT_TYPE;
20 : import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_METHOD;
21 : import static com.google.gerrit.httpd.restapi.RestApiServlet.replyBinaryResult;
22 : import static com.google.gerrit.httpd.restapi.RestApiServlet.replyError;
23 : import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
24 :
25 : import com.google.auto.value.AutoValue;
26 : import com.google.common.annotations.VisibleForTesting;
27 : import com.google.common.base.Splitter;
28 : import com.google.common.base.Strings;
29 : import com.google.common.collect.ImmutableListMultimap;
30 : import com.google.common.collect.ImmutableSet;
31 : import com.google.common.collect.Iterables;
32 : import com.google.common.collect.ListMultimap;
33 : import com.google.common.collect.MultimapBuilder;
34 : import com.google.gerrit.common.Nullable;
35 : import com.google.gerrit.extensions.restapi.BadRequestException;
36 : import com.google.gerrit.extensions.restapi.BinaryResult;
37 : import com.google.gerrit.extensions.restapi.Url;
38 : import com.google.gerrit.server.DynamicOptions;
39 : import com.google.gerrit.util.cli.CmdLineParser;
40 : import com.google.gerrit.util.http.CacheHeaders;
41 : import com.google.gson.JsonArray;
42 : import com.google.gson.JsonElement;
43 : import com.google.gson.JsonObject;
44 : import com.google.gson.JsonPrimitive;
45 : import com.google.inject.Inject;
46 : import com.google.inject.Singleton;
47 : import java.io.IOException;
48 : import java.io.StringWriter;
49 : import java.util.HashSet;
50 : import java.util.Iterator;
51 : import java.util.Map;
52 : import java.util.Set;
53 : import javax.servlet.http.HttpServletRequest;
54 : import javax.servlet.http.HttpServletResponse;
55 : import org.kohsuke.args4j.CmdLineException;
56 :
57 : public class ParameterParser {
58 : public static final String TRACE_PARAMETER = "trace";
59 : public static final String EXPERIMENT_PARAMETER = "experiment";
60 :
61 31 : private static final ImmutableSet<String> RESERVED_KEYS =
62 31 : ImmutableSet.of(
63 : "pp",
64 : "prettyPrint",
65 : "strict",
66 : "callback",
67 : "alt",
68 : "fields",
69 : TRACE_PARAMETER,
70 : EXPERIMENT_PARAMETER);
71 :
72 : @AutoValue
73 31 : public abstract static class QueryParams {
74 31 : static final String I = QueryParams.class.getName();
75 :
76 : static QueryParams create(
77 : @Nullable String accessToken,
78 : @Nullable String xdMethod,
79 : @Nullable String xdContentType,
80 : ImmutableListMultimap<String, String> config,
81 : ImmutableListMultimap<String, String> params) {
82 31 : return new AutoValue_ParameterParser_QueryParams(
83 : accessToken, xdMethod, xdContentType, config, params);
84 : }
85 :
86 : @Nullable
87 : public abstract String accessToken();
88 :
89 : @Nullable
90 : abstract String xdMethod();
91 :
92 : @Nullable
93 : abstract String xdContentType();
94 :
95 : abstract ImmutableListMultimap<String, String> config();
96 :
97 : abstract ImmutableListMultimap<String, String> params();
98 :
99 : boolean hasXdOverride() {
100 29 : return xdMethod() != null || xdContentType() != null;
101 : }
102 : }
103 :
104 : public static QueryParams getQueryParams(HttpServletRequest req) throws BadRequestException {
105 31 : QueryParams qp = (QueryParams) req.getAttribute(QueryParams.I);
106 31 : if (qp != null) {
107 28 : return qp;
108 : }
109 :
110 31 : String accessToken = null;
111 31 : String xdMethod = null;
112 31 : String xdContentType = null;
113 31 : ListMultimap<String, String> config = MultimapBuilder.hashKeys(4).arrayListValues().build();
114 31 : ListMultimap<String, String> params = MultimapBuilder.hashKeys().arrayListValues().build();
115 :
116 31 : String queryString = req.getQueryString();
117 31 : if (!Strings.isNullOrEmpty(queryString)) {
118 13 : for (String kvPair : Splitter.on('&').split(queryString)) {
119 13 : Iterator<String> i = Splitter.on('=').limit(2).split(kvPair).iterator();
120 13 : String key = Url.decode(i.next());
121 13 : String val = i.hasNext() ? Url.decode(i.next()) : "";
122 :
123 13 : if (XD_AUTHORIZATION.equals(key)) {
124 1 : if (accessToken != null) {
125 0 : throw new BadRequestException("duplicate " + XD_AUTHORIZATION);
126 : }
127 1 : accessToken = val;
128 13 : } else if (XD_METHOD.equals(key)) {
129 2 : if (xdMethod != null) {
130 1 : throw new BadRequestException("duplicate " + XD_METHOD);
131 2 : } else if (!ALLOWED_CORS_METHODS.contains(val)) {
132 1 : throw new BadRequestException("invalid " + XD_METHOD);
133 : }
134 2 : xdMethod = val;
135 13 : } else if (XD_CONTENT_TYPE.equals(key)) {
136 2 : if (xdContentType != null) {
137 1 : throw new BadRequestException("duplicate " + XD_CONTENT_TYPE);
138 : }
139 2 : xdContentType = val;
140 13 : } else if (RESERVED_KEYS.contains(key)) {
141 1 : config.put(key, val);
142 : } else {
143 13 : params.put(key, val);
144 : }
145 13 : }
146 : }
147 :
148 31 : qp =
149 31 : QueryParams.create(
150 : accessToken,
151 : xdMethod,
152 : xdContentType,
153 31 : ImmutableListMultimap.copyOf(config),
154 31 : ImmutableListMultimap.copyOf(params));
155 31 : req.setAttribute(QueryParams.I, qp);
156 31 : return qp;
157 : }
158 :
159 : private final CmdLineParser.Factory parserFactory;
160 :
161 : @Inject
162 27 : ParameterParser(CmdLineParser.Factory pf) {
163 27 : this.parserFactory = pf;
164 27 : }
165 :
166 : /**
167 : * Parses query parameters ({@code in}) into annotated option fields of {@code param}.
168 : *
169 : * @return true if parsing was successful. Requesting help is considered failure and returns
170 : * false.
171 : */
172 : <T> boolean parse(
173 : T param,
174 : DynamicOptions pluginOptions,
175 : ListMultimap<String, String> in,
176 : HttpServletRequest req,
177 : HttpServletResponse res)
178 : throws IOException {
179 27 : if (param.getClass().getAnnotation(Singleton.class) != null) {
180 : // Command-line parsing mutates the object, so we can't have options on @Singleton.
181 21 : return true;
182 : }
183 16 : CmdLineParser clp = parserFactory.create(param);
184 16 : pluginOptions.setBean(param);
185 16 : pluginOptions.startLifecycleListeners();
186 16 : pluginOptions.parseDynamicBeans(clp);
187 16 : pluginOptions.setDynamicBeans();
188 16 : pluginOptions.onBeanParseStart();
189 : try {
190 15 : clp.parseOptionMap(in);
191 4 : } catch (CmdLineException | NumberFormatException e) {
192 4 : if (!clp.wasHelpRequestedByOption()) {
193 4 : replyError(req, res, SC_BAD_REQUEST, e.getMessage(), e);
194 4 : return false;
195 : }
196 15 : }
197 :
198 15 : if (clp.wasHelpRequestedByOption()) {
199 0 : StringWriter msg = new StringWriter();
200 0 : clp.printQueryStringUsage(req.getRequestURI(), msg);
201 0 : msg.write('\n');
202 0 : msg.write('\n');
203 0 : clp.printUsage(msg, null);
204 0 : msg.write('\n');
205 0 : CacheHeaders.setNotCacheable(res);
206 0 : replyBinaryResult(req, res, BinaryResult.create(msg.toString()).setContentType("text/plain"));
207 0 : return false;
208 : }
209 15 : pluginOptions.onBeanParseEnd();
210 :
211 15 : return true;
212 : }
213 :
214 : private static Set<String> query(HttpServletRequest req) {
215 0 : Set<String> params = new HashSet<>();
216 0 : if (!Strings.isNullOrEmpty(req.getQueryString())) {
217 0 : for (String kvPair : Splitter.on('&').split(req.getQueryString())) {
218 0 : params.add(Iterables.getFirst(Splitter.on('=').limit(2).split(kvPair), null));
219 0 : }
220 : }
221 0 : return params;
222 : }
223 :
224 : /**
225 : * Convert a standard URL encoded form input into a parsed JSON tree.
226 : *
227 : * <p>Given an input such as:
228 : *
229 : * <pre>
230 : * message=Does+not+compile.&labels.Verified=-1
231 : * </pre>
232 : *
233 : * which is easily created using the curl command line tool:
234 : *
235 : * <pre>
236 : * curl --data 'message=Does not compile.' --data labels.Verified=-1
237 : * </pre>
238 : *
239 : * converts to a JSON object structure that is normally expected:
240 : *
241 : * <pre>
242 : * {
243 : * "message": "Does not compile.",
244 : * "labels": {
245 : * "Verified": "-1"
246 : * }
247 : * }
248 : * </pre>
249 : *
250 : * This input can then be further processed into the Java input type expected by a view using
251 : * Gson. Here we rely on Gson to perform implicit conversion of a string {@code "-1"} to a number
252 : * type when the Java input type expects a number.
253 : *
254 : * <p>Conversion assumes any field name that does not contain {@code "."} will be a property of
255 : * the top level input object. Any field with a dot will use the first segment as the top level
256 : * property name naming an object, and the rest of the field name as a property in the nested
257 : * object.
258 : *
259 : * @param req request to parse form input from and create JSON tree.
260 : * @return the converted JSON object tree.
261 : * @throws BadRequestException the request cannot be cast, as there are conflicting definitions
262 : * for a nested object.
263 : */
264 : static JsonObject formToJson(HttpServletRequest req) throws BadRequestException {
265 0 : Map<String, String[]> map = req.getParameterMap();
266 0 : return formToJson(map, query(req));
267 : }
268 :
269 : @VisibleForTesting
270 : static JsonObject formToJson(Map<String, String[]> map, Set<String> query)
271 : throws BadRequestException {
272 1 : JsonObject inputObject = new JsonObject();
273 1 : for (Map.Entry<String, String[]> ent : map.entrySet()) {
274 1 : String key = ent.getKey();
275 1 : String[] values = ent.getValue();
276 :
277 1 : if (query.contains(key) || values.length == 0) {
278 : // Disallow processing query parameters as input body fields.
279 : // Implementations of views should avoid duplicate naming.
280 0 : continue;
281 : }
282 :
283 1 : JsonObject obj = inputObject;
284 1 : int dot = key.indexOf('.');
285 1 : if (0 <= dot) {
286 1 : String property = key.substring(0, dot);
287 1 : JsonElement e = inputObject.get(property);
288 1 : if (e == null) {
289 1 : obj = new JsonObject();
290 1 : inputObject.add(property, obj);
291 1 : } else if (e.isJsonObject()) {
292 1 : obj = e.getAsJsonObject();
293 : } else {
294 0 : throw new BadRequestException(String.format("key %s conflicts with %s", key, property));
295 : }
296 1 : key = key.substring(dot + 1);
297 : }
298 :
299 1 : if (obj.get(key) != null) {
300 : // This error should never happen. If all form values are handled
301 : // together in a single pass properties are set only once. Setting
302 : // again indicates something has gone very wrong.
303 0 : throw new BadRequestException("invalid form input, use JSON instead");
304 1 : } else if (values.length == 1) {
305 1 : obj.addProperty(key, values[0]);
306 : } else {
307 1 : JsonArray list = new JsonArray();
308 1 : for (String v : values) {
309 1 : list.add(new JsonPrimitive(v));
310 : }
311 1 : obj.add(key, list);
312 : }
313 1 : }
314 1 : return inputObject;
315 : }
316 : }
|