001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.camel.util;
018
019import java.io.UnsupportedEncodingException;
020import java.net.URI;
021import java.net.URISyntaxException;
022import java.net.URLEncoder;
023import java.util.ArrayList;
024import java.util.Collection;
025import java.util.Iterator;
026import java.util.LinkedHashMap;
027import java.util.List;
028import java.util.Map;
029import java.util.regex.Pattern;
030
031import static org.apache.camel.util.CamelURIParser.URI_ALREADY_NORMALIZED;
032
033/**
034 * URI utilities.
035 */
036public final class URISupport {
037
038    public static final String RAW_TOKEN_PREFIX = "RAW";
039    public static final char[] RAW_TOKEN_START = { '(', '{' };
040    public static final char[] RAW_TOKEN_END = { ')', '}' };
041
042    // Match any key-value pair in the URI query string whose key contains
043    // "passphrase" or "password" or secret key (case-insensitive).
044    // First capture group is the key, second is the value.
045    private static final Pattern SECRETS = Pattern.compile(
046            "([?&][^=]*(?:passphrase|password|secretKey|accessToken|clientSecret|authorizationToken|saslJaasConfig)[^=]*)=(RAW(([{][^}]*[}])|([(][^)]*[)]))|[^&]*)",
047            Pattern.CASE_INSENSITIVE);
048
049    // Match the user password in the URI as second capture group
050    // (applies to URI with authority component and userinfo token in the form
051    // "user:password").
052    private static final Pattern USERINFO_PASSWORD = Pattern.compile("(.*://.*?:)(.*)(@)");
053
054    // Match the user password in the URI path as second capture group
055    // (applies to URI path with authority component and userinfo token in the
056    // form "user:password").
057    private static final Pattern PATH_USERINFO_PASSWORD = Pattern.compile("(.*?:)(.*)(@)");
058
059    private static final String CHARSET = "UTF-8";
060
061    private URISupport() {
062        // Helper class
063    }
064
065    /**
066     * Removes detected sensitive information (such as passwords) from the URI and returns the result.
067     *
068     * @param  uri The uri to sanitize.
069     * @see        #SECRETS and #USERINFO_PASSWORD for the matched pattern
070     * @return     Returns null if the uri is null, otherwise the URI with the passphrase, password or secretKey
071     *             sanitized.
072     */
073    public static String sanitizeUri(String uri) {
074        // use xxxxx as replacement as that works well with JMX also
075        String sanitized = uri;
076        if (uri != null) {
077            sanitized = SECRETS.matcher(sanitized).replaceAll("$1=xxxxxx");
078            sanitized = USERINFO_PASSWORD.matcher(sanitized).replaceFirst("$1xxxxxx$3");
079        }
080        return sanitized;
081    }
082
083    /**
084     * Removes detected sensitive information (such as passwords) from the <em>path part</em> of an URI (that is, the
085     * part without the query parameters or component prefix) and returns the result.
086     *
087     * @param  path the URI path to sanitize
088     * @return      null if the path is null, otherwise the sanitized path
089     */
090    public static String sanitizePath(String path) {
091        String sanitized = path;
092        if (path != null) {
093            sanitized = PATH_USERINFO_PASSWORD.matcher(sanitized).replaceFirst("$1xxxxxx$3");
094        }
095        return sanitized;
096    }
097
098    /**
099     * Extracts the scheme specific path from the URI that is used as the remainder option when creating endpoints.
100     *
101     * @param  u      the URI
102     * @param  useRaw whether to force using raw values
103     * @return        the remainder path
104     */
105    public static String extractRemainderPath(URI u, boolean useRaw) {
106        String path = useRaw ? u.getRawSchemeSpecificPart() : u.getSchemeSpecificPart();
107
108        // lets trim off any query arguments
109        if (path.startsWith("//")) {
110            path = path.substring(2);
111        }
112        int idx = path.indexOf('?');
113        if (idx > -1) {
114            path = path.substring(0, idx);
115        }
116
117        return path;
118    }
119
120    /**
121     * Extracts the query part of the given uri
122     *
123     * @param  uri the uri
124     * @return     the query parameters or <tt>null</tt> if the uri has no query
125     */
126    public static String extractQuery(String uri) {
127        if (uri == null) {
128            return null;
129        }
130        int pos = uri.indexOf('?');
131        if (pos != -1) {
132            return uri.substring(pos + 1);
133        } else {
134            return null;
135        }
136    }
137
138    /**
139     * Strips the query parameters from the uri
140     *
141     * @param  uri the uri
142     * @return     the uri without the query parameter
143     */
144    public static String stripQuery(String uri) {
145        int idx = uri.indexOf('?');
146        if (idx > -1) {
147            uri = uri.substring(0, idx);
148        }
149        return uri;
150    }
151
152    /**
153     * Parses the query part of the uri (eg the parameters).
154     * <p/>
155     * The URI parameters will by default be URI encoded. However you can define a parameter values with the syntax:
156     * <tt>key=RAW(value)</tt> which tells Camel to not encode the value, and use the value as is (eg key=value) and the
157     * value has <b>not</b> been encoded.
158     *
159     * @param  uri                the uri
160     * @return                    the parameters, or an empty map if no parameters (eg never null)
161     * @throws URISyntaxException is thrown if uri has invalid syntax.
162     * @see                       #RAW_TOKEN_PREFIX
163     * @see                       #RAW_TOKEN_START
164     * @see                       #RAW_TOKEN_END
165     */
166    public static Map<String, Object> parseQuery(String uri) throws URISyntaxException {
167        return parseQuery(uri, false);
168    }
169
170    /**
171     * Parses the query part of the uri (eg the parameters).
172     * <p/>
173     * The URI parameters will by default be URI encoded. However you can define a parameter values with the syntax:
174     * <tt>key=RAW(value)</tt> which tells Camel to not encode the value, and use the value as is (eg key=value) and the
175     * value has <b>not</b> been encoded.
176     *
177     * @param  uri                the uri
178     * @param  useRaw             whether to force using raw values
179     * @return                    the parameters, or an empty map if no parameters (eg never null)
180     * @throws URISyntaxException is thrown if uri has invalid syntax.
181     * @see                       #RAW_TOKEN_PREFIX
182     * @see                       #RAW_TOKEN_START
183     * @see                       #RAW_TOKEN_END
184     */
185    public static Map<String, Object> parseQuery(String uri, boolean useRaw) throws URISyntaxException {
186        return parseQuery(uri, useRaw, false);
187    }
188
189    /**
190     * Parses the query part of the uri (eg the parameters).
191     * <p/>
192     * The URI parameters will by default be URI encoded. However you can define a parameter values with the syntax:
193     * <tt>key=RAW(value)</tt> which tells Camel to not encode the value, and use the value as is (eg key=value) and the
194     * value has <b>not</b> been encoded.
195     *
196     * @param  uri                the uri
197     * @param  useRaw             whether to force using raw values
198     * @param  lenient            whether to parse lenient and ignore trailing & markers which has no key or value which
199     *                            can happen when using HTTP components
200     * @return                    the parameters, or an empty map if no parameters (eg never null)
201     * @throws URISyntaxException is thrown if uri has invalid syntax.
202     * @see                       #RAW_TOKEN_PREFIX
203     * @see                       #RAW_TOKEN_START
204     * @see                       #RAW_TOKEN_END
205     */
206    public static Map<String, Object> parseQuery(String uri, boolean useRaw, boolean lenient) throws URISyntaxException {
207        if (uri == null || uri.isEmpty()) {
208            // return an empty map
209            return new LinkedHashMap<>(0);
210        }
211
212        // must check for trailing & as the uri.split("&") will ignore those
213        if (!lenient && uri.endsWith("&")) {
214            throw new URISyntaxException(
215                    uri, "Invalid uri syntax: Trailing & marker found. " + "Check the uri and remove the trailing & marker.");
216        }
217
218        URIScanner scanner = new URIScanner();
219        return scanner.parseQuery(uri, useRaw);
220    }
221
222    /**
223     * Scans RAW tokens in the string and returns the list of pair indexes which tell where a RAW token starts and ends
224     * in the string.
225     * <p/>
226     * This is a companion method with {@link #isRaw(int, List)} and the returned value is supposed to be used as the
227     * parameter of that method.
228     *
229     * @param  str the string to scan RAW tokens
230     * @return     the list of pair indexes which represent the start and end positions of a RAW token
231     * @see        #isRaw(int, List)
232     * @see        #RAW_TOKEN_PREFIX
233     * @see        #RAW_TOKEN_START
234     * @see        #RAW_TOKEN_END
235     */
236    public static List<Pair<Integer>> scanRaw(String str) {
237        return URIScanner.scanRaw(str);
238    }
239
240    /**
241     * Tests if the index is within any pair of the start and end indexes which represent the start and end positions of
242     * a RAW token.
243     * <p/>
244     * This is a companion method with {@link #scanRaw(String)} and is supposed to consume the returned value of that
245     * method as the second parameter <tt>pairs</tt>.
246     *
247     * @param  index the index to be tested
248     * @param  pairs the list of pair indexes which represent the start and end positions of a RAW token
249     * @return       <tt>true</tt> if the index is within any pair of the indexes, <tt>false</tt> otherwise
250     * @see          #scanRaw(String)
251     * @see          #RAW_TOKEN_PREFIX
252     * @see          #RAW_TOKEN_START
253     * @see          #RAW_TOKEN_END
254     */
255    public static boolean isRaw(int index, List<Pair<Integer>> pairs) {
256        if (pairs == null || pairs.isEmpty()) {
257            return false;
258        }
259
260        for (Pair<Integer> pair : pairs) {
261            if (index < pair.getLeft()) {
262                return false;
263            }
264            if (index <= pair.getRight()) {
265                return true;
266            }
267        }
268        return false;
269    }
270
271    /**
272     * Parses the query parameters of the uri (eg the query part).
273     *
274     * @param  uri                the uri
275     * @return                    the parameters, or an empty map if no parameters (eg never null)
276     * @throws URISyntaxException is thrown if uri has invalid syntax.
277     */
278    public static Map<String, Object> parseParameters(URI uri) throws URISyntaxException {
279        String query = prepareQuery(uri);
280        if (query == null) {
281            // empty an empty map
282            return new LinkedHashMap<>(0);
283        }
284        return parseQuery(query);
285    }
286
287    public static String prepareQuery(URI uri) {
288        String query = uri.getQuery();
289        if (query == null) {
290            String schemeSpecificPart = uri.getSchemeSpecificPart();
291            int idx = schemeSpecificPart.indexOf('?');
292            if (idx < 0) {
293                return null;
294            } else {
295                query = schemeSpecificPart.substring(idx + 1);
296            }
297        } else if (query.indexOf('?') == 0) {
298            // skip leading query
299            query = query.substring(1);
300        }
301        return query;
302    }
303
304    /**
305     * Traverses the given parameters, and resolve any parameter values which uses the RAW token syntax:
306     * <tt>key=RAW(value)</tt>. This method will then remove the RAW tokens, and replace the content of the value, with
307     * just the value.
308     *
309     * @param parameters the uri parameters
310     * @see              #parseQuery(String)
311     * @see              #RAW_TOKEN_PREFIX
312     * @see              #RAW_TOKEN_START
313     * @see              #RAW_TOKEN_END
314     */
315    @SuppressWarnings("unchecked")
316    public static void resolveRawParameterValues(Map<String, Object> parameters) {
317        for (Map.Entry<String, Object> entry : parameters.entrySet()) {
318            if (entry.getValue() == null) {
319                continue;
320            }
321            // if the value is a list then we need to iterate
322            Object value = entry.getValue();
323            if (value instanceof List) {
324                List list = (List) value;
325                for (int i = 0; i < list.size(); i++) {
326                    Object obj = list.get(i);
327                    if (obj == null) {
328                        continue;
329                    }
330                    String str = obj.toString();
331                    String raw = URIScanner.resolveRaw(str);
332                    if (raw != null) {
333                        // update the string in the list
334                        list.set(i, raw);
335                    }
336                }
337            } else {
338                String str = entry.getValue().toString();
339                String raw = URIScanner.resolveRaw(str);
340                if (raw != null) {
341                    entry.setValue(raw);
342                }
343            }
344        }
345    }
346
347    /**
348     * Creates a URI with the given query
349     *
350     * @param  uri                the uri
351     * @param  query              the query to append to the uri
352     * @return                    uri with the query appended
353     * @throws URISyntaxException is thrown if uri has invalid syntax.
354     */
355    public static URI createURIWithQuery(URI uri, String query) throws URISyntaxException {
356        ObjectHelper.notNull(uri, "uri");
357
358        // assemble string as new uri and replace parameters with the query
359        // instead
360        String s = uri.toString();
361        String before = StringHelper.before(s, "?");
362        if (before == null) {
363            before = StringHelper.before(s, "#");
364        }
365        if (before != null) {
366            s = before;
367        }
368        if (query != null) {
369            s = s + "?" + query;
370        }
371        if (!s.contains("#") && uri.getFragment() != null) {
372            s = s + "#" + uri.getFragment();
373        }
374
375        return new URI(s);
376    }
377
378    /**
379     * Strips the prefix from the value.
380     * <p/>
381     * Returns the value as-is if not starting with the prefix.
382     *
383     * @param  value  the value
384     * @param  prefix the prefix to remove from value
385     * @return        the value without the prefix
386     */
387    public static String stripPrefix(String value, String prefix) {
388        if (value == null || prefix == null) {
389            return value;
390        }
391
392        if (value.startsWith(prefix)) {
393            return value.substring(prefix.length());
394        }
395
396        return value;
397    }
398
399    /**
400     * Strips the suffix from the value.
401     * <p/>
402     * Returns the value as-is if not ending with the prefix.
403     *
404     * @param  value  the value
405     * @param  suffix the suffix to remove from value
406     * @return        the value without the suffix
407     */
408    public static String stripSuffix(final String value, final String suffix) {
409        if (value == null || suffix == null) {
410            return value;
411        }
412
413        if (value.endsWith(suffix)) {
414            return value.substring(0, value.length() - suffix.length());
415        }
416
417        return value;
418    }
419
420    /**
421     * Assembles a query from the given map.
422     *
423     * @param  options            the map with the options (eg key/value pairs)
424     * @return                    a query string with <tt>key1=value&key2=value2&...</tt>, or an empty string if there
425     *                            is no options.
426     * @throws URISyntaxException is thrown if uri has invalid syntax.
427     */
428    @SuppressWarnings("unchecked")
429    public static String createQueryString(Map<String, Object> options) throws URISyntaxException {
430        return createQueryString(options.keySet(), options, true);
431    }
432
433    /**
434     * Assembles a query from the given map.
435     *
436     * @param  options            the map with the options (eg key/value pairs)
437     * @param  encode             whether to URL encode the query string
438     * @return                    a query string with <tt>key1=value&key2=value2&...</tt>, or an empty string if there
439     *                            is no options.
440     * @throws URISyntaxException is thrown if uri has invalid syntax.
441     */
442    @SuppressWarnings("unchecked")
443    public static String createQueryString(Map<String, Object> options, boolean encode) throws URISyntaxException {
444        return createQueryString(options.keySet(), options, encode);
445    }
446
447    public static String createQueryString(Collection<String> sortedKeys, Map<String, Object> options, boolean encode)
448            throws URISyntaxException {
449        try {
450            if (options.size() > 0) {
451                StringBuilder rc = new StringBuilder();
452                boolean first = true;
453                for (Object o : sortedKeys) {
454                    if (first) {
455                        first = false;
456                    } else {
457                        rc.append("&");
458                    }
459
460                    String key = (String) o;
461                    Object value = options.get(key);
462
463                    // the value may be a list since the same key has multiple
464                    // values
465                    if (value instanceof List) {
466                        List<String> list = (List<String>) value;
467                        for (Iterator<String> it = list.iterator(); it.hasNext();) {
468                            String s = it.next();
469                            appendQueryStringParameter(key, s, rc, encode);
470                            // append & separator if there is more in the list
471                            // to append
472                            if (it.hasNext()) {
473                                rc.append("&");
474                            }
475                        }
476                    } else {
477                        // use the value as a String
478                        String s = value != null ? value.toString() : null;
479                        appendQueryStringParameter(key, s, rc, encode);
480                    }
481                }
482                return rc.toString();
483            } else {
484                return "";
485            }
486        } catch (UnsupportedEncodingException e) {
487            URISyntaxException se = new URISyntaxException(e.toString(), "Invalid encoding");
488            se.initCause(e);
489            throw se;
490        }
491    }
492
493    private static void appendQueryStringParameter(String key, String value, StringBuilder rc, boolean encode)
494            throws UnsupportedEncodingException {
495        if (encode) {
496            String encoded = URLEncoder.encode(key, CHARSET);
497            rc.append(encoded);
498        } else {
499            rc.append(key);
500        }
501        if (value == null) {
502            return;
503        }
504        // only append if value is not null
505        rc.append("=");
506        String raw = URIScanner.resolveRaw(value);
507        if (raw != null) {
508            // do not encode RAW parameters unless it has %
509            // need to replace % with %25 to avoid losing "%" when decoding
510            String s = StringHelper.replaceAll(value, "%", "%25");
511            rc.append(s);
512        } else {
513            if (encode) {
514                String encoded = URLEncoder.encode(value, CHARSET);
515                rc.append(encoded);
516            } else {
517                rc.append(value);
518            }
519        }
520    }
521
522    /**
523     * Creates a URI from the original URI and the remaining parameters
524     * <p/>
525     * Used by various Camel components
526     */
527    public static URI createRemainingURI(URI originalURI, Map<String, Object> params) throws URISyntaxException {
528        String s = createQueryString(params);
529        if (s.length() == 0) {
530            s = null;
531        }
532        return createURIWithQuery(originalURI, s);
533    }
534
535    /**
536     * Appends the given parameters to the given URI.
537     * <p/>
538     * It keeps the original parameters and if a new parameter is already defined in {@code originalURI}, it will be
539     * replaced by its value in {@code newParameters}.
540     *
541     * @param  originalURI                  the original URI
542     * @param  newParameters                the parameters to add
543     * @return                              the URI with all the parameters
544     * @throws URISyntaxException           is thrown if the uri syntax is invalid
545     * @throws UnsupportedEncodingException is thrown if encoding error
546     */
547    public static String appendParametersToURI(String originalURI, Map<String, Object> newParameters)
548            throws URISyntaxException, UnsupportedEncodingException {
549        URI uri = new URI(normalizeUri(originalURI));
550        Map<String, Object> parameters = parseParameters(uri);
551        parameters.putAll(newParameters);
552        return createRemainingURI(uri, parameters).toString();
553    }
554
555    /**
556     * Normalizes the uri by reordering the parameters so they are sorted and thus we can use the uris for endpoint
557     * matching.
558     * <p/>
559     * The URI parameters will by default be URI encoded. However you can define a parameter values with the syntax:
560     * <tt>key=RAW(value)</tt> which tells Camel to not encode the value, and use the value as is (eg key=value) and the
561     * value has <b>not</b> been encoded.
562     *
563     * @param  uri                          the uri
564     * @return                              the normalized uri
565     * @throws URISyntaxException           in thrown if the uri syntax is invalid
566     * @throws UnsupportedEncodingException is thrown if encoding error
567     * @see                                 #RAW_TOKEN_PREFIX
568     * @see                                 #RAW_TOKEN_START
569     * @see                                 #RAW_TOKEN_END
570     */
571    public static String normalizeUri(String uri) throws URISyntaxException, UnsupportedEncodingException {
572        // try to parse using the simpler and faster Camel URI parser
573        String[] parts = CamelURIParser.fastParseUri(uri);
574        if (parts != null) {
575            // we optimized specially if an empty array is returned
576            if (parts == URI_ALREADY_NORMALIZED) {
577                return uri;
578            }
579            // use the faster and more simple normalizer
580            return doFastNormalizeUri(parts);
581        } else {
582            // use the legacy normalizer as the uri is complex and may have unsafe URL characters
583            return doComplexNormalizeUri(uri);
584        }
585    }
586
587    /**
588     * The complex (and Camel 2.x) compatible URI normalizer when the URI is more complex such as having percent encoded
589     * values, or other unsafe URL characters, or have authority user/password, etc.
590     */
591    private static String doComplexNormalizeUri(String uri) throws URISyntaxException {
592        URI u = new URI(UnsafeUriCharactersEncoder.encode(uri, true));
593        String scheme = u.getScheme();
594        String path = u.getSchemeSpecificPart();
595
596        // not possible to normalize
597        if (scheme == null || path == null) {
598            return uri;
599        }
600
601        // find start and end position in path as we only check the context-path and not the query parameters
602        int start = path.startsWith("//") ? 2 : 0;
603        int end = path.indexOf('?');
604        if (start == 0 && end == 0 || start == 2 && end == 2) {
605            // special when there is no context path
606            path = "";
607        } else {
608            if (start != 0 && end == -1) {
609                path = path.substring(start);
610            } else if (end != -1) {
611                path = path.substring(start, end);
612            }
613            if (scheme.startsWith("http")) {
614                path = UnsafeUriCharactersEncoder.encodeHttpURI(path);
615            } else {
616                path = UnsafeUriCharactersEncoder.encode(path);
617            }
618        }
619
620        // okay if we have user info in the path and they use @ in username or password,
621        // then we need to encode them (but leave the last @ sign before the hostname)
622        // this is needed as Camel end users may not encode their user info properly,
623        // but expect this to work out of the box with Camel, and hence we need to
624        // fix it for them
625        int idxPath = path.indexOf('/');
626        if (StringHelper.countChar(path, '@', idxPath) > 1) {
627            String userInfoPath = idxPath > 0 ? path.substring(0, idxPath) : path;
628            int max = userInfoPath.lastIndexOf('@');
629            String before = userInfoPath.substring(0, max);
630            // after must be from original path
631            String after = path.substring(max);
632
633            // replace the @ with %40
634            before = StringHelper.replaceAll(before, "@", "%40");
635            path = before + after;
636        }
637
638        // in case there are parameters we should reorder them
639        String query = prepareQuery(u);
640        if (query == null) {
641            // no parameters then just return
642            return buildUri(scheme, path, null);
643        } else {
644            Map<String, Object> parameters = URISupport.parseQuery(query, false, false);
645            if (parameters.size() == 1) {
646                // only 1 parameter need to create new query string
647                query = URISupport.createQueryString(parameters);
648                return buildUri(scheme, path, query);
649            } else {
650                // reorder parameters a..z
651                List<String> keys = new ArrayList<>(parameters.keySet());
652                keys.sort(null);
653
654                // build uri object with sorted parameters
655                query = URISupport.createQueryString(keys, parameters, true);
656                return buildUri(scheme, path, query);
657            }
658        }
659    }
660
661    /**
662     * The fast parser for normalizing Camel endpoint URIs when the URI is not complex and can be parsed in a much more
663     * efficient way.
664     */
665    private static String doFastNormalizeUri(String[] parts) throws URISyntaxException {
666        String scheme = parts[0];
667        String path = parts[1];
668        String query = parts[2];
669
670        // in case there are parameters we should reorder them
671        if (query == null) {
672            // no parameters then just return
673            return buildUri(scheme, path, null);
674        } else {
675            Map<String, Object> parameters = null;
676            if (query.indexOf('&') != -1) {
677                // only parse if there is parameters
678                parameters = URISupport.parseQuery(query, false, false);
679            }
680            if (parameters == null || parameters.size() == 1) {
681                return buildUri(scheme, path, query);
682            } else {
683                // reorder parameters a..z
684                // optimize and only build new query if the keys was resorted
685                boolean sort = false;
686                String prev = null;
687                for (String key : parameters.keySet()) {
688                    if (prev == null) {
689                        prev = key;
690                    } else {
691                        int comp = key.compareTo(prev);
692                        if (comp < 0) {
693                            sort = true;
694                            break;
695                        }
696                        prev = key;
697                    }
698                }
699                if (sort) {
700                    List<String> keys = new ArrayList<>(parameters.keySet());
701                    keys.sort(null);
702                    // rebuild query with sorted parameters
703                    query = URISupport.createQueryString(keys, parameters, true);
704                }
705
706                return buildUri(scheme, path, query);
707            }
708        }
709    }
710
711    private static String buildUri(String scheme, String path, String query) {
712        // must include :// to do a correct URI all components can work with
713        int len = scheme.length() + 3 + path.length();
714        if (query != null) {
715            len += 1 + query.length();
716            StringBuilder sb = new StringBuilder(len);
717            sb.append(scheme).append("://").append(path).append('?').append(query);
718            return sb.toString();
719        } else {
720            StringBuilder sb = new StringBuilder(len);
721            sb.append(scheme).append("://").append(path);
722            return sb.toString();
723        }
724    }
725
726    public static Map<String, Object> extractProperties(Map<String, Object> properties, String optionPrefix) {
727        Map<String, Object> rc = new LinkedHashMap<>(properties.size());
728
729        for (Iterator<Map.Entry<String, Object>> it = properties.entrySet().iterator(); it.hasNext();) {
730            Map.Entry<String, Object> entry = it.next();
731            String name = entry.getKey();
732            if (name.startsWith(optionPrefix)) {
733                Object value = properties.get(name);
734                name = name.substring(optionPrefix.length());
735                rc.put(name, value);
736                it.remove();
737            }
738        }
739
740        return rc;
741    }
742
743    public static String pathAndQueryOf(final URI uri) {
744        final String path = uri.getPath();
745
746        String pathAndQuery = path;
747        if (ObjectHelper.isEmpty(path)) {
748            pathAndQuery = "/";
749        }
750
751        final String query = uri.getQuery();
752        if (ObjectHelper.isNotEmpty(query)) {
753            pathAndQuery += "?" + query;
754        }
755
756        return pathAndQuery;
757    }
758
759    public static String joinPaths(final String... paths) {
760        if (paths == null || paths.length == 0) {
761            return "";
762        }
763
764        final StringBuilder joined = new StringBuilder();
765
766        boolean addedLast = false;
767        for (int i = paths.length - 1; i >= 0; i--) {
768            String path = paths[i];
769            if (ObjectHelper.isNotEmpty(path)) {
770                if (addedLast) {
771                    path = stripSuffix(path, "/");
772                }
773
774                addedLast = true;
775
776                if (path.charAt(0) == '/') {
777                    joined.insert(0, path);
778                } else {
779                    if (i > 0) {
780                        joined.insert(0, '/').insert(1, path);
781                    } else {
782                        joined.insert(0, path);
783                    }
784                }
785            }
786        }
787
788        return joined.toString();
789    }
790}