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 ALL_SECRETS = Pattern.compile(
046            "([?&][^=]*(?:" + SensitiveUtils.getSensitivePattern() + ")[^=]*)=(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     * @return     Returns null if the uri is null, otherwise the URI with the passphrase, password or secretKey
070     *             sanitized.
071     * @see        #ALL_SECRETS and #USERINFO_PASSWORD for the matched pattern
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 = ALL_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                        // do not encode RAW parameters unless it has %
335                        // need to reverse: replace % with %25 to avoid losing "%" when decoding
336                        String s = raw.replace("%25", "%");
337                        list.set(i, s);
338                    }
339                }
340            } else {
341                String str = entry.getValue().toString();
342                String raw = URIScanner.resolveRaw(str);
343                if (raw != null) {
344                    // do not encode RAW parameters unless it has %
345                    // need to reverse: replace % with %25 to avoid losing "%" when decoding
346                    String s = raw.replace("%25", "%");
347                    entry.setValue(s);
348                }
349            }
350        }
351    }
352
353    /**
354     * Creates a URI with the given query
355     *
356     * @param  uri                the uri
357     * @param  query              the query to append to the uri
358     * @return                    uri with the query appended
359     * @throws URISyntaxException is thrown if uri has invalid syntax.
360     */
361    public static URI createURIWithQuery(URI uri, String query) throws URISyntaxException {
362        ObjectHelper.notNull(uri, "uri");
363
364        // assemble string as new uri and replace parameters with the query
365        // instead
366        String s = uri.toString();
367        String before = StringHelper.before(s, "?");
368        if (before == null) {
369            before = StringHelper.before(s, "#");
370        }
371        if (before != null) {
372            s = before;
373        }
374        if (query != null) {
375            s = s + "?" + query;
376        }
377        if (!s.contains("#") && uri.getFragment() != null) {
378            s = s + "#" + uri.getFragment();
379        }
380
381        return new URI(s);
382    }
383
384    /**
385     * Strips the prefix from the value.
386     * <p/>
387     * Returns the value as-is if not starting with the prefix.
388     *
389     * @param  value  the value
390     * @param  prefix the prefix to remove from value
391     * @return        the value without the prefix
392     */
393    public static String stripPrefix(String value, String prefix) {
394        if (value == null || prefix == null) {
395            return value;
396        }
397
398        if (value.startsWith(prefix)) {
399            return value.substring(prefix.length());
400        }
401
402        return value;
403    }
404
405    /**
406     * Strips the suffix from the value.
407     * <p/>
408     * Returns the value as-is if not ending with the prefix.
409     *
410     * @param  value  the value
411     * @param  suffix the suffix to remove from value
412     * @return        the value without the suffix
413     */
414    public static String stripSuffix(final String value, final String suffix) {
415        if (value == null || suffix == null) {
416            return value;
417        }
418
419        if (value.endsWith(suffix)) {
420            return value.substring(0, value.length() - suffix.length());
421        }
422
423        return value;
424    }
425
426    /**
427     * Assembles a query from the given map.
428     *
429     * @param  options            the map with the options (eg key/value pairs)
430     * @return                    a query string with <tt>key1=value&key2=value2&...</tt>, or an empty string if there
431     *                            is no options.
432     * @throws URISyntaxException is thrown if uri has invalid syntax.
433     */
434    @SuppressWarnings("unchecked")
435    public static String createQueryString(Map<String, Object> options) throws URISyntaxException {
436        return createQueryString(options.keySet(), options, true);
437    }
438
439    /**
440     * Assembles a query from the given map.
441     *
442     * @param  options            the map with the options (eg key/value pairs)
443     * @param  encode             whether to URL encode the query string
444     * @return                    a query string with <tt>key1=value&key2=value2&...</tt>, or an empty string if there
445     *                            is no options.
446     * @throws URISyntaxException is thrown if uri has invalid syntax.
447     */
448    @SuppressWarnings("unchecked")
449    public static String createQueryString(Map<String, Object> options, boolean encode) throws URISyntaxException {
450        return createQueryString(options.keySet(), options, encode);
451    }
452
453    public static String createQueryString(Collection<String> sortedKeys, Map<String, Object> options, boolean encode)
454            throws URISyntaxException {
455        try {
456            if (options.size() > 0) {
457                StringBuilder rc = new StringBuilder();
458                boolean first = true;
459                for (Object o : sortedKeys) {
460                    if (first) {
461                        first = false;
462                    } else {
463                        rc.append("&");
464                    }
465
466                    String key = (String) o;
467                    Object value = options.get(key);
468
469                    // the value may be a list since the same key has multiple
470                    // values
471                    if (value instanceof List) {
472                        List<String> list = (List<String>) value;
473                        for (Iterator<String> it = list.iterator(); it.hasNext();) {
474                            String s = it.next();
475                            appendQueryStringParameter(key, s, rc, encode);
476                            // append & separator if there is more in the list
477                            // to append
478                            if (it.hasNext()) {
479                                rc.append("&");
480                            }
481                        }
482                    } else {
483                        // use the value as a String
484                        String s = value != null ? value.toString() : null;
485                        appendQueryStringParameter(key, s, rc, encode);
486                    }
487                }
488                return rc.toString();
489            } else {
490                return "";
491            }
492        } catch (UnsupportedEncodingException e) {
493            URISyntaxException se = new URISyntaxException(e.toString(), "Invalid encoding");
494            se.initCause(e);
495            throw se;
496        }
497    }
498
499    private static void appendQueryStringParameter(String key, String value, StringBuilder rc, boolean encode)
500            throws UnsupportedEncodingException {
501        if (encode) {
502            String encoded = URLEncoder.encode(key, CHARSET);
503            rc.append(encoded);
504        } else {
505            rc.append(key);
506        }
507        if (value == null) {
508            return;
509        }
510        // only append if value is not null
511        rc.append("=");
512        String raw = URIScanner.resolveRaw(value);
513        if (raw != null) {
514            // do not encode RAW parameters unless it has %
515            // need to replace % with %25 to avoid losing "%" when decoding
516            String s = value.replace("%", "%25");
517            rc.append(s);
518        } else {
519            if (encode) {
520                String encoded = URLEncoder.encode(value, CHARSET);
521                rc.append(encoded);
522            } else {
523                rc.append(value);
524            }
525        }
526    }
527
528    /**
529     * Creates a URI from the original URI and the remaining parameters
530     * <p/>
531     * Used by various Camel components
532     */
533    public static URI createRemainingURI(URI originalURI, Map<String, Object> params) throws URISyntaxException {
534        String s = createQueryString(params);
535        if (s.length() == 0) {
536            s = null;
537        }
538        return createURIWithQuery(originalURI, s);
539    }
540
541    /**
542     * Appends the given parameters to the given URI.
543     * <p/>
544     * It keeps the original parameters and if a new parameter is already defined in {@code originalURI}, it will be
545     * replaced by its value in {@code newParameters}.
546     *
547     * @param  originalURI                  the original URI
548     * @param  newParameters                the parameters to add
549     * @return                              the URI with all the parameters
550     * @throws URISyntaxException           is thrown if the uri syntax is invalid
551     * @throws UnsupportedEncodingException is thrown if encoding error
552     */
553    public static String appendParametersToURI(String originalURI, Map<String, Object> newParameters)
554            throws URISyntaxException, UnsupportedEncodingException {
555        URI uri = new URI(normalizeUri(originalURI));
556        Map<String, Object> parameters = parseParameters(uri);
557        parameters.putAll(newParameters);
558        return createRemainingURI(uri, parameters).toString();
559    }
560
561    /**
562     * Normalizes the uri by reordering the parameters so they are sorted and thus we can use the uris for endpoint
563     * matching.
564     * <p/>
565     * The URI parameters will by default be URI encoded. However you can define a parameter values with the syntax:
566     * <tt>key=RAW(value)</tt> which tells Camel to not encode the value, and use the value as is (eg key=value) and the
567     * value has <b>not</b> been encoded.
568     *
569     * @param  uri                          the uri
570     * @return                              the normalized uri
571     * @throws URISyntaxException           in thrown if the uri syntax is invalid
572     * @throws UnsupportedEncodingException is thrown if encoding error
573     * @see                                 #RAW_TOKEN_PREFIX
574     * @see                                 #RAW_TOKEN_START
575     * @see                                 #RAW_TOKEN_END
576     */
577    public static String normalizeUri(String uri) throws URISyntaxException, UnsupportedEncodingException {
578        // try to parse using the simpler and faster Camel URI parser
579        String[] parts = CamelURIParser.fastParseUri(uri);
580        if (parts != null) {
581            // we optimized specially if an empty array is returned
582            if (parts == URI_ALREADY_NORMALIZED) {
583                return uri;
584            }
585            // use the faster and more simple normalizer
586            return doFastNormalizeUri(parts);
587        } else {
588            // use the legacy normalizer as the uri is complex and may have unsafe URL characters
589            return doComplexNormalizeUri(uri);
590        }
591    }
592
593    /**
594     * The complex (and Camel 2.x) compatible URI normalizer when the URI is more complex such as having percent encoded
595     * values, or other unsafe URL characters, or have authority user/password, etc.
596     */
597    private static String doComplexNormalizeUri(String uri) throws URISyntaxException {
598        URI u = new URI(UnsafeUriCharactersEncoder.encode(uri, true));
599        String scheme = u.getScheme();
600        String path = u.getSchemeSpecificPart();
601
602        // not possible to normalize
603        if (scheme == null || path == null) {
604            return uri;
605        }
606
607        // find start and end position in path as we only check the context-path and not the query parameters
608        int start = path.startsWith("//") ? 2 : 0;
609        int end = path.indexOf('?');
610        if (start == 0 && end == 0 || start == 2 && end == 2) {
611            // special when there is no context path
612            path = "";
613        } else {
614            if (start != 0 && end == -1) {
615                path = path.substring(start);
616            } else if (end != -1) {
617                path = path.substring(start, end);
618            }
619            if (scheme.startsWith("http")) {
620                path = UnsafeUriCharactersEncoder.encodeHttpURI(path);
621            } else {
622                path = UnsafeUriCharactersEncoder.encode(path);
623            }
624        }
625
626        // okay if we have user info in the path and they use @ in username or password,
627        // then we need to encode them (but leave the last @ sign before the hostname)
628        // this is needed as Camel end users may not encode their user info properly,
629        // but expect this to work out of the box with Camel, and hence we need to
630        // fix it for them
631        int idxPath = path.indexOf('/');
632        if (StringHelper.countChar(path, '@', idxPath) > 1) {
633            String userInfoPath = idxPath > 0 ? path.substring(0, idxPath) : path;
634            int max = userInfoPath.lastIndexOf('@');
635            String before = userInfoPath.substring(0, max);
636            // after must be from original path
637            String after = path.substring(max);
638
639            // replace the @ with %40
640            before = before.replace("@", "%40");
641            path = before + after;
642        }
643
644        // in case there are parameters we should reorder them
645        String query = prepareQuery(u);
646        if (query == null) {
647            // no parameters then just return
648            return buildUri(scheme, path, null);
649        } else {
650            Map<String, Object> parameters = URISupport.parseQuery(query, false, false);
651            if (parameters.size() == 1) {
652                // only 1 parameter need to create new query string
653                query = URISupport.createQueryString(parameters);
654                return buildUri(scheme, path, query);
655            } else {
656                // reorder parameters a..z
657                List<String> keys = new ArrayList<>(parameters.keySet());
658                keys.sort(null);
659
660                // build uri object with sorted parameters
661                query = URISupport.createQueryString(keys, parameters, true);
662                return buildUri(scheme, path, query);
663            }
664        }
665    }
666
667    /**
668     * The fast parser for normalizing Camel endpoint URIs when the URI is not complex and can be parsed in a much more
669     * efficient way.
670     */
671    private static String doFastNormalizeUri(String[] parts) throws URISyntaxException {
672        String scheme = parts[0];
673        String path = parts[1];
674        String query = parts[2];
675
676        // in case there are parameters we should reorder them
677        if (query == null) {
678            // no parameters then just return
679            return buildUri(scheme, path, null);
680        } else {
681            Map<String, Object> parameters = null;
682            if (query.indexOf('&') != -1) {
683                // only parse if there is parameters
684                parameters = URISupport.parseQuery(query, false, false);
685            }
686            if (parameters == null || parameters.size() == 1) {
687                return buildUri(scheme, path, query);
688            } else {
689                // reorder parameters a..z
690                // optimize and only build new query if the keys was resorted
691                boolean sort = false;
692                String prev = null;
693                for (String key : parameters.keySet()) {
694                    if (prev == null) {
695                        prev = key;
696                    } else {
697                        int comp = key.compareTo(prev);
698                        if (comp < 0) {
699                            sort = true;
700                            break;
701                        }
702                        prev = key;
703                    }
704                }
705                if (sort) {
706                    List<String> keys = new ArrayList<>(parameters.keySet());
707                    keys.sort(null);
708                    // rebuild query with sorted parameters
709                    query = URISupport.createQueryString(keys, parameters, true);
710                }
711
712                return buildUri(scheme, path, query);
713            }
714        }
715    }
716
717    private static String buildUri(String scheme, String path, String query) {
718        // must include :// to do a correct URI all components can work with
719        int len = scheme.length() + 3 + path.length();
720        if (query != null) {
721            len += 1 + query.length();
722            StringBuilder sb = new StringBuilder(len);
723            sb.append(scheme).append("://").append(path).append('?').append(query);
724            return sb.toString();
725        } else {
726            StringBuilder sb = new StringBuilder(len);
727            sb.append(scheme).append("://").append(path);
728            return sb.toString();
729        }
730    }
731
732    public static Map<String, Object> extractProperties(Map<String, Object> properties, String optionPrefix) {
733        Map<String, Object> rc = new LinkedHashMap<>(properties.size());
734
735        for (Iterator<Map.Entry<String, Object>> it = properties.entrySet().iterator(); it.hasNext();) {
736            Map.Entry<String, Object> entry = it.next();
737            String name = entry.getKey();
738            if (name.startsWith(optionPrefix)) {
739                Object value = properties.get(name);
740                name = name.substring(optionPrefix.length());
741                rc.put(name, value);
742                it.remove();
743            }
744        }
745
746        return rc;
747    }
748
749    public static String pathAndQueryOf(final URI uri) {
750        final String path = uri.getPath();
751
752        String pathAndQuery = path;
753        if (ObjectHelper.isEmpty(path)) {
754            pathAndQuery = "/";
755        }
756
757        final String query = uri.getQuery();
758        if (ObjectHelper.isNotEmpty(query)) {
759            pathAndQuery += "?" + query;
760        }
761
762        return pathAndQuery;
763    }
764
765    public static String joinPaths(final String... paths) {
766        if (paths == null || paths.length == 0) {
767            return "";
768        }
769
770        final StringBuilder joined = new StringBuilder();
771
772        boolean addedLast = false;
773        for (int i = paths.length - 1; i >= 0; i--) {
774            String path = paths[i];
775            if (ObjectHelper.isNotEmpty(path)) {
776                if (addedLast) {
777                    path = stripSuffix(path, "/");
778                }
779
780                addedLast = true;
781
782                if (path.charAt(0) == '/') {
783                    joined.insert(0, path);
784                } else {
785                    if (i > 0) {
786                        joined.insert(0, '/').insert(1, path);
787                    } else {
788                        joined.insert(0, path);
789                    }
790                }
791            }
792        }
793
794        return joined.toString();
795    }
796
797}