001/*
002 * Copyright (c) 2010-2025 Mark Allen, Norbert Bartels.
003 *
004 * Permission is hereby granted, free of charge, to any person obtaining a copy
005 * of this software and associated documentation files (the "Software"), to deal
006 * in the Software without restriction, including without limitation the rights
007 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
008 * copies of the Software, and to permit persons to whom the Software is
009 * furnished to do so, subject to the following conditions:
010 *
011 * The above copyright notice and this permission notice shall be included in
012 * all copies or substantial portions of the Software.
013 *
014 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
015 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
016 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
017 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
018 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
019 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
020 * THE SOFTWARE.
021 */
022package com.restfb.util;
023
024import static java.lang.String.format;
025import static java.net.URLDecoder.decode;
026import static java.net.URLEncoder.encode;
027import static java.util.Collections.emptyMap;
028import static java.util.stream.Collectors.toList;
029
030import java.io.UnsupportedEncodingException;
031import java.nio.charset.StandardCharsets;
032import java.util.Arrays;
033import java.util.HashMap;
034import java.util.List;
035import java.util.Map;
036import java.util.regex.Pattern;
037import java.util.stream.Collectors;
038
039/**
040 * @author <a href="http://restfb.com">Mark Allen</a>
041 * @since 1.6.10
042 */
043public final class UrlUtils {
044
045  /**
046   * Prevents instantiation.
047   */
048  private UrlUtils() {
049    throw new IllegalStateException("UrlUtils must not be instantiated");
050  }
051
052  /**
053   * URL-encodes a string.
054   * <p>
055   * Assumes {@code string} is in {@link StandardCharsets#UTF_8} format.
056   * 
057   * @param string
058   *          The string to URL-encode.
059   * @return The URL-encoded version of the input string, or {@code null} if {@code string} is {@code null}.
060   * @throws IllegalStateException
061   *           If unable to URL-encode because the JVM doesn't support {@link StandardCharsets#UTF_8}.
062   */
063  public static String urlEncode(String string) {
064    if (string == null) {
065      return null;
066    }
067    try {
068      return encode(string, StandardCharsets.UTF_8.name());
069    } catch (UnsupportedEncodingException e) {
070      throw new IllegalStateException("Platform doesn't support " + StandardCharsets.UTF_8.name(), e);
071    }
072  }
073
074  /**
075   * URL-decodes a string.
076   * <p>
077   * Assumes {@code string} is in {@link StandardCharsets#UTF_8} format.
078   * 
079   * @param string
080   *          The string to URL-decode.
081   * @return The URL-decoded version of the input string, or {@code null} if {@code string} is {@code null}.
082   * @throws IllegalStateException
083   *           If unable to URL-decode because the JVM doesn't support {@link StandardCharsets#UTF_8}.
084   * @since 1.6.5
085   */
086  public static String urlDecode(String string) {
087    if (string == null) {
088      return null;
089    }
090    try {
091      return decode(string, StandardCharsets.UTF_8.name());
092    } catch (UnsupportedEncodingException e) {
093      throw new IllegalStateException("Platform doesn't support " + StandardCharsets.UTF_8.name(), e);
094    }
095  }
096
097  /**
098   * For the given {@code queryString}, extract a mapping of query string parameter names to values.
099   * <p>
100   * Example of a {@code queryString} is {@code accessToken=123&expires=345}.
101   * 
102   * @param queryString
103   *          The URL query string from which parameters are extracted.
104   * @return A mapping of query string parameter names to values. If {@code queryString} is {@code null}, an empty
105   *         {@code Map} is returned.
106   * @throws IllegalStateException
107   *           If unable to URL-decode because the JVM doesn't support {@link StandardCharsets#UTF_8}.
108   */
109  public static Map<String, List<String>> extractParametersFromQueryString(String queryString) {
110    if (queryString == null) {
111      return emptyMap();
112    }
113
114    // If there is no ? character at the front of the string, append it.
115    return extractParametersFromUrl(
116      format("restfb://url%s", queryString.startsWith("?") ? queryString : "?" + queryString));
117  }
118
119  /**
120   * For the given {@code url}, extract a mapping of query string parameter names to values.
121   * <p>
122   * Adapted from an implementation by BalusC and dfrankow, available at
123   * <a href="http://stackoverflow.com/questions/1667278/parsing-query-strings-in-java">
124   * http://stackoverflow.com/questions/1667278/parsing-query-strings-in-java</a>.
125   *
126   * @param url
127   *          The URL from which parameters are extracted.
128   * @return A mapping of query string parameter names to values. If {@code url} is {@code null}, an empty {@code Map}
129   *         is returned.
130   * @throws IllegalStateException
131   *           If unable to URL-decode because the JVM doesn't support {@link StandardCharsets#UTF_8}.
132   */
133  public static Map<String, List<String>> extractParametersFromUrl(String url) {
134    if (url == null) {
135      return emptyMap();
136    }
137
138    Map<String, List<String>> parameters = new HashMap<>();
139    String[] urlParts = url.split("\\?");
140
141    if (urlParts.length > 1) {
142      String query = urlParts[1];
143      parameters = Pattern.compile("&").splitAsStream(query) //
144        .map(s -> Arrays.copyOf(s.split("="), 2))
145        .collect(Collectors.groupingBy(s -> urlDecode(s[0]), Collectors.mapping(s -> urlDecode(s[1]), toList())));
146    }
147
148    return parameters;
149  }
150
151  /**
152   * Modify the query string in the given {@code url} and return the new url as String.
153   * <p>
154   * The given key/value pair is added to the url. If the key is already present, it is replaced with the new value.
155   *
156   * @param url
157   *          The URL which parameters should be modified.
158   * @param key
159   *          the key, that should be modified or added
160   * @param value
161   *          the value of the key/value pair
162   * @return the modified URL as String
163   */
164  public static String replaceOrAddQueryParameter(String url, String key, String value) {
165    String[] urlParts = url.split("\\?");
166    String qParameter = key + "=" + value;
167
168    if (urlParts.length == 2) {
169      Map<String, List<String>> paramMap = extractParametersFromQueryString(urlParts[1]);
170      if (paramMap.containsKey(key)) {
171        String queryValue = paramMap.get(key).get(0);
172        return url.replace(key + "=" + queryValue, qParameter);
173      } else {
174        return url + "&" + qParameter;
175      }
176
177    } else {
178      return url + "?" + qParameter;
179    }
180  }
181
182  /**
183   * Remove the given key from the url query string and return the new URL as String.
184   *
185   * @param url
186   *          The URL from which parameters are extracted.
187   * @param key
188   *          the key, that should be removed
189   * @return the modified URL as String
190   */
191  public static String removeQueryParameter(String url, String key) {
192    String[] urlParts = url.split("\\?");
193
194    if (urlParts.length == 2) {
195      Map<String, List<String>> paramMap = extractParametersFromQueryString(urlParts[1]);
196      if (paramMap.containsKey(key)) {
197        String queryValue = paramMap.get(key).get(0);
198        String result = url.replace(key + "=" + queryValue, "");
199        // improper separators have to be fixed
200        // @TODO find a better way to solve this
201        result = result.replace("?&", "?").replace("&&", "&");
202        if (result.endsWith("&")) {
203          return result.substring(0, result.length() - 1);
204        } else {
205          return result;
206        }
207      }
208    }
209    return url;
210  }
211}