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