001/*
002 * Copyright 2023 the original author or authors.
003 * <p>
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 * <p>
008 * https://www.apache.org/licenses/LICENSE-2.0
009 * <p>
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package de.cuioss.tools.net;
017
018import static de.cuioss.tools.collect.CollectionLiterals.immutableList;
019import static de.cuioss.tools.string.MoreStrings.requireNotEmptyTrimmed;
020import static java.net.URLEncoder.encode;
021import static java.util.Objects.requireNonNull;
022
023import java.io.Serializable;
024import java.net.URLDecoder;
025import java.net.URLEncoder;
026import java.nio.charset.StandardCharsets;
027import java.util.ArrayList;
028import java.util.Collections;
029import java.util.Comparator;
030import java.util.HashMap;
031import java.util.List;
032import java.util.Map;
033import java.util.Map.Entry;
034
035import de.cuioss.tools.collect.CollectionBuilder;
036import de.cuioss.tools.collect.MoreCollections;
037import de.cuioss.tools.logging.CuiLogger;
038import de.cuioss.tools.string.Joiner;
039import de.cuioss.tools.string.MoreStrings;
040import de.cuioss.tools.string.Splitter;
041import lombok.EqualsAndHashCode;
042import lombok.Getter;
043import lombok.ToString;
044
045/**
046 * Simple wrapper around an Url Parameter Object.
047 * <p>
048 * Depending on the constructor arguments the attributes #getName() and
049 * #getValue() are implicitly encoded properly using
050 * {@link URLEncoder#encode(String, String)}. This is helpful for reliable
051 * handling of special characters.
052 * </p>
053 *
054 * @author Oliver Wolff
055 */
056@EqualsAndHashCode
057@ToString
058public class UrlParameter implements Serializable, Comparable<UrlParameter> {
059
060    private static final CuiLogger log = new CuiLogger(UrlParameter.class);
061
062    /** Shortcut constant for faces redirect parameter. */
063    public static final UrlParameter FACES_REDIRECT = new UrlParameter("faces-redirect", "true");
064
065    /** Shortcut constant parameter for includeViewParams. */
066    public static final UrlParameter INCLUDE_VIEW_PARAMETER = new UrlParameter("includeViewParams", "true");
067
068    private static final long serialVersionUID = 634175928228707534L;
069
070    /** The name of the parameter. */
071    @Getter
072    private final String name;
073
074    /** The value of the parameter. */
075    @Getter
076    private final String value;
077
078    /**
079     * Constructor. Name and value are implicitly encoded using UTF-8.
080     *
081     * @param name  must not be null or empty
082     * @param value may be null.
083     */
084    public UrlParameter(final String name, final String value) {
085        this(name, value, true);
086    }
087
088    /**
089     * Constructor.
090     *
091     * @param name   must not be null or empty
092     * @param value  value may be null.
093     * @param encode indicates whether to encode the parameter name and value as
094     *               UTF-8
095     */
096    public UrlParameter(final String name, final String value, final boolean encode) {
097        requireNotEmptyTrimmed(name, "Parameter name must not be empty");
098        if (encode) {
099            this.name = encode(name, StandardCharsets.UTF_8);
100            if (MoreStrings.isEmpty(value)) {
101                this.value = null;
102            } else {
103                this.value = encode(value, StandardCharsets.UTF_8);
104            }
105        } else {
106            this.name = name;
107            this.value = value;
108        }
109    }
110
111    /**
112     * Returns a boolean indicating whether the {@link UrlParameter} is empty,
113     * saying has a null value
114     *
115     * @return boolean flag whether the {@link UrlParameter} is empty
116     */
117    public boolean isEmpty() {
118        return null == value;
119    }
120
121    /**
122     * Creates a parameter String for a given number of {@link UrlParameter}.
123     *
124     * @param parameters to be appended, must not be null
125     * @return the concatenated ParameterString in the form
126     *         "?parameter1Name=parameter1Value&amp;parameter2Name=parameter2Value"
127     */
128    public static String createParameterString(final UrlParameter... parameters) {
129        return createParameterString(false, parameters);
130    }
131
132    /**
133     * Create a String-representation of the URL-Parameter
134     *
135     * @param encode
136     * @param parameters
137     * @return the created parameter String
138     */
139    public static String createParameterString(final boolean encode, final UrlParameter... parameters) {
140        final var builder = new StringBuilder();
141        // First parameter to be treated specially.
142        if (null != parameters && parameters.length > 0 && null != parameters[0]) {
143            builder.append('?').append(parameters[0].createNameValueString(encode));
144            if (parameters.length > 1) {
145                // The other parameters are appended with '&'
146                for (var i = 1; i < parameters.length; i++) {
147                    builder.append('&').append(parameters[i].createNameValueString(encode));
148                }
149            }
150        }
151        return builder.toString();
152    }
153
154    /**
155     * Convert a map of raw Url-parameter into a list of {@link UrlParameter}
156     *
157     * @param map             containing the parameter extracted usually directly
158     *                        from servlet request. From the String[] solely the
159     *                        first element will be extracted. The others will be
160     *                        ignored.
161     * @param parameterFilter defines the parameter to be filtered. May be null or
162     *                        empty.
163     * @param encode          indicates whether to encode the parameter name and
164     *                        value as UTF-8
165     * @return the found List of {@link UrlParameter} or empty list if the given map
166     *         is null or empty. The List is always sorted by #getName()
167     */
168    @SuppressWarnings("squid:S1166") // now need to throw exception
169    public static final List<UrlParameter> getUrlParameterFromMap(final Map<String, List<String>> map,
170            final ParameterFilter parameterFilter, final boolean encode) {
171        if (MoreCollections.isEmpty(map)) {
172            return Collections.emptyList();
173        }
174        final List<UrlParameter> extracted = new ArrayList<>();
175        for (final Entry<String, List<String>> entry : map.entrySet()) {
176            String value = null;
177            if (!MoreCollections.isEmpty(entry.getValue())) {
178                value = entry.getValue().get(0);
179            }
180            final var key = entry.getKey();
181            if (null == parameterFilter || !parameterFilter.isExcluded(key)) {
182                try {
183                    extracted.add(new UrlParameter(key, value, encode));
184                } catch (final IllegalArgumentException e) {
185                    log.debug("Unable to read url parameter due to missing parameter name", e.getMessage());
186                }
187            }
188        }
189        extracted.sort(Comparator.comparing(UrlParameter::getName));
190        return extracted;
191    }
192
193    /**
194     * Filters the given list of {@link UrlParameter}
195     *
196     * @param toBeFiltered    may be null or empty
197     * @param parameterFilter used for filtering, may be null
198     * @return the filtered parameter list or empty List if toBeFiltered is null or
199     *         empty.
200     */
201    public static List<UrlParameter> filterParameter(final List<UrlParameter> toBeFiltered,
202            final ParameterFilter parameterFilter) {
203        if (toBeFiltered == null || toBeFiltered.isEmpty()) {
204            return Collections.emptyList();
205        }
206        final var filtered = new ArrayList<UrlParameter>();
207        for (final UrlParameter parameter : toBeFiltered) {
208            final var key = parameter.getName();
209            if (null == parameterFilter || !parameterFilter.isExcluded(key)) {
210                filtered.add(parameter);
211            }
212        }
213
214        return filtered;
215    }
216
217    /**
218     * Create a parameterMap for a given list of {@link UrlParameter}
219     *
220     * @param urlParameters may be null or empty
221     * @return parameter Map, may be empty if urlParameters is empty. The list of
222     *         String will solely contain one element.
223     */
224    public static final Map<String, List<String>> createParameterMap(final List<UrlParameter> urlParameters) {
225        final Map<String, List<String>> result = new HashMap<>();
226        if (null != urlParameters && !urlParameters.isEmpty()) {
227            for (final UrlParameter urlParameter : urlParameters) {
228                result.put(urlParameter.getName(), immutableList(urlParameter.getValue()));
229            }
230        }
231        return result;
232    }
233
234    /**
235     * Helper class that create a list of {@link UrlParameter} from a given
236     * query-String
237     *
238     * @param queryString if it is null or empty or solely consists of an "?" an
239     *                    empty {@link List}
240     * @return if queryString is null or empty or solely consists of an "?" an empty
241     *         {@link List} will be returned. An immutable {@link List} of
242     *         {@link UrlParameter} otherwise
243     */
244    public static List<UrlParameter> fromQueryString(String queryString) {
245        log.trace("Parsing Query String %s", queryString);
246        if (MoreStrings.isEmpty(queryString)) {
247            return Collections.emptyList();
248        }
249        var cleaned = queryString.trim();
250        if (cleaned.startsWith("?")) {
251            cleaned = cleaned.substring(1);
252        }
253        if (MoreStrings.isEmpty(cleaned)) {
254            log.debug("Given String solely consists of '?' symbol, ignoring");
255            return Collections.emptyList();
256        }
257        var elements = Splitter.on("&").omitEmptyStrings().splitToList(cleaned);
258        var builder = new CollectionBuilder<UrlParameter>();
259        for (String element : elements) {
260            if (element.contains("=")) {
261                var splitted = Splitter.on("=").omitEmptyStrings().splitToList(element);
262                switch (splitted.size()) {
263                case 0:
264                    log.debug(
265                            "Unable to parse queryString '%s' correctly, unable to extract key-value-pair for element '%s'",
266                            queryString, element);
267                    break;
268                case 1:
269                    builder.add(createDecoded(splitted.get(0), null));
270                    break;
271                case 2:
272                    builder.add(createDecoded(splitted.get(0), splitted.get(1)));
273                    break;
274                default:
275                    log.debug(
276                            "Unable to parse queryString '%s' correctly, multiple '=' symbols found at unexpected locations",
277                            queryString);
278                    break;
279                }
280            } else {
281                builder.add(createDecoded(element, null));
282            }
283        }
284        return builder.toImmutableList();
285    }
286
287    private static UrlParameter createDecoded(final String name, final String value) {
288        requireNonNull(name);
289        String decodedKey;
290        decodedKey = URLDecoder.decode(name, StandardCharsets.UTF_8);
291
292        String decodedValue = null;
293        if (null != value) {
294            decodedValue = URLDecoder.decode(value, StandardCharsets.UTF_8);
295        }
296
297        return new UrlParameter(decodedKey, decodedValue, false);
298    }
299
300    /**
301     * Create a String representation of a name value pair, saying name=value
302     *
303     * @return String representation of a name value pair, saying name=value
304     */
305    public String createNameValueString() {
306        return createNameValueString(false);
307    }
308
309    /**
310     * @param encode flag indicate if the result need to be encoded
311     * @return string representation of name + vale
312     */
313    public String createNameValueString(final boolean encode) {
314        if (encode) {
315            return new UrlParameter(name, value).createNameValueString();
316        }
317        return Joiner.on('=').useForNull("").join(name, value);
318    }
319
320    @Override
321    public int compareTo(final UrlParameter compareTo) {
322        return getName().compareTo(compareTo.getName());
323    }
324}