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